[feat][io] Aeron source: Archive-backed lossless mode - #134
[feat][io] Aeron source: Archive-backed lossless mode#134david-streamlio wants to merge 10 commits into
Conversation
Adds an archive mode to the Aeron source that replays from an Aeron Archive recording and checkpoints its position, so a restart resumes instead of losing whatever arrived while the connector was down. Transport mode is unchanged and remains the default. Fixes apache#133 Archive mode is a second AeronPoller implementation rather than a rework, which is what that interface was added for. It lives on the existing connector because pulsar-io.yaml declares at most one sourceClass per NAR, so a separate class would not be discoverable by name and a separate module would re-bundle Aeron. The mode is an explicit `mode: transport | archive`, not a boolean, and archive settings supplied in transport mode are rejected rather than ignored. The two modes have different delivery guarantees, and a config that looks set up for lossless replay while quietly running at-most-once is the worst outcome available. The active mode is logged at open() for the same reason. Checkpointing uses the function state store and records (recordingId, position), not a bare position: archive positions are only monotonic within a recording, and a publisher restart opens a new one numbered from zero. Rotation is followed automatically, since a publisher restart is routine. requireAvailable() fails at open() when stateStorageServiceUrl is absent, because otherwise the connector looks healthy while re-ingesting the whole recording on every restart. A stored checkpoint outranks a configured startPosition — the other order would silently re-ingest for anyone who left a startPosition in their config. resetCheckpoint is the explicit escape hatch for deliberately reprocessing history. It is a one-shot flag and documented as such in the FieldDoc, in the class javadoc, and in a WARN logged on every start, because left in place it re-ingests on every restart and presents as a duplicate-message problem long before anyone suspects a config value. It deletes the stored checkpoint rather than ignoring it, so removing the flag leaves a usable checkpoint behind rather than a stale pre-reset one. Continuous bounded replay rather than ReplayMerge, which deviates from the plan in apache#133 and is flagged there rather than changed quietly. It cannot outrun durability, since only what the archive has already recorded is consumed; it works over IPC, which ReplayMerge cannot serve at all as it needs a multi-destination subscription and a UDP live destination; and the steady-state latency it costs disappears into Pulsar's fsync. Records carry the archive position as getRecordSequence(), so users who enable broker deduplication on the destination topic get effectively-once. That is not promised by the connector, because it depends on user-side configuration the connector cannot verify. Delivery is documented as at-least-once, with checkpointEveryRecords bounding the duplicate window. Two Aeron behaviours found by testing rather than by reading: findLastMatchingRecording matches its sessionId argument literally and does not treat Aeron.NULL_VALUE as a wildcard, so it returns NULL_VALUE unless the caller already knows the session id — which a connector configured with a channel and a stream id never does. Verified against a live archive: NULL_VALUE returns -1 whether the recording is active or stopped, while the real session id returns the recording. Discovery therefore uses listRecordingsForUri, which takes no sessionId and filters server-side. A recording still being written has no stop position; the archive reports NULL_POSITION. Replaying to getStopPosition() would replay nothing at all against a live recording, which is the common case, so it falls back to getRecordingPosition(). Test containers now start with withFunctionsWorker(), which drops the container's default "--no-functions-worker -nss" and brings up the stream storage service the state store needs. The archive tests back their SourceContext with real in-memory state rather than a bare mock, which would have swallowed every checkpoint and made the resume assertions vacuous. Tests: 130 in the module, 49 new. Thirteen integration tests run against a real in-process ArchivingMediaDriver, including a restart that resumes mid-stream and delivers only what arrived while the source was down, messages published before the source existed, larger-than-MTU reassembly on replay, and both directions of the checkpoint-versus-startPosition precedence. Known gap: no Aeron Archive deployment was available to test against, so coverage does not exercise control-channel configuration against a separately deployed archive, or reconnection to one.
…he tenant CI failed in AeronSourceContainerTest.setUp with "Namespace not found" while all 130 tests passed locally. The setup waited for the "public" tenant and then immediately created a producer on persistent://public/default/..., but bootstrap creates the namespace after the tenant. Waiting on the tenant was never sufficient; enabling the functions worker in the previous commit shifted startup timing enough to expose it, and CI is slower than a laptop so it surfaced there first. Now waits for public/default to exist as well. Applied to both container tests, since both got the functions worker.
There was a problem hiding this comment.
Pull request overview
Adds an Aeron Archive-backed source mode with replay, checkpointing, recording rotation, and configuration validation.
Changes:
- Adds archive replay and state-store checkpointing.
- Extends source configuration, metadata, and dependencies.
- Adds archive integration/config tests and updates container setup.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
gradle/libs.versions.toml |
Registers Aeron Archive dependency. |
aeron/build.gradle.kts |
Bundles Aeron Archive. |
AeronSource.java |
Selects transport or archive mode. |
ArchivePollingRunner.java |
Implements archive replay and rotation. |
ArchiveCheckpoint.java |
Persists replay checkpoints. |
AeronSourceConfig.java |
Adds archive configuration and validation. |
AeronRecord.java |
Adds recording metadata and sequences. |
AeronArchiveSourceIntegrationTest.java |
Tests archive replay and checkpoints. |
AeronSourceConfigTest.java |
Tests archive configuration. |
AeronSourceContainerTest.java |
Enables state storage in containers. |
AeronSinkContainerTest.java |
Aligns container bootstrap setup. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Eight findings from review, all verified against the code and the Aeron/Pulsar APIs before changing anything. Two invalidated the advertised guarantee. 1. At-least-once was false as written. consume() only queues a record; the framework publishes and acks later, so checkpointing at enqueue time committed positions for records still in memory. A crash in that window resumed PAST records that were never published — silent loss, in the mode meant to prevent it. Replay cursor and commit watermark are now separate: CommitTracker turns out-of-order acks into a contiguous acknowledged prefix, and only that is checkpointed. A failed record stalls the watermark rather than being stepped over, so a restart replays it. 2. The archive position was exposed as getRecordSequence() for broker dedup, but positions restart with each recording. After a rotation the same producer would emit decreasing sequence ids and dedup would discard the new recording as already-seen — an optional optimisation turned into active message loss. The sequence is removed; the position remains available as metadata. Doing this safely needs a sequence monotonic across rotation, which is follow-up work. Noted in apache#133 as a hazard and then implemented anyway; caught in review. 3. listRecordingsForUri takes an inclusive recording ID, not an offset. Advancing by the match count re-read the same page whenever matching IDs had gaps, looping forever. Now steps past the highest ID each page returned. 4/5. A recording does not necessarily begin at position 0, and neither does a successor after rotation. Both now read the descriptor's start position rather than assuming zero, which would have failed the replay asynchronously. 6. An unexpected failure killed the poll thread while AeronSource stayed open and read() blocked forever, so a dead connector reported itself healthy. Now reports through SourceContext.fatal() so the runtime can restart the instance, which resumes from the checkpoint. 7. clear() swallowed delete failures, so a failed reset could resurrect the stale position. Deleting an absent key can legitimately throw, so the outcome is now verified by reading back rather than inferred from the exception, and a checkpoint that survives deletion fails open(). 8. The test stub implemented getState and putState but left deleteState as Mockito's no-op, so every reset test would have passed even if clear() deleted nothing. Now modelled. The tests could not have caught finding 1: the reader collected records without ever acknowledging them, modelling a world where publishing always succeeds instantly. It now acks as the framework does, and a test withholds acks to assert nothing is checkpointed while records are unpublished. Tests: 134 in the module, 4 new covering the unacknowledged-record case, the acknowledged-prefix watermark, reset failing loudly when deletion does not take, and reset actually deleting. Findings 3, 4 and 5 are fixed but not directly covered: they need a catalog with ID gaps beyond one page, and recordings with non-zero start positions, neither of which the in-process archive makes convenient to construct.
…er a chunk CI failed testResetActuallyDeletesTheStoredCheckpoint waiting for a checkpoint that never arrived. The test was fine; the loop was wrong. writeCheckpoint was only attempted after replaying a chunk. Acknowledgements arrive on framework threads, so the commit watermark advances asynchronously — and once the source has caught up there is no further chunk to trigger a write. If the acks landed a moment after that last attempt, the committed position was never persisted until shutdown. Locally the acks happened to land in time; CI's slower scheduling did not. The consequence in production is a stale checkpoint rather than lost data: a crash would re-replay from an older position than the source had actually committed. That is safe but wasteful, and on a recording that stops growing the checkpoint could stay stale indefinitely. The loop now attempts a checkpoint on every pass, including idle ones, and writeCheckpointIfAdvanced skips the write unless the watermark actually moved, so idle passes stay cheap. Verified by running the archive suite three times from clean rather than once — the original failure was timing-dependent and a single green pass proves little.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Suppressed comments (2)
aeron/src/main/java/org/apache/pulsar/io/aeron/AeronSource.java:52
- This documentation still promises a record sequence backed by archive position, but the implementation and
testNoRecordSequenceIsExposeddeliberately removed it because positions decrease across recording rotation. Document archive mode as at-least-once and state that positions are metadata only, otherwise users may enable broker deduplication expecting a guarantee the connector no longer provides.
* <li><b>{@code archive}</b> replays from an Aeron Archive recording, so a position in durable
* storage exists and data missed while the connector was down can be re-read. Records carry
* the archive position as their record sequence, which broker deduplication can use.
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:303
- Calling
writeCheckpointIfAdvanced()on every loop pass bypassescheckpointEveryRecordsonce replay has caught up. As acknowledgements arrive asynchronously, each newly advanced acknowledgement batch can trigger a remote state-store write even with the default interval of 1000, so the configured write-cost tradeoff is not honored. Track newly committed records (rather than emitted records) and apply the interval to normal writes, while retaining explicit final/rotation flushes.
// Attempted on every pass, including idle ones. Acknowledgements arrive on
// framework threads, so the watermark can advance long after the last record was
// read — and once caught up there is no further chunk to trigger a write. Only
// checkpointing after a chunk left the committed position unpersisted until
// shutdown whenever the acks landed a moment too late.
writeCheckpointIfAdvanced();
Three findings from the re-review, all in code the first review never saw — the CommitTracker work landed after it. All three verified before changing anything. Rotation could drop in-flight records. advanceRecordingIfFinished switched recordings as soon as the replay cursor reached the stop position, even with records still awaiting acknowledgement, and resetting the tracker discarded them. Worse, because positions restart in the successor, a late acknowledgement for an old position could collide with a successor position and advance ITS watermark, skipping unpublished successor records after a crash. Rotation now waits for the pending count to drain and persists the old recording's final watermark before switching. A record the framework never acknowledges holds rotation indefinitely, which is deliberate — advancing would skip unpublished data — so it warns on a throttled interval rather than stalling silently. read() turned every state-store exception into "no checkpoint". A transient read failure therefore restarted from configured history and silently re-ingested the recording, which is the exact failure this mode exists to prevent. It now returns empty only for a confirmed absent or truncated value and propagates store failures. clear() no longer mistakes "delete failed and verification also failed" for a successful reset; it fails and says the outcome is unknown. recordingStartPosition ignored listRecording's return value. A configured recordingId that does not exist produced a synthetic position 0, after which every position query answered NULL_POSITION and the loop idled forever while the source reported itself healthy and emitted nothing. It now fails at open(). Tests: 136 in the module, 2 new — a nonexistent recordingId failing fast, and an unreadable checkpoint store failing rather than re-ingesting. Archive suite run three times from clean, since the last two defects here were timing-dependent.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
aeron/src/main/java/org/apache/pulsar/io/aeron/AeronSource.java:52
- This documents a sequence ID that archive records deliberately no longer expose.
ArchivePollingRunnerandtestNoRecordSequenceIsExposedexplicitly keepgetRecordSequence()empty because positions decrease across recording rotation. Leaving this claim tells users broker deduplication is safe when enabling it could instead drop data; document the actual at-least-once behavior and metadata properties.
* <li><b>{@code archive}</b> replays from an Aeron Archive recording, so a position in durable
* storage exists and data missed while the connector was down can be re-read. Records carry
* the archive position as their record sequence, which broker deduplication can use.
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:317
- This unconditional idle-loop call bypasses
checkpointEveryRecords: whenever acknowledgements arrive asynchronously after replay catches up, each watermark advance can cause another state-store write. Thus the default can write nearly once per record rather than once per 1000 records. Gate normal writes on acknowledged progress reaching the configured interval, while retaining forced flushes for rotation and shutdown.
// Attempted on every pass, including idle ones. Acknowledgements arrive on
// framework threads, so the watermark can advance long after the last record was
// read — and once caught up there is no further chunk to trigger a write. Only
// checkpointing after a chunk left the committed position unpersisted until
// shutdown whenever the acks landed a moment too late.
writeCheckpointIfAdvanced();
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:190
- The checkpoint path does not verify that its recording still exists. If archive retention removed the checkpointed recording (or the archive was replaced), both position queries return
NULL_POSITION; the loop then treats it as an active recording and idles forever while the source appears healthy. Validate the descriptor duringopen()just as the explicitrecordingIdpath does, and fail rather than silently hanging when durable history is gone.
if (stored.isPresent()) {
currentRecordingId = stored.get().recordingId();
currentPosition = stored.get().position();
aeron/src/test/java/org/apache/pulsar/io/aeron/AeronArchiveSourceIntegrationTest.java:472
- This test does not exercise the advertised mid-recording restart: it waits until every record is delivered and acknowledged, closes cleanly, then
recordMessages(second)creates a separate successor recording. The core resume case—checkpoint partway through an active recording, publish more while the source is down, then resume that same recording—remains untested. Stop after an acknowledged prefix while keeping the recording active, publish the remainder, and assert the restart emits only that remainder.
public void testResumesFromCheckpointAfterRestart() throws Exception {
// The property this whole mode exists for: a restart continues rather than re-ingesting.
List<String> first = new ArrayList<>();
for (int i = 0; i < 50; i++) {
first.add("first-" + i);
}
recordMessages(first);
Map<String, Object> config = archiveConfig();
// Checkpoint aggressively so the restart has something recent to resume from.
config.put("checkpointEveryRecords", 1);
startSource(config);
awaitRecords(first.size());
aeron/src/main/java/org/apache/pulsar/io/aeron/AeronSourceConfig.java:260
recordingIdandstartPositionvalues below the documented-1sentinel are accepted, butresumeOrStart()treats every negative value as unset via>= 0. For example,startPosition: -2silently starts at the recording beginning instead of rejecting an invalid operator value. Validate both fields as>= -1so mistyped positions are not ignored.
if (isArchiveMode()) {
requireArchiveField(archiveControlRequestChannel, "archiveControlRequestChannel");
requireArchiveField(archiveControlResponseChannel, "archiveControlResponseChannel");
requireArchiveField(replayChannel, "replayChannel");
Third-round review finding, against the current head this time. Stalling the commit watermark on a failed publish stopped the failure being checkpointed past, but nothing re-emitted the record: the replay cursor had already moved on, so the message stayed absent from Pulsar while the source went on looking healthy. Only an external restart would have retried it, and nothing triggered one. That is not at-least-once. A failed record now also fails the source via SourceContext.fatal(), so the runtime restarts the instance and the stalled watermark decides where replay resumes. Same approach as the Kinesis source, which does this for the same reason. CommitTracker keeps stalling the watermark and its javadoc now says plainly that this is only half the answer — it holds the position, the caller decides the policy. Tests: 137 in the module, 1 new asserting a failed publish reports fatal and leaves nothing checkpointed.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchiveCheckpoint.java:104
- A truncated checkpoint is present but unreadable; treating it as absent starts from configured history and silently re-ingests the recording, exactly the failure this class otherwise avoids for state-read errors. Fail startup on a malformed checkpoint instead of returning empty.
if (buffer.remaining() < SERIALIZED_BYTES) {
LOG.warn("Ignoring a truncated archive checkpoint under {} ({} bytes, expected {})",
key, buffer.remaining(), SERIALIZED_BYTES);
return Optional.empty();
aeron/src/main/java/org/apache/pulsar/io/aeron/AeronSource.java:52
- This documents a record sequence that archive mode deliberately does not expose:
ArchivePollingRunnersays no sequence is set, andtestNoRecordSequenceIsExposedasserts it is empty. Leaving this claim directs operators to enable deduplication that cannot work. Document the at-least-once checkpoint semantics and position metadata instead.
* <li><b>{@code archive}</b> replays from an Aeron Archive recording, so a position in durable
* storage exists and data missed while the connector was down can be re-read. Records carry
* the archive position as their record sequence, which broker deduplication can use.
aeron/src/main/java/org/apache/pulsar/io/aeron/AeronSourceConfig.java:304
- Comparing primitives with their defaults cannot distinguish “unset” from an explicitly supplied default. For example,
mode: transportwithcheckpointEveryRecords: 1000,resetCheckpoint: false,recordingId: -1, orstartPosition: -1passes validation, contrary to the stated guarantee that archive-only keys are rejected rather than ignored. Preserve key presence during deserialization (or use nullable fields and apply defaults after validation) so every supplied archive-only setting is detected.
if (recordingId != -1L) {
unexpected.add("recordingId");
}
if (startPosition != -1L) {
unexpected.add("startPosition");
}
if (checkpointEveryRecords != DEFAULT_CHECKPOINT_EVERY_RECORDS) {
unexpected.add("checkpointEveryRecords");
}
if (resetCheckpoint) {
unexpected.add("resetCheckpoint");
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:195
- A checkpoint’s recording ID is trusted without checking that the recording still exists. If archive retention removes that recording, both position queries return
NULL_POSITION; the run loop then treats it as an active recording and idles forever while the source appears healthy. Validate the checkpoint recording descriptor duringopen()just as the explicitrecordingIdpath does.
if (stored.isPresent()) {
currentRecordingId = stored.get().recordingId();
currentPosition = stored.get().position();
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:322
- This unconditional idle-pass write bypasses
checkpointEveryRecords. In the steady-state trickle case, each acknowledgement advances the watermark and the next loop writes state, so the default of 1000 can still cause one state-store write per record. Count committed records since the last persisted watermark and gate ordinary writes on that count, while retaining forced flushes for rotation and shutdown.
// Attempted on every pass, including idle ones. Acknowledgements arrive on
// framework threads, so the watermark can advance long after the last record was
// read — and once caught up there is no further chunk to trigger a write. Only
// checkpointing after a chunk left the committed position unpersisted until
// shutdown whenever the acks landed a moment too late.
writeCheckpointIfAdvanced();
…red padding Fourth-round review finding, against the current head. The replay cursor advanced only from delivered fragment headers, but poll() advances an image over padding frames without invoking the handler. A recording ending in padding therefore reaches end-of-stream with the cursor still short of the bound, and the outer loop replays the same padding-only range forever, spinning and never rotating. The cursor now advances to what the image actually consumed. This is safe precisely because the read cursor and the commit watermark are separate: it moves only what has been read, while the checkpoint still tracks what has been acknowledged, so nothing unpublished can be committed past. Also adds the first direct test of recording rotation, which until now was implemented and shipped on reasoning alone. What that test does NOT prove, checked rather than assumed: it passes against a build with the cursor fix disabled. Padding in the middle of a recording is stepped over by the next fragment's header and cannot strand the cursor. Only trailing padding can, and producing it needs recording to stop in the window between a padding frame and the message that follows. The livelock therefore remains reasoned, not reproduced, and the test is named for what it actually covers. Tests: 138 in the module, 1 new.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Suppressed comments (4)
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:196
- A stored checkpoint bypasses the descriptor existence check used for configured recording IDs. If archive retention has removed that recording, both position queries return
NULL_POSITION,advanceRecordingIfFinished()treats it as still active, and the source idles forever while appearing healthy. Validate the checkpoint's recording duringopen()so this fails loudly instead.
currentRecordingId = stored.get().recordingId();
currentPosition = stored.get().position();
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:323
- This unconditional call defeats
checkpointEveryRecords: while caught up, each acknowledgement can advance the watermark and cause a state-store write on the next idle pass, regardless of the configured interval. A failed write is likewise retried and logged on every pass, potentially hammering an unavailable state service and flooding logs. Gate normal writes by the configured committed-record interval, with a separately forced flush for catch-up/rotation/shutdown and backoff for failures.
// Attempted on every pass, including idle ones. Acknowledgements arrive on
// framework threads, so the watermark can advance long after the last record was
// read — and once caught up there is no further chunk to trigger a write. Only
// checkpointing after a chunk left the committed position unpersisted until
// shutdown whenever the acks landed a moment too late.
writeCheckpointIfAdvanced();
aeron/src/main/java/org/apache/pulsar/io/aeron/AeronSource.java:52
- This documentation still promises a record sequence for broker deduplication, but archive records intentionally expose no sequence (
testNoRecordSequenceIsExposedandArchivePollingRunnerdocument why). Users could enable deduplication expecting effectively-once behavior that is not provided. State the at-least-once behavior and the absence of a deduplication sequence instead.
* <li><b>{@code archive}</b> replays from an Aeron Archive recording, so a position in durable
* storage exists and data missed while the connector was down can be re-read. Records carry
* the archive position as their record sequence, which broker deduplication can use.
aeron/src/main/java/org/apache/pulsar/io/aeron/AeronSourceConfig.java:271
- Only
-1is documented as the sentinel forrecordingIdandstartPosition, but other negative values pass validation and are silently treated as unset byresumeOrStart(). A typo such asrecordingId: -2can therefore select the newest recording instead of failing, potentially replaying the wrong range. Reject values below-1explicitly.
if (checkpointEveryRecords <= 0) {
throw new IllegalArgumentException(
"checkpointEveryRecords must be positive but was: " + checkpointEveryRecords);
Two findings against 840fd34, both real, both data loss. The FragmentAssembler was created per chunk and discarded at the end of it. On a live recording the chunk bound comes from getRecordingPosition(), which can fall between fragments of a larger-than-MTU message: the assembler held the BEGIN fragment, was thrown away, and the next chunk started on a continuation fragment with a fresh assembler that dropped it. The message vanished silently. This one was created by the previous commit. Before the padding fix the cursor advanced only from delivered fragment headers, so it always sat on a complete-message boundary; advancing to the image position moved it to a fragment boundary and opened the window. The assembler is now held across chunks and reset only when the cursor moves to a different recording, since a partial message cannot span one. Second, concurrent publishers on one channel and stream were silently reduced to one. Transport mode receives from every publication; the archive records each session separately, so concurrent publishers produce concurrent recordings and this replays one at a time. The others would never be replayed, and because discovery only moves to higher recording ids they could not be picked up later either. Archive mode now requires a single active recording and fails at open() with the recording ids and what to do about it, rather than dropping a publisher's data. Historical recordings are unaffected: rotation walks them in id order. Tests: 139 in the module, 1 new. The concurrent-recordings guard was verified by running its test against a build with the guard disabled, where it fails as intended. The assembler fix is NOT directly tested: it needs the chunk bound to land between fragments of a fragmented message on a live recording, which depends on archive write timing and cannot be forced reliably here. Reasoned, not reproduced, and flagged as such.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (6)
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:347
- This only verifies that the explicit recording exists. A valid ID belonging to another channel or stream is accepted and replayed, while
onFragment()labels every record with the configured channel/stream and successor discovery switches back to that configured stream. Validate the descriptor's stream and channel here, or derive the emitted metadata and rotation scope from the descriptor, to avoid ingesting and mislabeling the wrong recording.
final int found = archive.listRecording(recordingId,
(controlSessionId, correlationId, id, startTimestamp, stopTimestamp,
startPosition, stopPosition, initialTermId, segmentFileLength, termBufferLength,
mtuLength, sessionId, streamId, strippedChannel, originalChannel,
sourceIdentity) -> start.set(startPosition));
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchiveCheckpoint.java:104
- A truncated value is not a confirmed absent checkpoint. Treating corruption as empty restarts from configured history and silently re-ingests data—the same fail-open behavior this class avoids for read exceptions. Fail startup and require an explicit
resetCheckpointinstead.
if (buffer.remaining() < SERIALIZED_BYTES) {
LOG.warn("Ignoring a truncated archive checkpoint under {} ({} bytes, expected {})",
key, buffer.remaining(), SERIALIZED_BYTES);
return Optional.empty();
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:268
- A stored checkpoint bypasses
recordingStartPosition(), so its recording ID is never checked for existence. If archive retention has removed that recording, both position queries returnNULL_POSITION; the loop then treats it as an active recording and idles forever while the source appears healthy. Validate the checkpoint's recording descriptor (and position range) duringopen()and fail with reset/recovery guidance when it is no longer replayable.
if (stored.isPresent()) {
currentRecordingId = stored.get().recordingId();
currentPosition = stored.get().position();
LOG.info("Resuming Aeron archive replay from checkpoint: recordingId={} position={}",
currentRecordingId, currentPosition);
aeron/src/main/java/org/apache/pulsar/io/aeron/ArchivePollingRunner.java:393
- This unconditional call bypasses
checkpointEveryRecords: any acknowledgement that advances the watermark is persisted on the next loop pass, so a caught-up low-volume stream can perform one state-store write per record even with the default of 1000. Gate normal-loop writes by the configured threshold; rotation and thefinallyblock can still force boundary/final checkpoints.
writeCheckpointIfAdvanced();
aeron/src/main/java/org/apache/pulsar/io/aeron/AeronSource.java:52
- This documents a deduplication capability that the implementation deliberately removed:
ArchivePollingRunnernever supplies a record sequence, andtestNoRecordSequenceIsExposedrequires it to be empty because archive positions reset on rotation. As written, users may enable broker deduplication expecting effectively-once delivery even though it cannot operate on these records. Update this contract (and the matching PR description) to describe the position/recording-id properties instead.
* <li><b>{@code archive}</b> replays from an Aeron Archive recording, so a position in durable
* storage exists and data missed while the connector was down can be re-read. Records carry
* the archive position as their record sequence, which broker deduplication can use.
aeron/src/main/java/org/apache/pulsar/io/aeron/CommitTracker.java:79
- The contiguous-prefix algorithm specifically promises correctness for out-of-order framework acknowledgements, but the integration reader acknowledges records serially in emission order and there is no
CommitTrackerunit test. Add focused tests for out-of-order acknowledgements, a gap/failure blocking later acknowledgements, reset, and duplicate callbacks so this checkpoint-safety invariant is exercised directly.
synchronized void acked(long position) {
acked.add(position);
while (!emitted.isEmpty() && acked.remove(emitted.peekFirst())) {
committed = emitted.removeFirst();
anyCommitted = true;
…ng it CI failed testStartsFreshWhenNoCheckpointExists on "running should have produced a checkpoint". The product is fine; the assertion raced. awaitRecords() returns once records are COLLECTED, but the test reader acknowledges after collecting and the checkpoint is written after that, on the poller thread. Asserting the checkpoint immediately races that chain, which is why it passed locally and failed on slower CI hardware. Four assertions had this shape, two of them checking while the source was still running. All now use a shared awaitCheckpoint() helper, whose javadoc says why asserting directly is wrong so the pattern is not reintroduced. The three remaining direct assertions check for ABSENCE of a checkpoint, which is the safe direction, and each already has a settle period. Verified by running the archive suite four times from clean rather than once, since the failure was timing-dependent.
…workaround Sixth-round review finding, and it was about text I wrote. rejectConcurrentRecordings() told the user to "set 'recordingId' explicitly and run one source per recording". That cannot work. The guard runs before the start position is resolved, so it rejects a pinned configuration too — and even if it did not, pinned runners auto-advance on rotation and would walk into each other's recordings. The hard failure stays; the advice goes. Concurrent publishers on one channel and stream are now documented as unsupported in archive mode, with the two remedies that do work: a distinct channel or streamId per publisher, or mode: transport, which genuinely does receive from every publication. Stated in the three places someone meets it — the exception message, the class javadoc, and the mode FieldDoc, which is what the generated config reference shows. A test asserts the message does not offer the pinned-recordingId workaround again.
Design question for reviewers: continuous replay vs
|
| Defect | Why chunking caused it |
|---|---|
FragmentAssembler discarded per chunk → message lost |
A chunk bound can fall between fragments of a larger-than-MTU message |
| Replay cursor stranded by padding → livelock | poll() advances the image over padding without invoking the handler, so a cursor driven by delivered headers never reaches the chunk bound |
| Rotation racing in-flight records → data loss | Recording switch happens at a chunk boundary while acknowledgements are still outstanding |
| Checkpoint only written after a chunk → stale checkpoint | Acknowledgements land asynchronously; once caught up there is no further chunk to trigger a write |
Chunk bound taken from getRecordingPosition() on a live recording |
The bound is whatever is recorded at that instant, which is not a message boundary |
ReplayMerge has a single continuous subscription. None of those five failure modes exist in
it — there is no chunk boundary to land mid-message, no per-chunk assembler, no bound to compute,
and no gap between chunks for a rotation to race into.
So the honest summary of the tradeoff is: this PR accepted five correctness defects in
hand-rolled replay in order to keep IPC support and avoid reading ahead of durability. All five are
fixed, but they were found by review rather than by design, and two of them were introduced by the
fix for a previous one.
What I am asking
Whether that trade is the right one is a maintainer's call, not mine. Three ways it could go:
- Keep continuous replay. The three original reasons are real, and IPC support is not
hypothetical — the tests and the demo harness both useaeron:ipc. The defects are fixed. If
this is the answer, I would want the replay loop's invariants written down and property-tested
(see below), because they currently live only in my head and in review comments. - Switch to
ReplayMerge. Removes the whole class of chunk-boundary bugs by delegating to code
Aeron maintains and tests. Costs IPC support in archive mode, means the connector can read ahead
of what is durably recorded, and is a substantial rewrite ofArchivePollingRunner— with its
own new review surface. - Both, chosen by channel.
ReplayMergefor UDP, chunked replay for IPC. Best behaviour,
worst maintenance: two replay paths, and the chunked one keeps every failure mode above while
getting less real-world exercise.
My own view, having written option 1 and lived through its review: I would not choose it again
without a strong IPC requirement. I underweighted how much hand-rolled cursor management there
was, and how many ways a chunk boundary can be wrong. But I am close to it and may be
over-correcting from a rough review cycle, which is precisely why it wants another opinion.
Regardless of which way it goes
Three paths remain fixed-but-not-reproduced, because I could not construct them reliably against
an in-process archive:
- Catalog paging with recording-ID gaps spanning more than one page.
- Recordings with a non-zero start position, in both the initial and rotation paths.
- Trailing padding stranding the replay cursor — I verified my rotation test still passes with
that fix disabled, so it does not cover it.
And the tests have a systematic blind spot worth fixing whichever design survives: the harness
acknowledges records synchronously, immediately and successfully, which is exactly the shape
that hid the acknowledgement-timing bugs. All three CI-only failures on this PR were the same
mistake appearing in the harness rather than the code. A property-style harness that can produce
delayed, out-of-order and failing acknowledgements would have caught most of this class. Written up
in more detail on #133.
Also worth a reviewer's judgement
Archive mode does not support concurrent publishers on one channel and stream — the archive
records each session separately and this replays one recording at a time, so it fails at startup
when more than one is active. Transport mode has no such limit. That asymmetry is documented and
deliberate, but it is a real semantic gap between the two modes and reasonable people might want it
solved rather than documented.
Fixes #133
Motivation
The Aeron source added in #128 is at-most-once, and that is inherent to what it reads: plain
Aeron is a transport with no persistence and no resumable position. Anything published while the
connector is restarting is simply gone. For a bridge whose purpose is getting data into durable
storage, that is the limitation people ask about first, and it blocks using the connector as a
system of record rather than a best-effort tap.
This adds an archive mode that replays from an Aeron Archive recording and checkpoints its
position, so a restart resumes rather than losing the gap. Transport mode is unchanged and remains
the default.
Modifications
Archive mode is a second
AeronPollerimplementation rather than a rework — which is what thatinterface was introduced for in #128:
It lives on the existing connector rather than a new one because
pulsar-io.yamldeclares atmost one
sourceClassper NAR — true of every module in this repo. A second class would not bediscoverable by name, and a second module would re-bundle Aeron.
The mode is explicit, not a boolean.
mode: transport | archive, logged atopen(), witharchive-only settings rejected in transport mode rather than ignored. The two modes have
materially different delivery guarantees, and a config that looks set up for lossless replay while
quietly running at-most-once is the worst outcome available here.
Checkpointing uses the function state store and records
(recordingId, position), not a bareposition: archive positions are only monotonic within a recording, and a publisher restart opens
a new one numbered from zero. Recording rotation is followed automatically, since a publisher
restart is routine rather than exceptional.
ArchiveCheckpoint.requireAvailable()fails atopen()whenstateStorageServiceUrlis absent — without that check the connector looks healthywhile re-ingesting the whole recording after every restart.
Precedence, and the escape hatch. A stored checkpoint outranks a configured
startPosition;the other order would silently re-ingest for anyone who left a
startPositionin their config.resetCheckpointis the explicit way to reprocess history. It is one-shot, and documented as suchin the
@FieldDoc(so it reaches the generated config reference), in the class javadoc, and in aWARNlogged on every start — because left in place it re-ingests on every restart and presentsas a duplicate-message problem long before anyone suspects a config value. It deletes the stored
checkpoint rather than ignoring it, so removing the flag afterwards leaves a usable checkpoint
rather than a stale pre-reset one.
Delivery is at-least-once. The checkpoint is written after records go downstream, so a crash in
between replays that window;
checkpointEveryRecords(default 1000) bounds it. Records carry thearchive position as
getRecordSequence(), so enabling broker deduplication on the destinationtopic yields effectively-once — but that depends on user-side configuration the connector cannot
verify, so it is documented rather than promised.
Deviation from the plan in #133, flagged rather than made quietly
The issue proposes
ReplayMerge. This uses continuous bounded replay instead — repeatedlyreplaying
[checkpoint, current recorded position)and advancing. Reasons, in order of weight:connector never publishes to Pulsar something not yet durable in the archive.
ReplayMergerequires a multi-destination subscription and a UDP livedestination, so it cannot serve an
aeron:ipcchannel at all.microseconds on fsync.
ReplayMergeremains the right answer for a latency-sensitive consumer and is worth revisiting ifone appears. Discussion is on the issue.
Two Aeron behaviours worth knowing, found by testing rather than by reading
findLastMatchingRecordingmatchessessionIdliterally. It does not treatAeron.NULL_VALUEas a wildcard, so it returnsNULL_VALUEunless the caller already knows thesession id — which a connector configured with a channel and a stream id never does. Probed against
a live archive:
NULL_VALUEreturns-1whether the recording is active or stopped, while the realsession id returns the recording. Discovery therefore uses
listRecordingsForUri, which takes nosession id and filters server-side.
A recording still being written has no stop position — the archive reports
NULL_POSITION. Soreplaying up to
getStopPosition()would replay nothing against a live recording, which is thecommon case rather than an edge one. It falls back to
getRecordingPosition().Verifying this change
130 tests in the module, 49 new, all passing locally along with
spotlessJavaCheckand the NARbuild. The connector doc generator was run to confirm the new
@FieldDocfields render.ArchivingMediaDriver: a restart thatresumes mid-stream and delivers only what arrived while the source was down; messages published
before the source existed; larger-than-MTU reassembly on replay; recording-id and archive
position on every record; both directions of the checkpoint-versus-
startPositionprecedence;resetCheckpointreprocessing and rewriting rather than merely skipping; and fail-fast when norecording matches.
and every archive setting rejected in transport mode.
withFunctionsWorker(), which drops the container's default--no-functions-worker -nssand brings up the stream storage service the state store needs. Thearchive tests back their
SourceContextwith real in-memory state rather than a bare mock —which would have swallowed every checkpoint and made the resume assertions vacuous.
Known gap, stated plainly: no Aeron Archive deployment was available to test against, so
coverage does not exercise control-channel configuration against a separately deployed archive, or
reconnection to one. The in-process driver proves the record/replay/checkpoint cycle, not a
realistic deployment topology.
Does this pull request potentially affect one of the following parts?
Adds
io.aeron:aeron-archive(Apache-2.0, same version as the existingaeron-client/aeron-driver) to theaeronmodule only. No existing default changes:modedefaults totransport, which is exactly today's behaviour.Deployment note for anyone using archive mode: it needs a running Aeron Archive, and
stateStorageServiceUrlconfigured on the cluster for checkpointing. Both fail loudly atopen()rather than degrading silently.
Documentation
doc-requireddoc-not-neededConfig is documented via
@FieldDoconAeronSourceConfig, which thedocsmodule generates theconfig reference from; verified by running the generator. Delivery semantics per mode, the start
precedence, and the
ReplayMergerationale are in class javadoc. The connector is already listedunder Sources in the README.