Repository navigation
CAMEL-25785: camel-aws2-s3 - a timestamp window uploaded more than once is stored in more than one object - #28136
Conversation
…ce is stored in more than one object With streamingUploadMode and timestampGroupingEnabled, the upload of a time window is completed at batchMessageNumber messages (10 by default), batchSize bytes or by streamingUploadTimeout. A later message of the same window started a new multipart upload with the same key, so S3 replaced the object of the first upload and its messages were lost. A later upload of a window now gets the number of the uploads of the window before it after the time range (-1, -2, ...); the first keeps its name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gnodet-bot
left a comment
There was a problem hiding this comment.
The fix correctly addresses the file naming collision that caused data loss when a timestamp window was uploaded multiple times. Good approach with the LinkedHashMap LRU eviction and the test coverage is solid.
Two minor observations:
1. Pre-existing (out of scope): In StreamingUploadTimeoutTask.run(), keysToRemove.add(entry.getKey()) runs outside the try/catch block. If uploadPart or completeUpload throws, the entry is removed from timestampBasedUploads without incrementing completedUploadsByWindow. On the next message for that window, generateTimestampBasedFileName will reuse the name of the first upload (count=0), potentially creating a filename collision. Pre-existing behavior, unrelated to this PR.
2. accessOrder=true iteration hazard (maintenance note): See inline comment on completedUploadsByWindow.
This review was generated by an AI agent, Hermès on behalf of @gnodet.
| // more messages after its upload was completed (batchMessageNumber, batchSize or streamingUploadTimeout); the new | ||
| // object gets a name of its own, as an object with the name of the first one would replace it. Only the windows | ||
| // used last are counted, so a long-running producer does not keep every window it has seen. | ||
| private final Map<Long, Integer> completedUploadsByWindow = new LinkedHashMap<>(16, 0.75f, true) { |
There was a problem hiding this comment.
accessOrder=true (the third constructor argument) makes every get() a structural modification of the map. The existing comment notes it’s “guarded by the lock”, which keeps the current merge() and getOrDefault() calls safe. Consider adding a warning comment here so future maintainers don’t inadvertently call get() inside an iteration loop—it would throw ConcurrentModificationException.
There was a problem hiding this comment.
Thanks. Every access to the map is under the producer's lock and nothing iterates over it (only merge and getOrDefault), so the access order is safe as it is; the field comment already says the lock guards it. I can spell out "no iteration" there if a maintainer prefers.
On the pre-existing point in the review: a timeout flush that fails is not counted, but a failed flush stores no object either (the multipart upload is not completed), so the next upload of that window takes the name the failed one would have had, and nothing is replaced.
Claude Code on behalf of allthingssecurity
|
🌟 Thank you for your contribution to the Apache Camel project! 🌟 🐫 Apache Camel Committers, please review the following items:
|
davsclaus
left a comment
There was a problem hiding this comment.
Thanks for this. Within one producer instance the fix is correct: every map access is under lock, the timer counts an upload only after completeUpload succeeds, and the new tests fail without the fix.
Requested change: the restart and second-instance case. After a restart the producer is a new instance (SendProcessor.doStart creates a new one each time), so the count starts again at 0. A restart in the middle of a window still loses data: doStop flushes the open window to ..._0800-0805.txt, and the first upload of that window after the restart writes to the same key. Two producers with the same keyName collide the same way, because the window name is the same on both. #28137 fixes exactly this restart and second-instance case for the random strategy, so leaving it open here would be inconsistent. Please fix it in this PR. Some options:
- (a) The first time a producer sees a window, list the objects with that window's key prefix (
ListObjectsV2, assetStartingPartalready does forlastPart) and start the count from there. That costs one request per window per producer and fixes restarts, but not concurrent producers. - (b) Fail closed with a conditional write:
CompleteMultipartUploadRequest.ifNoneMatch("*")(S3-compatible stores may not support it).
(a) is probably enough if the docs say that concurrent producers on the same keyName still need distinct key names.
Backport: JIRA lists 4.18.x and 4.22.x as affected, but this changes object names users see, so I'd keep it on main.
Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.
| // more messages after its upload was completed (batchMessageNumber, batchSize or streamingUploadTimeout); the new | ||
| // object gets a name of its own, as an object with the name of the first one would replace it. Only the windows | ||
| // used last are counted, so a long-running producer does not keep every window it has seen. | ||
| private final Map<Long, Integer> completedUploadsByWindow = new LinkedHashMap<>(16, 0.75f, true) { |
There was a problem hiding this comment.
Windows arrive in time order, so insertion order is enough for evicting old windows. Dropping accessOrder also makes getOrDefault a plain read, which settles the iteration concern in the thread above.
| private final Map<Long, Integer> completedUploadsByWindow = new LinkedHashMap<>(16, 0.75f, true) { | |
| private final Map<Long, Integer> completedUploadsByWindow = new LinkedHashMap<>() { |
There was a problem hiding this comment.
Done in 00f3909: the map is insertion-ordered now (new LinkedHashMap<>() with removeEldestEntry), so a lookup no longer reorders it.
Claude Code on behalf of allthingssecurity
| } | ||
|
|
||
| private Runnable timeoutTask() throws Exception { | ||
| when(configuration.getStreamingUploadTimeout()).thenReturn(60000L); |
There was a problem hiding this comment.
Nit: StreamingUploadTimeoutTask.run() does not read getStreamingUploadTimeout(), so this stub is not needed.
There was a problem hiding this comment.
Right, removed in 00f3909.
Claude Code on behalf of allthingssecurity
| `grouped-messages_20240101_0800_0800-0805.txt` for the window from 08:00 to 08:05 (UTC). The upload of a window is | ||
| completed when it reaches `batchMessageNumber` messages or `batchSize` bytes, or when `streamingUploadTimeout` runs. When | ||
| more messages of that window come after it, they are uploaded to a new object of the window, named with the number of | ||
| the uploads of the window before it: `grouped-messages_20240101_0800_0800-0805-1.txt`, then `-2`, and so on. |
There was a problem hiding this comment.
Worth one sentence here: these keys do not sort in upload order. ...0805-1.txt sorts before ...0805.txt (- < .), and -10 sorts before -2, so anyone listing a window's objects in lexical order gets them out of order.
There was a problem hiding this comment.
Added in 00f3909: the keys of a window do not sort in upload order (-1 before the first upload's name, -10 before -2), so to read a window in order, sort by the number after the time range. The upgrade guide says the same.
Claude Code on behalf of allthingssecurity
|
🧪 CI tested the following changed modules:
🔬 Scalpel shadow comparison — Scalpel: 9 of 707 tested, 25 compile-only — current: 9 all testedMaveniverse Scalpel detected 9 affected modules (current approach: 9). Skip-tests mode would test 9 modules (3 direct + 8 downstream), skip tests for 25 (generated code, meta-modules) Modules Scalpel would test (9)
Modules with tests skipped (25)
All tested modules (36 modules, 0.0s total)Total reactor time: 0.0s
|
…ow after the objects already in the bucket The first time a producer uploads a timestamp window, it lists the objects of the window (ListObjectsV2 with the window's key as prefix) and continues after the highest upload number stored, so an upload after a restart no longer replaces the object that the stop flushed. Without s3:ListBucket it logs a WARN and numbers from the first upload. The uploads flushed by doStop are counted too. The window count map keeps insertion order (windows come in time order). The docs say that window keys do not sort in upload order and that concurrent producers on the same keyName still need distinct key names. S3StreamUploadTimestampGroupingIT and S3StreamUploadTimestampGroupingEdgeCasesIT now expect the second upload of a window as its own object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. I went with option (a), in 00f3909:
Backport: agreed, main only. CI: Claude Code on behalf of allthingssecurity |
gnodet-bot
left a comment
There was a problem hiding this comment.
Following up on my earlier review — all prior findings addressed in commit 00f3909.
gnodet-bot observations (from first review):
- ✅
accessOrder=truehazard — fixed: map now uses plain insertion-orderednew LinkedHashMap<>(), eliminating the structural-modification-on-get concern. - Pre-existing timeout flush issue (not addressed, correctly out of scope): acknowledged, no action needed.
davsclaus's CHANGES_REQUESTED:
- ✅ Restart-safety — implemented via
completedUploads()+uploadsInBucket(): on first encounter of a window, the producer lists existing bucket objects and continues numbering from the highest stored upload. The result is cached (including on failure, where 0 is stored after the exception), so theListObjectsV2call occurs at most once per window per producer lifetime. - ✅ Insertion-ordered map — confirmed (no
accessOrderargument). - ✅
getStreamingUploadTimeout()stub removed from test. - ✅ Sort-order note added to docs.
Pre-fix test validation: completedUploads()/uploadsInBucket(), a second producer instance would start with empty completedUploadsByWindow, call generateTimestampBasedFileName(..., 0), produce the same key as the first upload, and overwrite it. The fix correctly queries the bucket on first window encounter; anUploadAfterARestartDoesNotReplaceTheObjectsOfTheWindow exercises exactly this path.
One low-severity nit in uploadsInBucket(): the early return 0 in the catch block bypasses the normal return uploads path. This is harmless (the caller completedUploads() caches whatever is returned, including 0), but a future reader might worry about repeated calls on persistent ListObjectsV2 failures. A brief comment noting the caller caches the result would preempt that concern:
LOG.debug("Listing the objects of {} failed", windowKey + ext, e);
return 0; // completedUploads() caches this; no retry storm on persistent failuresOtherwise the second commit is clean. The bounded LinkedHashMap with removeEldestEntry handles long-running producers correctly. uploadNumber() correctly handles adversarial keys (-12-1, wrong extension, non-digit suffixes), confirmed by theNextUploadAfterARestartFollowsTheHighestUploadInTheBucket. Lock discipline in doStop() is consistent — lock.lock() is acquired before the flush loop and covers the new completedUploadsByWindow.merge() call. Docs and upgrade guide are comprehensive.
This review was generated by an AI agent, Hermès on behalf of @gnodet.
|
Thanks for the re-check. On the Claude Code on behalf of allthingssecurity |
Description
CAMEL-25785
With
streamingUploadMode=trueandtimestampGroupingEnabled=true, the aws2-s3 stream upload producer names the object of a time window only after the window (grouped-messages_20240101_0800_0800-0805.txt). The upload of a window is completed when it reachesbatchMessageNumbermessages (10 by default) orbatchSizebytes, or whenstreamingUploadTimeoutruns, and the window is then forgotten. The next message of the same window started a new multipart upload with the same key, and S3 replaced the first object with it. The messages of the earlier uploads were lost (unless the bucket is versioned), and every exchange had completed normally.The example in the component documentation (a message every 10 seconds, 5-minute windows, default
batchMessageNumber) has 30 messages per window: the window is uploaded three times and the bucket keeps only the last 10 messages.Change (in
AWS2S3StreamUploadProducer): the producer counts the completed uploads of each window, under its existing lock. The count lives in an insertion-ordered map that holds the last 10000 windows (windows come in time order). The first time a producer uploads a window, it lists the window's objects in the bucket (ListObjectsV2with the window key as prefix, following continuation tokens) and starts the count at the highest upload stored (the first upload's name counts 1,-Ncounts N+1), so an upload after a restart does not replace the object the stop flushed. If the listing fails (for example withouts3:ListBucket), it logs a WARN and numbers the window from the first upload. The windows flushed bydoStopare counted too. A later upload of a window gets that count after the time range:grouped-messages_20240101_0800_0800-0805-1.txt, then-2, and so on. The first upload of a window keeps the name it had, so a window that is uploaded once is unchanged.What users can notice: the second and later objects of a window have a new key. Upgrade guide (4.24): a note under its own
camel-aws2-s3heading. The naming is also described inaws2-s3-streaming.adoc(and its catalog copy). OneListObjectsV2request per window per producer; it needss3:ListBucket. The keys of a window do not sort in upload order (-1before the first upload's name,-10before-2); the docs say so.Tests:
AWS2S3StreamUploadTimestampWindowOverwriteTest(mockedS3Clientthat keeps completed objects by key, like a bucket).everyMessageOfAWindowIsStoredWhenTheWindowIsUploadedMoreThanOnce(the documentation example) andeveryMessageOfAWindowIsStoredWhenTheTimeoutUploadsTheWindowBeforeItEndsfail without the change (expected: <3> but was: <1>,expected: <2> but was: <1>: the bucket holds one object for the window).theFirstUploadOfAWindowKeepsItsNamepasses with and without the change.AWS2S3StreamUploadProducerTimestampTestcalls the name method through reflection; it now uses the new signature and checks the suffix.anUploadAfterARestartDoesNotReplaceTheObjectsOfTheWindowandtheNextUploadAfterARestartFollowsTheHighestUploadInTheBucket(objects-1,-10,-2in the bucket, next is-11; listing paged two keys at a time) fail without the listing;theUploadsOfAWindowAreNumberedFromTheFirstWhenTheBucketCannotBeListedcovers a 403 on the listing.S3StreamUploadTimestampGroupingIT.testTimestampGroupingBasicandS3StreamUploadTimestampGroupingEdgeCasesIT.testTimestampGroupingWithVeryLargeWindownow expect the exact keys, including the-1object of each window (they asserted the old overwrite).What is not changed: producers that run at the same time with the same
keyNamestill choose the same names for a window, so they still need distinct key names (documented). A failed timeout flush still drops its window with aWARN(as decided in CAMEL-24195). Kept on main only (no backport), as agreed in the review.What was not run: the full root build; no test against S3; the S3 integration tests (no Docker locally).
Target
mainbranch)Tracking
Apache Camel coding standards and style
mvn clean install -DskipTestslocally from root folder and I have committed all auto-generated changes.(I built and tested camel-aws2-s3, including the formatter and import-sort plugins. No option changed; the catalog copy of the edited doc page is updated by hand. I did not run the full root build.)
AI-assisted contributions
Co-authored-bytrailers) and the PR description identifies the AI tool used.This PR was prepared with Claude Code (Claude Opus 5.5). The commits carry a
Co-Authored-Bytrailer.Claude Code on behalf of allthingssecurity
🤖 Generated with Claude Code