Repository navigation
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #35 +/- ##
==========================================
+ Coverage 89.73% 91.12% +1.39%
==========================================
Files 45 46 +1
Lines 2707 2952 +245
==========================================
+ Hits 2429 2690 +261
+ Misses 278 262 -16
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
The async migration sender ran independently of the epoch, so a batch could be in flight across a snapshot cut: the source's tombstone landed in one snapshot and the destination's insert in the next, and recovery from the first lost the key. The epoch now drains in-flight batches (acknowledged by the destination once applied) before its processing-done barrier and lets the sender resume after the cleanup barrier. The sender also waits for the protocol to start, so peers have loaded their state first. The delta flush cleared the delta map after awaiting the send, wiping migration writes that landed during the await. It now swaps the map out before sending. After a post-checkpoint recovery the destinations had no remote_keys, so a transaction on a key still at its source read it as missing. Workers now exchange the re-derived hash metadata on ReadyAfterRecovery and start the protocol only once every peer has reported. New partitions are created in Phase B before MigrationInitDone, so a peer that rebuilds first cannot send batches to a partition that does not exist yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… snapshotted A transaction that needed a migrating key could read it as missing: when the async sender had already taken the key into a batch, the local move found nothing and the remote source answered None, and in both cases the transaction ran without waiting for the batch. It now waits for the batch: the local move reports whether it moved the key, and a None response no longer wakes the waiter. Concurrent waiters on one key no longer crash on cleanup. The checkpoint was cleared on MigrationDone, so a snapshot cut before the last key landed but completed afterwards was saved without it, and recovery from it stranded keys at their old partitions. MigrationDone now carries the epoch of each worker's last batch, and the checkpoint stays on snapshots up to the latest such epoch. The coordinator always checks the latest snapshot for a checkpoint on recovery, restores the checkpoint and migration tracking while the transfer resumes, and retries a newer migration that had not reached its own checkpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration treated a key holding None as absent: the async sender popped and dropped it, and the on-demand path reported it as already gone. The destination still listed the key in remote_keys, so a transaction on it waited for a batch that never came. get_key_to_migrate now reports whether it found the key separately from its value, None-valued keys are sent like any other, and the remote reply carries the found flag. The migration YCSB, migration TPC-C and scalability demos wrote their initial data to S3 but never sent InitDataComplete, so workers deployed from snapshot -1 with empty state and the E2E tests ran against an empty store. They now notify the coordinator like the other demos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… batches in place Every migration rebuilt the protocol with a new idempotent Kafka producer and then scanned all output partitions for duplicates before sending any response; together these held responses back for 300-600 ms after Phase B. A graceful (migration) stop now flushes the egress but keeps its producer, and the rebuilt protocol takes it over. The dedup scan runs only after a recovery: a clean migration stop flushes every response at an epoch boundary, and the new protocol resumes from those offsets. The async sender also serialized batches for partitions on its own worker and sent them over TCP to itself. Those are now applied in place, with no acknowledgement needed since nothing is in flight. Remote batches default to 500 keys so each sender step stays short. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sender moved a worker's whole share in a burst, so all of it landed in one epoch's snapshot delta and was serialized on that epoch's critical path (about 200 ms for 60k keys). It now moves at most ASYNC_MIGRATION_KEYS_PER_EPOCH keys (default 3000) per epoch, refilling at each epoch end, or after a short wait when no epoch is running. Transactions do not wait for the transfer: they fetch keys on demand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rebuilt protocol created a new Kafka consumer, and the first epoch after a migration waited about 180 ms at its first barrier for the slowest worker's consumer to connect and fetch. A graceful (migration) stop now keeps the consumer connected, and the rebuilt protocol reassigns it to the new partitions and seeks each to its stop offset, which also discards any records it had prefetched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
exists() scanned the whole keys_to_send set of a partition on every call, and the ingress calls it for every message while a migration is running. With 60k keys pending, each call took about 3 ms, so a backlog of 2,000 messages kept the event loop busy for seconds and epochs barely ran during Phase C (this predates the PR). exists() now uses an index of the pending keys, built once per partition and reset when a new transfer is registered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kPsarakis
force-pushed
the
ft-state-migration
branch
from
October 24, 2026 17:57
8bf26cf to
be18240
Compare
The new worker tests import worker_service, which imports uvloop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eckpoint, escalate lost barrier replies Snapshots: the snapshot id advanced only when every file of a snapshot had uploaded, so a snapshot started while the previous one was still uploading wrote its files under the same id (overwriting the earlier deltas) and overwrote its offsets; failed uploads were also reported as complete. Each snapshot now gets its id at start and its own metadata, failed uploads are not reported, and completions are reported to the coordinator strictly in id order. Stale messages: after a recovery, migration batches, remote-key requests and replies from a protocol of before the recovery could still be applied. The coordinator now bumps an incarnation number on every recovery; these messages carry it and are dropped when it does not match. Checkpoint tag: any snapshot completing after Phase B began got the checkpoint and was taken as the checkpoint, including snapshots cut before Phase B. The workers now flag the checkpoint snapshot, and only it and later snapshots carry the checkpoint. Stop timeout: Phase B force-stopped a protocol still running after 15 s, possibly cutting an epoch mid-commit, then continued the migration. It now keeps waiting. A barrier or migration message the coordinator cannot deliver hands the worker to recovery instead, which is what the timeout was guarding against. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
inspect.getsource() cannot read Cython-compiled functions, which the hot-path CI job uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check - Remove the debug 10 s timeout on every barrier wait. - Lower per-message, per-snapshot and step-tracing warnings to debug. - Log a failed graph publication instead of dropping the future's error. - Compaction merged each partition's deltas in S3 listing order (10.bin before 2.bin), kept a partition's first delta when it was not 0.bin (so it was re-applied stale on load), and never dropped tombstones. It now merges in snapshot-id order into the partition's latest file, deletes the older ones, and only then drops tombstones (compaction is off by default). - FT E2E: names and docstrings now say which recovery path each kill hits. - Coordinator FT tests drive the real _perform_recovery instead of re-implementing its migration check. - FT E2E: after the run, every key's stored record is fingerprinted and compared with the loaded dataset, so a lost or emptied key fails the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recovery kept only the input offsets present in the snapshot. When the snapshot did not cover every assigned partition (a shadow partition, after recovering from the snapshot written at data load), that partition's consumer was never seeked and started at the latest offset, skipping the requests clients wrote to it before and during the failure. Seen in CI as 194 missed responses in the external-kill migration test. Recovery now defaults missing offsets to -1, and the ingress seeks every assigned partition, from the beginning when it has no offset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
calculate_metrics writes missed_requests.csv, and the FT tests copy their results directory into logs/, which CI uploads, so a failure that only reproduces in CI can be traced to the requests involved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request consumed for a key that migrated to a peer's partition was forwarded into the peer's epoch (WrongPartitionRequest). The ingress partition's offset was then advanced by two workers, each taking the max, and over a 16-socket pool the forwarded requests could run out of order: a snapshot or Phase B stop could record an offset past requests one of them had not run yet, and recovery or the rebuilt consumer skipped them. The request now stays in the consuming worker's epoch and runs on the owner as a proxied call (RunFunProxy). The owner runs its chain against itself and returns the whole outcome in one ProxyResult (response, abort, participants, fallback read/write-set change), which the root awaits before it can commit. Responses go to the request's ingress partition, which the root owns. Both messages carry the incarnation, since a replay reuses t_ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xied calls The regression test feeds Kafka records through the real ingress and sequencer into the consuming worker's epoch: a request for a migrated key, then a local one. The offset it records must not pass a request that has not run. It fails on the forwarding code (offset 41 recorded while request 40 waits in the owner's sequencer). Workers log how many proxied calls they sent in an epoch, when there were any, so the E2E worker logs show whether the path ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stalled epoch stalls every worker at the next barrier and nothing is logged: a CI run of the kill-before-post-migration-snapshot test went silent for two minutes after recovering. Each epoch wait (transactions, chains, the WAL write, a barrier, fallback, migration batch acks) now records what it waits on, and a watchdog warns every EPOCH_STALL_WARN_S (default 10 s) while one lasts, with the unfinished chains and pending proxied calls. A ProxyResult with no pending call, which would leave its call waiting forever, is now logged as a warning instead of at debug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… barriers A worker a barrier or migration message could not be delivered to was handed to recovery at once, but only the heartbeat check removes a worker from the pool and reschedules its partitions. A dead one therefore stayed a participant, got InitRecovery, and recovery waited on it forever (reproduced locally: a worker exiting at the Phase B stop, under CPU-capped workers, failed the SyncCleanup send first). Unreachable workers now wait HEARTBEAT_LIMIT: by then the heartbeat check has removed a dead one, and the ones still in the pool are alive and are recovered as participants. The coordinator also warns when no barrier completes for BARRIER_STALL_WARN_S (default 10 s) while some workers wait, naming the workers it waits on, and workers mark the remaining epoch waits (snapshot delta, responses to Kafka, snapshot request) for the stall watchdog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 25, 2026
An epoch's ingress offset passed requests rescheduled to a later epoch (a fallback read/write-set change, or a concurrency abort with fallback off): with fallback the offset advanced past them, without it the max over the committed ones did. They were only held in the sequencer, so a recovery from a snapshot cut in between, or the protocol rebuilt after a Phase B stop, never ran them. Offsets now advance past every request of the epoch, and the requests still in the sequencer at or below their partition's recorded offset are pending. A snapshot carries them (SnapTakeSnapshot, SnapID, a sixth element of the sequencer file), as do MigrationInitDone and MigrationDone. The protocol that takes over (after recovery, possibly on another worker, or after a migration) sequences the ones of its ingress partitions before consuming anything, routed to the partition that holds the key now. The coordinator now writes each sequencer file from its own snapshot's reports. It used to fold every report into the completed offsets on arrival, so a worker's report of snapshot N+1 that came before another worker's report of N put N+1's offsets in N's file, past requests N's state does not contain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shot retrieve_snapshot returns them as a seventh value; the startup path and the S3 integration tests still unpacked six. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes state migration fault tolerant. Before this, a worker crash during a migration reverted to the pre-migration snapshot and retried from scratch. Now migration progress is checkpointed into snapshots: a crash after the checkpoint resumes the transfer with the new layout, a crash before it reverts to the old layout and retries the migration. The branch is rebased onto
mainand also fixes several correctness and latency problems in the migration path, some of which predate it.Migration algorithm
A migration starts when a client submits a graph whose operators have different partition counts. The coordinator runs one migration at a time. It forces a snapshot at the next epoch boundary (the revert point if the migration fails before its checkpoint), assigns the new partitions to workers, and sends every worker
InitMigrationwith the new graph, its assignment and the partition locations. Partitions are pre-created up to the maximum parallelism; those at or above an operator's partition count are shadow partitions. The migration then runs in three phases. Steps marked (new) are added by this PR; the rest is onmain.Phase A: rehash, protocol running. Each worker rehashes the keys of its partitions with the new partitioner, in a process pool and without network I/O, and records which keys must move and to which partition. It does not apply the new plan yet. Each worker reports
MigrationRepartitioningDone. Once all have, the coordinator sets the checkpoint metadata (new) and asks the protocol to stop at the next epoch boundary.Phase B: switch over, protocol stopped. Each worker:
remote_keys, so it knows where to find a key that hasn't arrived yet.MigrationInitDonewith its epoch and transaction counters and its ingress and egress offsets.The coordinator merges the counters and offsets (max per partition). Once the checkpoint is complete on every worker (new), it publishes the new graph, so the other clients switch partitioners (the client that submitted the migration switched when it submitted it), and sends
MigrationDonewith the merged counters. Each worker then rebuilds the protocol for its new assignment, reusing its Kafka clients (new), and starts it.Phase C: transfer, protocol running. A background sender on each worker moves its keys to their new partitions in batches, alongside normal processing. A batch to a partition on the same worker is applied in place (new); a batch to a peer stays unacknowledged until the peer has applied it. Each epoch drains the in-flight batches before its processing-done barrier and caps how many keys the sender moves (
ASYNC_MIGRATION_KEYS_PER_EPOCH) (new). Transactions don't wait for the transfer:RequestRemoteKey/ReceiveRemoteKey). If the sender already took it into a batch, the transaction waits for that batch (new).When a worker has sent every key and every batch is acknowledged, it reports
MigrationDonewith the epoch its last key landed in. Once every worker has, the migration is finished. Snapshots keep the checkpoint metadata until one is cut after that epoch (new).Fault tolerance
Checkpoint. In Phase B, after the protocol stops and the hash metadata is exchanged, every worker takes a snapshot flagged as the migration checkpoint. The coordinator attaches the migration metadata (new graph, partition locations) to the sequencer file of that snapshot and of every later one, until a snapshot is cut after the last migrated key landed. It publishes the new graph and sends
MigrationDoneonly once the checkpoint is complete on every worker.Recovery. On recovery the coordinator reads the latest complete snapshot:
remote_keys(all-to-all, onReadyAfterRecovery) before the protocol starts. The coordinator keeps tracking the resumed transfer, so a second crash during it resumes again.Snapshots during the transfer. Migrated keys are recorded as tombstones at the source and as inserts at the destination. A batch never straddles a snapshot cut: each epoch drains in-flight batches (acknowledged by the destination once applied) before its processing-done barrier, and the sender resumes after the cleanup barrier. The flush to the snapshot process swaps the delta map before sending, so writes landing during the flush are not lost.
Exactly-once output. After a recovery, every worker scans all output partitions (a replayed request may now run on a different worker) and suppresses responses already sent. A clean migration stop needs no scan: it stops at an epoch boundary with every response flushed.
Requests for migrated keys. A request consumed for a key that now belongs to a partition on another worker stays in the consuming worker's epoch and runs on the owner as a proxied call. The owner returns the whole outcome (response, abort, chain participants, fallback read/write-set change) in one
ProxyResult, which the consuming worker awaits before it can commit, and the response goes to the request's ingress partition. So an ingress partition's offset is only ever advanced by the worker consuming it, in order.Stale messages. The coordinator bumps an incarnation number on every recovery. Migration batches, remote-key requests/replies, proxied calls and their results, and the recovery hash exchange carry it, and messages from another incarnation are dropped.
Fixes beyond the original change
Correctness:
remote_keys, so transactions read keys still at their source as missing.Nonereply to a remote fetch); it now waits for the batch.Nonewere dropped by the transfer.MigrationDone, so a snapshot cut before the last key landed but completed afterwards lost it; and any snapshot completing after Phase B began was taken as the checkpoint.MigrationInitDoneandMigrationDonenow carry these pending requests, and the protocol that takes over sequences them before consuming anything, routed to the partition that holds the key now (possibly on another worker, after a recovery).InitDataComplete, so those E2E tests ran on an empty store.Latency (the stall after Phase B):
exists()scanned all pending migration keys for every incoming message (about 3 ms per call with 60k keys pending, which kept the event loop busy for seconds during Phase C). It is now O(1).ASYNC_MIGRATION_KEYS_PER_EPOCH, default 3000) so no single epoch serializes the whole share into its snapshot delta.Performance
YCSB, 4000 tx/s, 500k keys, 4 → 8 partitions, interleaved runs on the same machine:
mainThe remaining spike is mostly the Phase B stop, which includes waiting for the checkpoint snapshot (about 100 ms).
Diagnostics
A stalled epoch stalls every worker at the next barrier and nothing is logged. Workers now warn every
EPOCH_STALL_WARN_S(10 s) while one epoch wait lasts, naming the wait (transactions, chains, WAL write, a barrier, fallback, migration batch acks, snapshot delta, responses to Kafka, snapshot request), the unfinished chains and the pending proxied calls. The coordinator warns when no barrier completes forBARRIER_STALL_WARN_S(10 s) while some workers wait, naming the workers it waits on.Wire format changes
New message types
SnapMigrationReassign(44),AsyncMigrationAck(45),RecoveryMigrationHashes(46),RunFunProxy(47),ProxyResult(48);WrongPartitionRequest(26) is gone.AsyncMigration,RequestRemoteKey,ReceiveRemoteKey,MigrationInitDone,MigrationDone,SnapID,SnapTakeSnapshotandInitRecoverycarry extra fields. The sequencer file has a fifth element (the migration metadata) and a sixth (the pending requests); 4- and 5-element files still load.Testing
_perform_recovery.Proxied calls this epoch: Nwhen they sent any, so the E2E worker logs show whether that path ran. In a local run of the migration E2E suite, 236 of the 239 proxied calls came from the external-kill test: its recovery put shadow partition 5 and old partition 1, which holds partition 5's keys under the old layout, on different workers, so every request the submitting client sent to partition 5 crossed workers. On the forwarding code, that is where two workers advanced one partition's offset.tests/e2e/test_e2e_migration_ft_ycsb.py): baseline migration, a kill before the post-migration snapshot (resume from the checkpoint), a kill after it, a kill well after the migration, and an external kill during Phase A (revert and retry). Besides duplicates and missed responses, every key's stored record is now compared with the loaded dataset at the end.🤖 Generated with Claude Code