You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by hg...@apache.org on 2026/10/21 18:54:20 UTC
(iggy) branch feat/spread-placement updated (29d05754d -> 43cebb262)
This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a change to branch feat/spread-placement
in repository https://gitbox.apache.org/repos/asf/iggy.git
discard 29d05754d chore(cluster): reconcile upstream recovery changes
discard aac2beeff chore(repo): integrate completed PR6 lanes
discard 5194ddf35 chore(repo): merge PR6 liveness checkpoint
discard 820edbddd fix(consensus): require proof before adopting a repaired head
discard 64bba0dc7 Merge commit '0e66a23c3583f4d2ab768942fac81dd6db9d6636' into pr6-work/d
discard 0e66a23c3 fix(server): bound partition admission by quorum freshness
discard 711db5c8a fix(server): recheck resolved partition identity after reads
discard 608c5c1d9 fix(partitions): mark covered offset replies as committed
discard caa2ffd39 test(server): read connection watermarks in isolation proof
discard ad6368b5e chore(repo): merge durable adopted-view recovery
discard d293d332b chore(pr6): merge validated lane dependencies into freshness work
discard 03f78d409 chore(java): merge validated operation lineage fixes
discard b6872cbd6 perf(sdk): reduce producer error storage
discard 98863ae71 test(java): restore the carrier in login routing fixtures
discard 491644076 fix(java): preserve caller lineage across composed message calls
discard 726e389b2 test(cluster): verify inherited admission after WAL bootstrap
discard 9deca8d41 test(metadata): reopen receipts at checkpoint boundaries
discard 529c1a7cd test(metadata): pin conservative admission deferral
discard 6dc8a67ec test(server): verify the primary before stopping its replies
discard e51914abf fix(test): account for retained data and retirement evidence
discard ea01c3728 fix(server): run session liveness on the bus clock
discard a20c2450b docs(cluster): integrate recovery and receipt contracts
discard 7a6e5e008 fix(server): require reply ownership in shell simulations
discard 83bc15883 fix(sdk): reserve retired consumer offset command 124
discard 48c9470a9 docs(go): identify VSR client IDs in ownership errors
discard d346cc315 docs(server): define upgrade and retry recovery limits
discard cf93ecb8d chore(java): integrate validated session and parity repairs
discard c728bf96b chore(sdk): integrate Swift session recovery repairs
discard fea465d45 chore(js): integrate session recovery repairs
discard 214962cdf chore(csharp): integrate session recovery repairs
discard eaeddbc5f chore(go): integrate session recovery repairs
discard f89695632 chore(sdk): integrate Rust session and bridge repairs
discard a26c666bc chore(cluster): integrate durable recovery repairs
discard 87f720b3b chore(repo): merge validated reply ownership
discard 871dd204f test(server): pin shared forwarding capacity for floor waits
discard b506a8443 fix(server): require canonical relay proof fields
discard 5ded7e015 fix(consensus): persist adopted log identity across recovery
discard 880993f42 fix(message_bus): require reply ownership for HTTP slots
discard 3758f894f fix(go): preserve answered sign-in refusal errors
discard 313c24ff0 fix(go): preserve caller ownership through sign-in and cleanup
discard a59bf916f fix(java): preserve newer sign-ins after delayed logout
discard 39a8eaef3 test(swift): prove password refresh ordering
discard 26488084c docs(consensus): clarify retained view header evidence
discard 45d6618b2 fix(consensus): distinguish broadcast control log targets
discard e162dd865 fix(metadata): refuse transfers below the durable checkpoint
discard ca2ba2ef7 test(java): cover pipelined polls with a paused reader
discard ea2cb51c3 test(node): prove password refresh ordering
discard 62d348f3b docs(csharp): define sign-in and shutdown contracts
discard a3112ea8f test(csharp): assert refresh work between restarts
discard ea9fae00f fix(csharp): fence sign-ins and preserve recovery
discard fbaed9b01 fix(csharp): checkpoint offsets before leaving
discard 5fbcf52a5 fix(java): preserve refreshed credentials across recovery
discard 8bf34668c test(sdk): distinguish reconnect after credential refusal
discard 0f6a491a0 refactor(java): classify authentication failures in request state
discard 77716023a fix(sdk): retain committed credentials during recovery
discard df67165cf fix(sdk): keep explicit sign-in ownership through completion
discard 0140d77ef fix(sdk): revalidate frozen retries after waits
discard 8d9c801bf fix(sdk): fence consumer group cache publication
discard c12542c7b fix(java): retry refreshed credentials after an old refusal
discard fde353a6c fix(java): keep newer connections during sign-in cleanup
discard 81835f709 fix(java): reject unequal node ports before resolving
discard 6099c571e fix(java): fence explicit sign-in ownership and failure
discard 7525ddc7b fix(swift): discard sessions after failed explicit sign-in
discard a7ef06fed fix(java): resolve namespace aliases off the event loop
discard 646e82e75 fix(java): capture request identity before caller changes
discard a77bb9f2b fix(java): back off repeated routed primary refusals
discard f658e16ef fix(java): back off lost links and refused binds
discard d054c92d5 test(node): align recovery fixtures with explicit session ownership
discard 4171b17dc fix(node): preserve login transport errors at queue deadlines
discard de68e5138 fix(node): keep consumers polling after repairable socket closure
discard 3a89887c6 fix(node): order lifecycle retries in the current incarnation lane
discard 6319f768d fix(sdk): preserve stale progress poll uncertainty
discard ae8c4081b fix(node): let explicit sign-in own credentials and session
discard ba89d37b1 fix(sdk): keep operations within one caller sign-in
discard 87e48f206 refactor(java): classify link loss in the refusal helper
discard b04383e45 test(swift): wait for sign-in ownership in messaging tests
discard 8e65d037e fix(java): retain credentials when a sign-in loses its link
discard 5a3269e64 fix(swift): give explicit sign-in sole credential ownership
discard 3b082d0d2 test(cluster): keep ring fault hooks local to their scenario
discard 15b77fa70 test(cluster): pace five-replica repair traffic by replies
discard e3f248c98 test(partitions): reopen the existing truncated WAL files
discard d02b3b6fa test(metadata): preserve unvouched suffix truncation coverage
discard a38bc48a1 test(go): advance routing metadata through a metadata mutation
discard aba4363e3 fix(java): import the detached connection list type
discard 7a9585b7a test(go): require route refresh through a newer metadata floor
discard 5cde2a45d fix(java): avoid deadlock when closing a timed-out poll
discard a2b277d67 test(go): share the replica readiness fixture
discard c90b75afb test(go): cover caller changes across multi-request operations
discard 17dd829d5 fix(metadata): retain vouched WAL entries across state transfer
discard 6ba9aefbb fix(go): match partition refusal lookup behavior
discard 63a3f20be test(partitions): keep the crash fixture WAL history valid
discard f3f6170d5 test(swift): consume the stale-client fixture send response
discard fe60c7847 test(swift): distinguish detached links from ended sessions
discard 7b982cb7b fix(swift): back off between detached read retries
discard fd5e320f7 fix(swift): preserve caller sign-in interruption context
discard 774fd3b6a fix(swift): scope read watermarks to request contexts
discard af06dd5ed fix(swift): retain uncertain requests across replica changes
discard df2ab2cc1 fix(go): forget refused tokens and preserve sign-in causes
discard 7b1825c0d fix(go): remember sign-ins atomically with session binding
discard ecbd62de8 test(java): cover refused sign-in during queued failovers
discard f125b96fb fix(cluster): merge reviewed view-change repairs
discard bec57ca17 test(cluster): reopen persisted divergent partition truncation
discard 502ca29a4 Merge commit 'bc765d6c9' into pr6-work/a
discard a15b05160 test(java): order the shared fixture fields for checkstyle
discard ac44953a7 fix(swift): stop signing in with a refused personal access token
discard c77069d23 fix(swift): keep the caller lineage across all requests of a call
discard 3eb27f45f test(java): check that Rust error codes decode to the same name
discard 7552ea12d test(java): compare the command codes with Rust by name
discard 08700c2e0 docs(java): give the pending bytes default in MiB
discard 752a80640 docs(java): note how the link redial differs from the Rust SDK
discard c1df1c821 test(java): pin 57 on partition writes
discard da240f956 fix(java): look up on the metadata primary after a backup lookup miss
discard cd09fd5e0 fix(java): confirm a namespace miss with a roster read on its link
discard 52127bc02 fix(shard): arm metadata repair for a proof op above the commit point
discard 71aaab494 fix(shard): arm partition repair for the op a view-log proof waits on
discard 437668261 fix(metadata): persist a transfer's lowered binding before its truncate
discard 65f03ea25 refactor(java): satisfy checkstyle in the session code
discard 53a9cfa9e fix(go): end a LogoutUser on Disconnect as the Rust SDK does
discard d54a630e7 test(java): pin the uncertain end of a write whose reply was lost
discard 14a00f91f test(java): cover the session rules with a fake server
discard 623b5f82a fix(java): forget a refused automatic sign-in and end its calls with 40
discard 60df3addf fix(java): end a call with 58 when its sign-in reaches the deadline
discard 55bd18fc8 fix(java): end calls made before a caller sign-in or sign-out with 40
discard cd794f123 fix(csharp): end an unsent call at once when its client stops
discard baebe189b fix(csharp): keep a balanced send in the session it started under
discard 6b210ec8d test(consensus): bound the view search in the redelivery test
discard 8ce3a8206 fix(consensus): log the target of a probe-answer StartView
discard 52a365885 fix(consensus): log the target of a targeted SendCommit
discard af30bc013 test(shard): say why the adopted header test pins the report
discard bc765d6c9 fix(server): point the relay proof lifetime edge one way
discard eef0facfe docs(shard): correct the nack claim for stitched adopted headers
discard 611acd091 fix(consensus): report a contradicting log once per view
discard 8961ef332 fix(partitions): refuse logs that contradict an applied partition op
discard 171a0d9fc test(csharp): pin 57 on partition writes as replay, then uncertain
discard cc1d94c57 refactor(java): move the session login to its own file
discard 41b9a6618 fix(csharp): leave the group again when a join lands after the dispose
discard 932a7ed95 test(csharp): end the slot-wait test at a short call deadline
discard 4f4603c0b test(csharp): hold fake bind replies without blocking a pool thread
discard 996d72143 test(csharp): read the lane counts and the deadline entry without reflection
discard 98d38720c docs(csharp): say why the mutation budget queues keep a strict order
discard 47d405a8d docs(shard): describe the reply path of shard 0 QUIC and WS clients
discard f18a4ec13 fix(server): log a reply to a closed connection at debug
discard 07f6a6518 fix(server): hold a forward slot while waiting for the metadata floor
discard 34ce450f6 fix(server): name a refused relay proof to the node that made it
discard 5c6ad8753 fix(consensus): skip corpse nacks when a canonical sender abstains
discard e38d3e3c3 fix(server): read relay header numbers only in their canonical form
discard 935133176 refactor(java): move three connection types to their own files
discard 0977bdbda fix(server): refuse a relay proof made a lifetime ahead of this clock
discard 1bf3f8fc1 fix(csharp): count a malformed login as a refused sign-in
discard 7470d3173 refactor(java): carry a retried call as one record
discard c36fa9a60 fix(csharp): forget a credential only when its register is refused
discard f1b2c6669 fix(java): restart an unsent call once per refused write
discard 388b1d1ba fix(java): pace NOT_PRIMARY rechecks and run call retries as a loop
discard 6619763bd fix(shard): never nack an op this replica holds in its DoViewChange
discard 130f39aed refactor(message_bus): take the reply installation with ok_or
discard 8588d3d81 fix(swift): keep an offset write unknown when its data connection drops
discard 944a859f0 fix(swift): end an evicted segment deletion as unknown
discard f603ee50a fix(server): read a metadata relay body before taking a forward slot
discard cb5b987ec fix(server): hold a relay for its metadata op before authenticating it
discard c7c87a06f fix(server): wait for reply queue room instead of closing the client
discard 11da2c729 docs(swift): describe the replay of an unknown write in the client doc
discard c63632719 fix(node): keep a typed send and a group poll in the caller lineage
discard bf799a249 fix(swift): replay an uncommitted offset write on a data connection
discard daaf1a48b test(node): sign in again by hand after the logout of the discovery test
discard e0467d810 fix(swift): confirm a data connection miss on the replica that answered
discard f1ed561e6 fix(swift): look up a route on the pinned coordinator without a session
discard cee70ccb2 fix(swift): keep a data connection whose bind was not written
discard 3902daddb fix(swift): keep the metadata primary when the budget runs out first
discard 392268c5a test(server): pin the retirement pace without a wall-clock budget
discard ec4e9cc6c fix(metadata): keep partitions_max hard for creates behind uncommitted ops
discard ff95f6b1d fix(swift): report the client disconnected once the server ends its session
discard 7f26ed541 fix(swift): replay an unknown write across lost connections
discard 01e2ba745 fix(node): start each route lookup pass at the metadata primary
discard e8ec5cc87 docs(swift): describe the session resume and drop a removed Rust type
discard 5c5ecd5f7 fix(node): stamp a retry after status 88 with the current route context
discard 9c33af445 fix(swift): end a routed call refused as a stale client with that refusal
discard 8be910998 test(node): cover a restarted progress poll, a late lane entry and recovery
discard dabf16920 fix(node): bound a group poll and its retries by one deadline
discard 3acd5437b fix(node): read the roster for a namespace miss once on the link that missed
discard 65188c9f3 fix(swift): end a read refused as a stale client with that refusal
discard 1871723bc fix(node): report the bind failure of a dialed endpoint in every case
discard dd38f18f6 test(node): bound the wait for a later bind under mocked timers
discard d03c5d610 test(server): pin the session retirement pace across many partitions
discard 6142657c6 test(partitions): pin one retirement barrier per batch of ended sessions
discard 9fd9ff096 fix(sdk): end calls made before a disconnect with Unauthenticated
discard 62a66d7fa fix(shard): rate limit the incarnation and repair conflict warnings
discard ff2bb0c09 fix(partitions): gate the checkpoint tick and checkpoint on one predicate
discard ce4afcf57 test(simulator): satisfy pedantic lints in the inherited create test
discard 2bff7948f docs(consensus): give the off-chain proof note its own paragraph
discard 9cf346738 fix(consensus): fetch a bridged op whose view header is off the chain
discard e54c4bb9f fix(shard): rotate partition repair around the whole ring
discard 869141817 fix(sdk): bound the heartbeat group refresh by the next tick
discard b728ce7d9 fix(sdk): end consume_messages once the client cannot come back
discard 0c8dcd030 fix(sdk): pause polling when the last replica link gives up
discard d2c9ad42a fix(common): read a numeric identifier without a copy or a panic
discard 6a8b795da test(integration): keep a session live without its metadata link
discard 4a763bfca test(simulator): count an inherited create once after a network election
discard eec63355a test(simulator): allow the long transfer-floor checker regression
discard c7c08d9f4 fix(shard): arm metadata repair for the op a view-log proof waits on
discard d75f98ab3 test(integration): deny a lost send's retry until its permission returns
discard 48294ea85 feat(python): expose the uncertain chunk length of ProducerSendError
discard aad61a472 refactor(php): build a QUIC client without entering the runtime
discard 02d3bac24 refactor(python): build a QUIC client without entering the runtime
discard 6727869cf fix(consensus): send the eager Commit only to backups that hold the op
discard 93fe328f3 test(sdk): keep a lost reply uncertain when a revocation ends the session
discard 3c9801cf8 test(metadata): fold the pipelined revocation replay into the revocation test
discard 8ac14751b test(consensus): decode the client table encoded after the pipelined commit
discard d8b20b6d9 feat(metadata): trace ops that commit after their session ended
discard 4930220a6 refactor(cpp): clear the poll error info only in the shared poll
discard 3e9a763ee refactor(cpp): put .await and .inspect_err of the bridge on separate lines
discard 0927fb9ee test(cpp): name the bridge recovery tests with the should_ prefix
discard a1287b0d6 fix(cpp): copy an IggyException without throwing
discard e68d90e1d refactor(cpp): let only the client build an IggyException from ErrorInfo
discard 3495eb702 refactor(common): share the operation ID lookup of recovery errors
discard 407753b8d fix(simulator): start the history walk at a transferred snapshot floor
discard b0ad53d66 docs(connectors): state at-least-once delivery where a source batch NACKs
discard 6b4456e7f refactor(connectors): return the source producer and encoder as one struct
discard 0c5d01740 fix(gateways): answer 7 when a blocking operation is no longer retained
discard 8bc48c50b fix(gateways): answer 6 when Iggy rejects a Kafka send with StaleClient
discard 32c8428f1 fix(swift): stamp reads with the metadata watermark
discard 8f1c7b9b9 fix(csharp): end calls at the caller's sign-in or sign-out
discard 499413f94 fix(csharp): give the heartbeat group refresh one tick in all
discard a800ed97c test(go): name the code of the send that keeps its context
discard 6965b8bcf test(go): keep the send context after a permission refusal
discard 883769696 Merge branch 'pr6-work/f' into pr6-work/a
discard 654ce48d4 perf(go): mark every cached route stale in O(1)
discard f34beac1d test(go): pin 57 on a partition write until commit or deadline
discard 5d8fff195 test(go): park the call lineage test in refused lookups, not pauses
discard 90676cc33 fix(go): end the route lookup of an earlier sign-in with 40
discard ec91a1cb9 refactor(server): satisfy clippy in the client origin plumbing
discard bb864b44d refactor(message_bus): write the reply route filter as clippy asks
discard 93274d9e9 fix(go): forget the sign-in of the caller on Disconnect
discard 857d56800 fix(go): look a partition up again after a final namespace miss
discard b108015d9 test(server): cover a relayed write reply reaching an HTTP reply slot
discard e38f426f4 fix(go): end the calls of every refused automatic sign-in
discard 7d886ac75 docs(swift): document the session rules and the unknown outcome code
discard 918601bcf test(simulator): refuse a reply to an earlier connection on the sim bus
discard 05b487fe4 test(server): pin every reply family to the connection that sent it
discard 04b37fed6 fix(message_bus): refuse a client socket reply addressed by client id
discard db99aa47c fix(message_bus): let a closing connection drop only its own metadata
discard 5e4d00d03 fix(swift): end calls unauthenticated when the server refuses a sign-in
discard 6fe83042a fix(server): stop answering a partition write by client id
discard 8bd026d42 fix(server): send every client reply to the connection that sent it
discard ac3c7cd5c fix(swift): stay signed out after the caller logs out
discard d0954eeb6 fix(swift): refuse a call as not accepted when its reconnect times out
discard 9d9f33f92 fix(swift): end calls made before the caller signs in or out
discard 8b717c840 test(node): handle the parked call rejection before the logout starts
discard a19d9d93a test(java): pin command and error codes to the Rust sources
discard c4f8a7382 refactor(java): drop the unread target length of the offset write
discard fe27b2089 refactor(java): drop the leader redirect cap that one login never hits
discard faf745b80 refactor(java): keep the pending operation defaults in one place
discard 2f9141b4d test(node): pin replays of sends and offset stores refused with 57
discard 0d41e6168 fix(node): apply the session rules for sign-in, logout and deadlines
discard 86415fb12 fix(sdk): end waiting calls when the server rejects an automatic sign-in
discard 560fba004 fix(swift): end an evicted write as unknown instead of replaying it
discard b95ded1de fix(metadata): count in-flight creates against the PAT and partition caps
discard 0eea92017 test(simulator): bound the view search in the contradiction test
discard e8cd4c430 fix(sdk): fail a session wait at the deadline as TransientNotAccepted
discard fe9062c75 fix(sdk): end unsent calls when the caller signs in or out
discard 8970f1083 fix(metadata): break the recovery barrier and state transfer deadlock
discard bac67223b test(simulator): let holed peers learn the last gap commit first
discard a70134311 test(shard): expect repair at the lowest op of a removed divergent run
discard 25add4a00 fix(consensus): keep adopted StartView headers across view changes
discard f21c4b182 test(java): keep the mock primary under the complexity limit
discard c21764196 perf(java): key routes by target bytes and skip timers for ready links
discard 5ef7c8331 fix(consensus): refuse a log that contradicts an applied metadata op
discard 12e879432 test(consensus): pin the repair op guard for a walk past the op
discard b856bdf09 test(metadata): keep one regression for re-acking an applied op
discard d0169a177 Merge commit '5213736ba' into pr6-work/c
discard 968bfab31 test(java): prove routed payloads go back on every poll exit
discard fddbe56ec fix(consensus): claim only a proven log_view in DoViewChange and on disk
discard 97771d83b refactor(binary_protocol): clear the committed-result marker by method
discard ac8b45470 fix(consensus): drop a view-log repair op the commit floor covers
discard 5980a3f5e fix(java): restart an unsent call under the recovered session
discard 54f1820ea fix(metadata): ack a retransmit of an op this backup already applied
discard 69d26d8c0 fix(consensus): accept a rebuilt chain that steps down a view
discard e6f787977 test(csharp): cover a recovery without sign-in that ends unsent calls
discard 38b1ca025 fix(csharp): end receiving cleanly when a dispose meets a group rejoin
discard 7f3ea0fb5 refactor(java): move the channel pool handler to its own file
discard 82b7ebbc8 test(go): pin that a caller disconnect ends a call between attempts
discard 0cf831085 docs(csharp): describe the namespace-miss retry and its 58 at the deadline
discard 84a037349 test(csharp): end the namespace-miss poll test at a short deadline
discard f74a56a33 test(csharp): cover an admitted unsent write across a session recovery
discard 63be42d15 test(shard): cover stale-donor, rotated and unattributed repair bodies
discard 1a922d776 fix(shard): refuse repaired prepares the view log does not vouch for
discard 8330b09df test(csharp): cover backup namespace misses of offset stores and polls
discard af98b5bab fix(csharp): continue the calls only when a recovery signs in
discard 5008f3524 fix(swift): cap a lookup's dial to the metadata primary like a failover dial
discard 1645f4fb1 test(go): check split primaries against the roster leader
discard 5213736ba fix(simulator): skip the default metadata floor on partition-plane runs
discard 76aa12921 perf(go): mark the aliases of a partition history stale in O(1)
discard e01fcc5d6 test(go): check the connect error in the dial bound test
discard 1ee7622fd fix(go): confirm a namespace miss while the metadata primary is down
discard bb8c7269f test(integration): read the revocation owner through the joining client
discard 4bdfade4f test(integration): size the backup commit budget from the servers' interval
discard dbce037ef fix(swift): end an unknown write's replay on any non-transient refusal
discard c8b6ffa0c fix(go): confirm a namespace miss of the metadata primary by its roster
discard a2d6a21f2 fix(csharp): end the receive loops once the consumer is disposed
discard d4ec29373 test(csharp): pin Disconnected before a session reset fails calls
discard a3ab90111 fix(csharp): confirm a namespace miss from a reachable metadata primary
discard 7d1e9c2b9 test(go): end a lost write uncertain when its session then ends
discard b140f7466 test(consensus): pin the guards of the eager Commit
discard e6533c4dd refactor(consensus): gate eager Commit and retransmit on one predicate
discard 85a57dc2c test(swift): use a port no fake server can bind as unreachable
discard e6dca6d7f test(swift): sweep every committed-result marker value
discard 8a2a0bc43 test(swift): make the fake server's stop close every connection
discard aef3a957a test(swift): model the view change by moving the leader role
discard 5a0fac808 fix(swift): keep the metadata primary on the client's own refusal
discard c4e304165 fix(swift): skip an unhealthy leader as the metadata primary
discard 1cff329f3 fix(partitions): fail closed on flushed offsets of a file-less partition
discard da9b87c0b fix(java): redial only when the connection in use fails
discard 6187fc47c fix(swift): stamp route lookups with the metadata watermark
discard e51389102 test(go): pin the refusal log with a create that waits for the recovery
discard 922923547 refactor(swift): pass the routed request deadline from its caller
discard 39dcd74ef fix(go): log every refusal of an automatic sign-in
discard 20e7fb556 test(integration): keep JWT secrets out of general spec test names
discard 8f85446ed docs(go): describe how calls wait for a session and end at a sign-in
discard e7866a778 fix(server): answer an HTTP partition write without a verdict as 504
discard e9c19321a test(go): pin that a caller logout and sign-in end a call between attempts
discard 28b39b442 fix(csharp): keep group syncs from stalling heartbeat link restarts
discard 7c92f8487 fix(shard): refuse polls until a new primary applies its view start
discard 7730253cf fix(go): forget a credential that the server refused
discard fc0dcfe29 test(node): keep the bind refusal of a dialed endpoint as the dial error
discard 02e1b7d73 fix(swift): retry send misses the metadata primary did not confirm
discard 658c3b0fe test(node): pin the metadata primary miss rule for raw route lookups
discard a3f5e8871 test(java): cover the sign-in after a failed retry of a refused bind
discard b4ea97d16 fix(server): deliver a relayed reply only to the connection that sent it
discard 5f19f45d1 test(simulator): crash a backup at each stage of adopting a view log
discard bdca25dbd fix(metadata): persist the view whose log the WAL holds
discard 7736585c9 fix(simulator): check polls answered during the drain on arrival
discard 9f5b9a45b fix(simulator): fail a replica past a retirement it did not apply
discard 34f8f1f84 fix(java): describe what follows a redial that gave up
discard 4e507d7a5 fix(go): recover the session as the last sign-in of the caller
discard 8ef280803 fix(java): fail a send still waiting for its lane as not accepted
discard e123eb1df perf(swift): look routes up over a coordinator at the metadata primary
discard a73d2238b fix(swift): bound data connection dials by the call deadline
discard b6ba510f5 fix(go): end the calls of an earlier sign-in when the caller signs in
discard dad6590a2 perf(csharp): build mutation frames outside the client-wide gate
discard 8f26540ab fix(common)!: carry the full VSR client id in error 5009
discard fbd0c48f3 perf(csharp): wake only the mutation waiters a release can admit
discard b99191b43 fix(node): retry a group poll after its signed-in session ends
discard 1a15e8f7a refactor(server): resolve the consumer offset target once
discard da8e8b025 fix(node): release the client of a destroyed stream without a session
discard f792a3572 test(node): type the credentials of the bootstrap dial failure test
discard cc1f90952 test(server): read the session watermark through touch_connection
discard 7d1d81a6c refactor(server): convert wire identifiers with TryFrom in namespace
discard d6f85ed20 fix(node): name a dial failure only when no endpoint accepted a dial
discard c00e148f5 test(node): keep an uncertain send through backup namespace misses
discard 4f43deae8 test(node): send after a route that names an unknown primary
discard 935947d5a test(simulator): crash each replica at each primary handoff message
discard 7b6b7453b fix(swift): end a write refused as a stale client with that refusal
discard b3b7f95d3 fix(server): answer an abandoned partition submit as not committed
discard 886ee0316 fix(message_bus): close a client connection whose reply queue is full
discard 2dabedc35 fix(server): refuse a poll whose partition was deleted during the read
discard c74596bf3 fix(server): recheck offset reads on the resolved namespace
discard ca0f6e105 fix(swift): close the connection when an unknown write's session ends
discard ee16583bd fix(swift): stop replaying an unknown write after its session ends
discard a832be28a fix(node): retry a backup namespace miss to a partition count lookup
discard b187f2246 fix(node): stamp the new route context on a call restarted under a session
discard a8638c54d fix(node): confirm a namespace miss from the believed metadata primary
discard fb7ff3cda test(simulator): poll as consumers and groups and read back committed data
discard 4dd740ca1 fix(java): keep a reconnect burst going when the login is refreshed
discard d4adb233d refactor(sdk): report the uncertain chunk length of a failed send
discard 671491490 fix(server): relay only a metadata op the contacted node applied
discard 0136086e0 refactor(go): rename UncertainOutcome to OperationUncertain
discard f6f1e696f fix(metadata): re-ack retransmits of an op a backup already applied
discard ca998cbe3 refactor(go): remove test-only DecodeReply and test DecodeReplyFor
discard f86f69c51 test(simulator): compare applied message data across replicas
discard 55eb53657 refactor(go): remove unused Session.Fork and hold its counter by value
discard 75f2eae62 fix(sdk): keep consume_messages running through connection errors
discard 9d1ccfd20 refactor(go): drop the client heartbeat default that tcp already sets
discard 6cdcb2d7a fix(csharp): confirm a namespace miss while the metadata primary is down
discard 41a4cd90f refactor(go): call drive directly for routed partition commands
discard af6a22b1d fix(consensus): repair every bridged view-log body in one round
discard 3a4495dbb refactor(go): remove unused replica link change notifications
discard 590458b01 fix(consensus): re-derive the view-log proof past a commit floor jump
discard e5a374395 fix(sdk): wait the retry interval before yielding an Unauthenticated poll
discard ec5700407 fix(java): end a pending failover when the caller signs in
discard 0c68c9204 fix(sdk): let the client state decide whether a consumer polls
discard 6c2585d68 test(sdk): cover evicting the last route of a topic in a shared stream
discard 77910a65c docs(sdk): explain why a StaleClient bind does not reset the session
discard 676eaa973 fix(sdk): log the WebSocket write buffer sizes that create rejects
discard efbe65958 refactor(common): move the identifier wire conversion to wire_conversions
discard 857fe4613 perf(server): name the partition primary in a hop target's refusal
discard a7076a0c6 test(common): check the undecodable poll payload with one direct send
discard 9cce1efe3 fix(shard): retry a failed divergent removal on the fenced op's repair
discard 84ecca101 perf(server): register an HTTP write session only where it is served
discard 04cda3a25 perf(go): pass a captured offset context without context.WithValue
discard 577db4b96 perf(go): look up a cached route without copying its key
discard b53bde66b test(simulator): replay a stale owner token in every lifecycle run
discard ce35b9a89 perf(go): bind each link once per session
discard 7e9f47e2d test(partitions): cover a full backup WAL that the cluster commits past
discard ed594d525 fix(csharp): restart an unsent call under the recovered session
discard c3720ae01 docs(sdk): explain why the route cache fences by a global watermark
discard 9f7e5d36c test(simulator): retire deleted consumer group owners end to end
discard d06689d25 refactor(common): share the binary sign-in steps of both logins
discard 7caa2d11f fix(java): sign in after a failover walk with the current login
discard 0aa31e67a refactor(common): share the fresh partition-count read of binary send
discard f7c45d028 perf(go): wake only the registry waiters that can proceed
discard 79d76a3c6 refactor(common): drop the binary topic-discovery cache writes
discard aa36f1edf perf(server): wait for a partition primary before rerunning a refusal
discard a3ed521a6 fix(sdk): let producer sends and offset writes inherit the outer deadline
discard a5a9b6fc5 feat(shard): count partition incarnation-mismatch drops by command
discard 095c4afc2 refactor(sdk): store the producer client lock without an extra Arc
discard 9e6df7c04 feat(server): serve linearizable partition reads at the commit floor
discard f3dfe47cd perf(server): serve HTTP partition requests on the owner first
discard e88df618e fix(node): confirm a namespace miss while the metadata primary is down
discard 15b45277f perf(shard): index merged headers once in the view coverage scan
discard eea2a8753 fix(java): sign in with the current login after a refused bind
discard eb0884c28 perf(sdk): write the header and body of a request in one call
discard ab50d8eed test(node): end a call uncertain when its reply is lost and its session ends
discard 3650d8333 fix(node): restart an unsent call under the recovered session
discard 23ab1e1ee perf(shard): truncate a divergent partition run in one transition
discard ecc1b74c3 test(csharp): keep a lost write uncertain when its session then ends
discard 45a4ea297 docs(integration): describe the eager metadata commit in parked frame spec
discard 2c25fd1ae fix(csharp): report Disconnected before a session reset fails calls
discard 3a8862d97 perf(shard): remove a divergent metadata run with one WAL rewrite
discard 25254a2fa fix(csharp): pin the configured address when the client is built
discard c372d167b test(consensus): pin the commit stamp check of the eager Commit
discard aca6686ed refactor(sdk): drop the identifier check from the partition count
discard 6636ce5fc perf(go): evict route cache entries without nested scans
discard 94be3f6c2 perf(common): hash an Identifier without allocating
discard 4e5c35880 test(swift): cover an offset store that a backup primary misses
discard 5f4e69d16 fix(metadata): serve an HTTP PAT create resend that carries its hash
discard daa74c05b fix(server): answer an HTTP NoAck produce denied before the partition
discard 00992bc93 fix(swift): confirm the metadata primary before a miss is final
discard c6e1c72fd fix(swift): fall back to the coordinator for route lookups
discard cefe42615 test(go): gate the managed heartbeat owner before perf changes
discard 3bc9d41db test(simulator): cover stale-route writes during a stream delete
discard 4f7946644 docs(partitions): mark missing simulator partition state transfer
discard dbc913214 test(integration): never expire archives in unlimited archives case
discard 9e2f69628 test(integration): wait for the revocation fence in round-robin spec
discard fb2c28768 docs(server): note that stream and topic stats are per node
discard 06a9dada9 test(integration): wait for backup topic stats in CLI send test
discard 2a1c81940 docs(server): explain keyless HTTP 503s on partition backups
discard 314cae625 fix(node): leave the group when a group consumer stream closes
discard 57f44177c test(integration): enable HTTP relays in spread placement tests
discard 1505fb566 fix(node): end a consumer stream when its sign-in is refused with 40
discard 8472e0fa2 fix(node): stop a group poll from repeating a refused sign-in
discard 5f0b6a838 fix(shard): warn when a resident entry blocks a repaired prepare
discard bd00ff6b5 fix(shard): rotate metadata repair around the whole ring
discard c6c7098c9 fix(shard): pace metadata repair rotation once its budget is spent
discard 5af637db7 fix(go): restart an unsent call under the recovered session
discard a14feac4f fix(java): abort an automatic sign-in only on the connection it uses
discard 66bc9e52c fix(sdk): count no partitions for a malformed identifier
discard c75fc6b18 fix(node): report the dial error when the sign-in deadline ends
discard f1240eaa5 fix(sdk): confirm a namespace miss while the metadata primary is down
discard 45cc093db fix(csharp): wait before polling again after a failed poll
discard 956b646c4 fix(csharp): end receiving with a cancellation when a poll is canceled
discard cae1f269a fix(java): replay a lost request on the metadata primary first
discard 83611ebd6 test(swift): keep a lost write unknown when its session then ends
discard 435a63fed fix(csharp): reject non-positive pending limits in the client factory
discard e29336cc8 test(server): cover a proven HTTP relay holding for an uncommitted op
discard a6b98f33d fix(server): prove HTTP relay hops with an HMAC over their fields
discard 0a22e1725 perf(sdk): group cached routes by stream and topic
discard e2984169f fix(java): close the channel of a Register that timed out
discard 01a8657c9 fix(consensus): announce metadata and fence commits on the next tick
discard 0f45d8cc8 test(simulator): cover a refusing primary that holds the repair hole
discard 34698a66c fix(consensus): keep the client table decodable after user revocation
discard 06bad455d fix(server): give HTTP relay targets their own budget and body deadline
discard b4ba5ebe7 test(swift): check wrong dials on the client, not server counts
discard 87a35501f perf(sdk): carry the progress flag of a poll into its call
discard c39ceddc1 test(swift): require the BindSession before reading its client id
discard c3fb389dd test(simulator): block ns_a acks on every replica link
discard c28f5c55a refactor(node): drop ignored followsLeaderMoves route option
discard 1c5df19ce perf(sdk): evict the oldest route without scanning the cache
discard 8388b9765 refactor(node): throw the zero-budget timeout directly in exchange
discard e89e22562 test(swift): make the fake server's stop wait until clients see it
discard dd496b098 refactor(node): remove test-only currentRequestId
discard 7d7e5e34c fix(java): retry a backup's namespace miss from the metadata primary
discard 6940acc29 refactor(node): remove write-only IggyConnection.connecting
discard cae566890 refactor(node): remove uncalled IggyConnection.abort
discard 63523444c refactor(node): drop unread ClientOperation generation
discard 9eb583099 fix(shard): stop busy transfer refusals from refilling the stall budget
discard 783ca46a6 test(go): pin that a call starts each session recovery at once
discard 2eb552ba7 fix(node): report stopped links as exhausted after heartbeat(0)
discard c7abc0b59 test(go): require split primaries and a real run in the Go #4292 gate
discard 1d9f08d03 fix(csharp): retry a namespace miss from a backup partition primary
discard e071f639d fix(node): fail a call at once when every replica link stopped
discard 85473ad0d fix(swift): refresh credentials after raw password changes
discard 14c1858ea fix(shard): rotate repair off a peer that refused a state transfer
discard cbd71366d fix(go): retry a namespace miss from a metadata backup primary
discard 0540c7b07 fix(swift): retry namespace misses from metadata backups
discard 39258e04d fix(node): retry a namespace miss from a backup partition primary
discard bb481fad8 fix(sdk): retry a namespace miss from a metadata backup
discard cb43af193 refactor(csharp): drop the unused connection from VsrAttempt
discard 25ec44337 refactor(csharp): read the committed reply marker once
discard 6a3aad8ca refactor(csharp): share the BindSession exchange
discard 73b520321 refactor(csharp): drop the unreachable replica roster size guard
discard e3b936f2b refactor(csharp): remove dead login flag from IsReplaySafeRead
discard 65be8345c refactor(sdk): name the Swift committed-result marker
discard 8de1632bf feat(cluster)!: spread and rebalance partition primaries
add fcf110f41 feat(gateways): report a stable Kafka cluster id and serve DescribeCluster (#4583)
add 3a8e85990 feat(gateways): serve Kafka CreatePartitions through the Iggy bridge (#4565)
add 5906e2db7 fix(cli): print errors and their causes instead of Debug value (#4590)
new 43cebb262 feat(cluster)!: spread and rebalance partition primaries
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (29d05754d)
\
N -- N -- N refs/heads/feat/spread-placement (43cebb262)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.config/nextest.toml | 2 +
Cargo.lock | 1 +
core/cli/src/main.rs | 15 +-
core/consensus/src/impls.rs | 6 +-
core/integration/src/harness/handle/server.rs | 9 +
core/integration/src/harness/helpers.rs | 43 +-
core/integration/src/harness/mod.rs | 2 +-
.../src/harness/orchestrator/harness.rs | 63 +-
.../tests/cli/general/test_missing_credentials.rs | 4 +-
.../tests/cli/system/test_me_command.rs | 4 +-
.../tests/cli/topic/test_topic_get_command.rs | 42 +-
.../tests/cluster/partition_primary_routing.rs | 9 +
core/integration/tests/sdk/options.rs | 16 +-
.../scenarios/segment_rotation_race_scenario.rs | 2 +-
core/metadata/src/applied_frontier.rs | 39 +-
core/sdk/src/links/replicas.rs | 27 +-
core/server_common/src/consensus_message.rs | 1 +
core/shard/src/primary_rebalance.rs | 92 +-
core/shard/src/primary_rebalance/tests.rs | 101 ++
core/simulator/src/lib.rs | 27 +-
gateways/kafka/Cargo.toml | 1 +
gateways/kafka/README.md | 68 +-
gateways/kafka/docs/BRIDGE_MAPPING.md | 7 +
gateways/kafka/docs/MANUAL_TESTING.md | 10 +-
gateways/kafka/docs/SCOPE.md | 125 +-
gateways/kafka/docs/TEST_SUITE.md | 12 +-
gateways/kafka/docs/kafka_api_keys_reference.md | 4 +-
gateways/kafka/scripts/ci-wire-fixtures.sh | 2 +-
gateways/kafka/src/bridge/error.rs | 56 +-
.../kafka/src/bridge/iggy_bridge/cluster_id.rs | 414 +++++
gateways/kafka/src/bridge/iggy_bridge/mod.rs | 16 +-
gateways/kafka/src/bridge/iggy_bridge/topics.rs | 50 +-
gateways/kafka/src/bridge/mod.rs | 9 +-
gateways/kafka/src/bridge/topic_map.rs | 61 +-
gateways/kafka/src/cluster_id.rs | 202 +++
gateways/kafka/src/error.rs | 4 +
gateways/kafka/src/lib.rs | 1 +
gateways/kafka/src/main.rs | 8 +
gateways/kafka/src/protocol/api.rs | 51 +-
gateways/kafka/src/protocol/bounds_guard.rs | 163 ++
.../src/protocol/handlers/create_partitions.rs | 1702 ++++++++++++++++++++
.../kafka/src/protocol/handlers/create_topics.rs | 14 +-
.../kafka/src/protocol/handlers/delete_topics.rs | 27 +-
.../src/protocol/handlers/describe_cluster.rs | 232 +++
gateways/kafka/src/protocol/handlers/metadata.rs | 91 +-
gateways/kafka/src/protocol/handlers/mod.rs | 27 +-
.../kafka/src/protocol/handlers/topic_config.rs | 11 +-
gateways/kafka/src/server.rs | 58 +-
gateways/kafka/tests/api_handler_tests.rs | 87 +-
.../kafka/tests/cluster_id_real_bridge_tests.rs | 429 +++++
gateways/kafka/tests/common/iggy_server.rs | 7 +
gateways/kafka/tests/common/scope.rs | 2 +
gateways/kafka/tests/common/wire.rs | 52 +
.../tests/create_partitions_real_bridge_tests.rs | 687 ++++++++
.../kafka/tests/create_topics_real_bridge_tests.rs | 27 +-
.../kafka/tests/delete_topics_real_bridge_tests.rs | 38 +-
gateways/kafka/tests/golden_wire_fixtures_tests.rs | 16 +-
gateways/kafka/tests/kafka_client_e2e_tests.rs | 102 ++
gateways/kafka/tests/version_firewall_tests.rs | 36 +-
gateways/kafka/tools/kafka-tool/README.md | 4 +-
gateways/kafka/tools/kafka-tool/src/main.rs | 21 +
gateways/kafka/tools/kafka-tool/src/response.rs | 3 +-
62 files changed, 5186 insertions(+), 256 deletions(-)
create mode 100644 gateways/kafka/src/bridge/iggy_bridge/cluster_id.rs
create mode 100644 gateways/kafka/src/cluster_id.rs
create mode 100644 gateways/kafka/src/protocol/handlers/create_partitions.rs
create mode 100644 gateways/kafka/src/protocol/handlers/describe_cluster.rs
create mode 100644 gateways/kafka/tests/cluster_id_real_bridge_tests.rs
create mode 100644 gateways/kafka/tests/create_partitions_real_bridge_tests.rs
(iggy) 01/01: feat(cluster)!: spread and rebalance partition primaries
Posted by hg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch feat/spread-placement
in repository https://gitbox.apache.org/repos/asf/iggy.git
commit 43cebb262f8c424763b33a2d8ac558f57132abd2
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Wed Oct 21 18:31:15 2026 +0200
feat(cluster)!: spread and rebalance partition primaries
Partition primaries followed the initial metadata view and a fixed
failover rotation, so primary work piled onto a subset of replicas
and stayed there after a failed node returned.
A new partition takes its primary from the last known primary views,
with a fallback to the committed initial view. Placement counts
committed ownership plus admitted creates, each partition once. The
metadata primary also moves existing primaries one at a time. It
uses fresh authenticated peer probes and a stable set of eligible
replicas. A move confirms the source frontier and the target's
applied prefix, drains accepted work under a revocable grant, and
persists an ordinary consensus view change before the target serves.
A replica refuses client partition requests with 58 when its last
commit evidence from the metadata primary is older than the
heartbeat timeout plus 3 s. This stops a replica that is cut off
from the metadata primary from serving on stale grants.
Some restarts lost a committed metadata op. The bug predates this
change. A replica now claims only a log view that it proved, in a
view change and on disk, and refuses a StartView or an old prepare
that contradicts an op it already applied. The superblock keeps the
adopted view, its head and exact headers across restarts. A state
transfer keeps the WAL entries above its snapshot when the current
view proves their identity.
Every SDK now follows the same session rules. Rust, Node, Swift, Go,
C# and Java implement them, and the Python, C++ and PHP bindings get
them from the Rust SDK. A sign-in, sign-out or disconnect by the
caller ends the calls it affects with 40. A call whose deadline
passes while it waits for a session or a primary ends with 58. Swift
no longer sends a write with an unknown outcome again under a new
session or request id, and ends it with 57 instead. The Rust SDK
waits 10 s, not 30 s, for a session bind, then drops the link and
dials again.
HTTP relays between nodes carry an HMAC proof that is valid for
10 s, so node clocks must agree within a few seconds.
BREAKING CHANGE: storage moves to the IGGY-BOUND-GRANTS-5 boundary,
because partition creation records now carry an initial view per
assignment. Older data directories are refused unchanged before
replay, and no migration is provided. Superblocks are written as
envelope v2, which older binaries refuse. The ProducerSendFailed
error gains an uncertain-message count and stores its stream and
topic names as Box<str>. ConsumerGroupPartitionNotOwned carries a
u128 client id. SDK call outcomes change as described above.
---
Cargo.lock | 3 +
core/ai/mcp/src/service/mod.rs | 11 +-
.../actors/producer/typed_benchmark_producer.rs | 2 +-
.../typed_benchmark_producing_consumer.rs | 2 +-
core/binary_protocol/src/codes.rs | 3 +-
core/binary_protocol/src/consensus/command.rs | 9 +-
core/binary_protocol/src/consensus/header.rs | 39 +
core/binary_protocol/src/consensus/mod.rs | 2 +
.../src/consensus/primary_rebalance.rs | 355 ++
core/binary_protocol/src/lib.rs | 14 +-
core/binary_protocol/src/primitives/consumer.rs | 32 +-
core/binary_protocol/src/primitives/identifier.rs | 87 +-
.../src/primitives/partition_assignment.rs | 24 +-
.../src/requests/messages/poll_messages.rs | 114 +
.../create_partitions_with_assignments.rs | 114 +-
.../topics/create_topic_with_assignments.rs | 95 +-
core/common/src/consumer_group_client_state.rs | 198 +-
core/common/src/error/iggy_error.rs | 67 +-
core/common/src/lib.rs | 5 +-
core/common/src/traits/binary_impls/messages.rs | 251 +-
core/common/src/traits/binary_impls/mod.rs | 41 +-
core/common/src/traits/binary_impls/partitions.rs | 2 -
.../traits/binary_impls/personal_access_tokens.rs | 30 +-
core/common/src/traits/binary_impls/streams.rs | 1 -
core/common/src/traits/binary_impls/topics.rs | 6 +-
core/common/src/traits/binary_impls/users.rs | 48 +-
core/common/src/traits/binary_transport.rs | 291 +-
core/common/src/traits/mod.rs | 2 +-
core/common/src/types/identifier/mod.rs | 84 +-
core/common/src/wire_conversions.rs | 27 +-
core/configs/src/server_config/cluster.rs | 65 +-
core/configs/src/server_config/metadata.rs | 5 +-
core/connectors/runtime/src/main.rs | 2 -
core/connectors/runtime/src/manager/source.rs | 4 +-
core/connectors/runtime/src/source.rs | 151 +-
core/consensus/src/client_table.rs | 89 +-
core/consensus/src/dvc_merge.rs | 65 +-
core/consensus/src/impls.rs | 1918 +++++-
core/consensus/src/lib.rs | 2 +-
core/consensus/src/metadata_helpers.rs | 48 +-
core/consensus/src/observability.rs | 14 +-
core/consensus/src/plane_helpers.rs | 503 +-
core/consensus/src/view_change_quorum.rs | 3 +-
core/consensus/src/vsr_state.rs | 638 +-
core/integration/Cargo.toml | 3 +
core/integration/src/harness/handle/server.rs | 42 +-
core/integration/src/harness/helpers.rs | 75 +-
core/integration/src/harness/mod.rs | 4 +-
.../src/harness/orchestrator/harness.rs | 63 +-
core/integration/tests/cli/common/mod.rs | 7 +
.../tests/cli/message/test_message_send_command.rs | 24 +-
.../tests/cli/topic/test_topic_get_command.rs | 42 +-
.../tests/cluster/client_table_restart.rs | 360 +-
.../tests/cluster/parked_frame_redispatch.rs | 21 +-
core/integration/tests/cluster/partition_dedup.rs | 157 +
.../tests/cluster/partition_primary_routing.rs | 1437 ++++-
.../tests/cluster/staggered_bootstrap.rs | 13 +-
.../verify_consumer_group_partition_assignment.rs | 77 +-
core/integration/tests/sdk/options.rs | 16 +-
core/integration/tests/server/general.rs | 6 +
core/integration/tests/server/http_vsr.rs | 89 +-
.../server/scenarios/log_rotation_scenario.rs | 5 +-
.../scenarios/segment_rotation_race_scenario.rs | 2 +-
.../tests/server/shared_jwt_secrets.toml | 23 +
core/journal/src/prepare_journal.rs | 57 +
core/journal/src/superblock.rs | 49 +-
core/message_bus/src/installer/mod.rs | 55 +-
core/message_bus/src/installer/quic.rs | 8 +-
core/message_bus/src/installer/tcp.rs | 14 +-
core/message_bus/src/installer/ws.rs | 11 +-
core/message_bus/src/lib.rs | 247 +-
.../src/lifecycle/connection_registry.rs | 275 +-
core/message_bus/src/transports/quic.rs | 229 +-
core/message_bus/tests/duplicate_client_id.rs | 51 +
core/message_bus/tests/quic_client_roundtrip.rs | 18 +-
core/message_bus/tests/tcp_client_roundtrip.rs | 258 +-
core/message_bus/tests/tcp_tls_client_listener.rs | 20 +-
core/message_bus/tests/tcp_tls_client_roundtrip.rs | 15 +-
core/message_bus/tests/ws_client_roundtrip.rs | 25 +-
core/message_bus/tests/wss_client_listener.rs | 15 +-
core/message_bus/tests/wss_client_roundtrip.rs | 15 +-
core/metadata/src/applied_frontier.rs | 132 +-
core/metadata/src/impls/metadata.rs | 3334 ++++++++--
core/metadata/src/impls/mod.rs | 1 +
core/metadata/src/impls/placement.rs | 570 ++
core/metadata/src/impls/recovery.rs | 348 +-
core/metadata/src/stm/authz.rs | 2 +-
core/metadata/src/stm/consumer_group.rs | 4 +-
core/metadata/src/stm/lifecycle.rs | 2 +-
core/metadata/src/stm/snapshot.rs | 17 +-
core/metadata/src/stm/stream.rs | 177 +-
core/metadata/src/stm/user.rs | 5 +-
core/partitions/src/iggy_partition.rs | 1569 ++++-
core/partitions/src/io.rs | 26 +-
core/partitions/src/lib.rs | 7 +-
core/partitions/src/poll_plan.rs | 17 +-
core/partitions/src/state_transfer.rs | 6 +
core/partitions/src/types.rs | 11 +
core/partitions/tests/consumer_offset_history.rs | 4 +-
core/sdk/README.md | 25 +-
core/sdk/src/binary/mod.rs | 72 +-
core/sdk/src/client_wrappers/client_wrapper.rs | 38 +-
core/sdk/src/clients/client.rs | 87 +-
core/sdk/src/clients/consumer.rs | 350 +-
core/sdk/src/clients/producer.rs | 726 ++-
core/sdk/src/clients/producer_dispatcher.rs | 15 +-
core/sdk/src/clients/producer_sharding.rs | 26 +-
core/sdk/src/consumer_ext/consumer_message_ext.rs | 49 +-
core/sdk/src/driver.rs | 1079 +++-
core/sdk/src/driver/route_cache.rs | 298 +-
core/sdk/src/links/fake_cluster.rs | 139 +-
core/sdk/src/links/mux.rs | 78 +-
core/sdk/src/links/replicas.rs | 227 +-
core/sdk/src/session_owner.rs | 237 +-
core/sdk/src/session_owner/lanes.rs | 36 +-
core/sdk/src/tcp/tcp_client.rs | 725 ++-
core/sdk/src/websocket/websocket_client.rs | 15 +
core/server/Cargo.toml | 2 +
core/server/README.md | 185 +-
core/server/config.toml | 26 +-
core/server/src/boot/mod.rs | 30 +-
core/server/src/boot/recovery.rs | 24 +-
core/server/src/consumer_group.rs | 2 +-
core/server/src/consumer_group/liveness.rs | 134 +-
core/server/src/dispatch/authz.rs | 52 +-
core/server/src/dispatch/failure.rs | 285 +-
core/server/src/dispatch/host.rs | 144 +-
core/server/src/dispatch/mod.rs | 1372 +++-
core/server/src/dispatch/partition.rs | 1280 +++-
core/server/src/dispatch/reads.rs | 142 +-
core/server/src/dispatch/session_ops.rs | 97 +-
core/server/src/dispatch/submit.rs | 4 +-
core/server/src/dispatch/test_support.rs | 169 +-
core/server/src/http.rs | 44 +-
core/server/src/http/admission.rs | 139 +-
core/server/src/http/error.rs | 21 +-
core/server/src/http/extractor.rs | 116 +-
core/server/src/http/forward.rs | 1799 +++++-
core/server/src/http/handlers.rs | 452 +-
core/server/src/http/metrics.rs | 37 +
core/server/src/http/reads.rs | 261 +-
core/server/src/http/relay_proof.rs | 356 ++
core/server/src/http/reply.rs | 9 +
core/server/src/http/state.rs | 45 +-
core/server/src/http/submit.rs | 138 +-
core/server/src/lib.rs | 5 +-
core/server/src/namespace.rs | 102 +-
core/server/src/partition_helpers.rs | 47 +-
core/server/src/partition_reconciler.rs | 318 +-
core/server/src/session_manager.rs | 175 +-
core/server_common/src/consensus_message.rs | 139 +-
core/shard/src/lib.rs | 2437 +++++++-
core/shard/src/metrics.rs | 83 +-
core/shard/src/partition_io.rs | 1834 +++++-
core/shard/src/poll.rs | 20 +-
core/shard/src/poll/completion_tests.rs | 22 +-
core/shard/src/primary_rebalance.rs | 790 +++
core/shard/src/primary_rebalance/tests.rs | 865 +++
core/shard/src/router.rs | 192 +
core/simulator/Cargo.toml | 2 +-
core/simulator/README.md | 23 +-
core/simulator/src/bin/workload-fuzz.rs | 197 +-
core/simulator/src/bus.rs | 139 +-
core/simulator/src/client.rs | 67 +-
core/simulator/src/deps.rs | 15 +
core/simulator/src/executor/time.rs | 17 +-
core/simulator/src/lib.rs | 6523 ++++++++++++++++----
core/simulator/src/packet.rs | 32 +-
core/simulator/src/replica.rs | 107 +-
core/simulator/src/workload/actions.rs | 1 +
core/simulator/src/workload/invariants.rs | 17 +
core/simulator/src/workload/mod.rs | 526 +-
core/simulator/src/workload/ops/mod.rs | 2 +
core/simulator/src/workload/ops/poll_messages.rs | 117 +
.../src/workload/ops/store_stale_owner_offset.rs | 4 +-
core/simulator/src/workload/options.rs | 17 +-
core/simulator/src/workload/oracle.rs | 496 +-
core/simulator/src/workload/readback.rs | 420 ++
core/simulator/src/workload/state_checker.rs | 288 +-
core/simulator/tests/lifecycle_seeds.rs | 127 +
foreign/cpp/README.md | 5 +
foreign/cpp/include/iggy.hpp | 24 +-
foreign/cpp/src/client.rs | 101 +-
foreign/cpp/tests/unit/unit_tests.cpp | 22 +
.../Iggy_SDK/Consumers/IggyConsumer.Rented.cs | 37 +-
foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs | 304 +-
.../csharp/Iggy_SDK/Consumers/IggyConsumerOfT.cs | 23 +-
.../Exceptions/IggyInvalidStatusCodeException.cs | 3 +-
.../csharp/Iggy_SDK/Factory/IggyClientFactory.cs | 11 +-
.../TcpMessageStream.PollRouting.cs | 216 +-
.../TcpMessageStream.ReplicaLinks.cs | 74 +-
.../Implementations/TcpMessageStream.Vsr.cs | 307 +-
.../IggyClient/Implementations/TcpMessageStream.cs | 202 +-
foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs | 2 +-
foreign/csharp/Iggy_SDK/Vsr/ConsensusSession.cs | 126 +-
foreign/csharp/Iggy_SDK/Vsr/MutationLanes.cs | 437 +-
foreign/csharp/Iggy_SDK/Vsr/VsrConnection.cs | 34 +-
foreign/csharp/Iggy_SDK/Vsr/VsrError.cs | 6 +-
foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs | 11 +-
.../ClientTests/IggyClientFactoryTests.cs | 23 +
.../PartitionIncarnationTests.Disposal.cs | 240 +
.../ConsumerTests/PartitionIncarnationTests.cs | 2 +-
.../ConsumerTests/PollFailureTests.cs | 457 ++
.../VsrTests/ConsensusSessionTests.cs | 17 +-
.../VsrTests/EndpointFailoverTests.cs | 69 +-
foreign/csharp/Iggy_SDK_Tests/VsrTests/MockNode.cs | 12 +-
.../Iggy_SDK_Tests/VsrTests/MutationLaneTests.cs | 302 +-
.../VsrTests/NativeRecoveryTests.SignIn.cs | 490 ++
.../Iggy_SDK_Tests/VsrTests/NativeRecoveryTests.cs | 644 +-
.../Iggy_SDK_Tests/VsrTests/PrimaryPollingTests.cs | 112 +-
.../Iggy_SDK_Tests/VsrTests/ReplicaLinkTests.cs | 177 +-
.../VsrTests/VsrMultiplexingTests.cs | 4 +-
foreign/csharp/README.md | 35 +
foreign/go/README.md | 50 +-
foreign/go/client/iggy_client.go | 11 +-
foreign/go/client/iggy_client_test.go | 48 +
foreign/go/client/tcp/cluster.go | 55 +-
foreign/go/client/tcp/tcp_connect_test.go | 373 +-
foreign/go/client/tcp/tcp_core.go | 77 +-
foreign/go/client/tcp/tcp_core_review_test.go | 21 +-
foreign/go/client/tcp/tcp_core_test.go | 13 +-
foreign/go/client/tcp/tcp_driver.go | 127 +-
foreign/go/client/tcp/tcp_driver_test.go | 121 +-
foreign/go/client/tcp/tcp_failover_test.go | 117 +-
foreign/go/client/tcp/tcp_group_polling.go | 79 +-
foreign/go/client/tcp/tcp_group_polling_test.go | 48 +-
foreign/go/client/tcp/tcp_link.go | 36 +-
foreign/go/client/tcp/tcp_link_test.go | 275 +
foreign/go/client/tcp/tcp_messaging.go | 12 +-
foreign/go/client/tcp/tcp_offset_management.go | 9 +-
foreign/go/client/tcp/tcp_pending.go | 335 +-
foreign/go/client/tcp/tcp_poll_routing.go | 273 +-
foreign/go/client/tcp/tcp_poll_routing_test.go | 895 ++-
foreign/go/client/tcp/tcp_replicas.go | 36 +-
foreign/go/client/tcp/tcp_replicas_test.go | 598 +-
.../go/client/tcp/tcp_session_credentials_test.go | 158 +-
foreign/go/client/tcp/tcp_session_management.go | 403 +-
foreign/go/client/tcp/tcp_testing_test.go | 19 +-
foreign/go/client/tcp/tcp_topic_cache_test.go | 14 +-
foreign/go/client/tcp/tcp_user_management.go | 17 +-
foreign/go/contracts/client.go | 2 +-
foreign/go/errors/errors.yaml | 4 +-
foreign/go/errors/errors_gen.go | 24 +-
foreign/go/errors/errors_test.go | 2 +-
foreign/go/internal/command/code.go | 2 +-
foreign/go/internal/vsr/reply.go | 46 +-
foreign/go/internal/vsr/reply_test.go | 117 +-
foreign/go/internal/vsr/session.go | 18 +-
foreign/go/internal/vsr/session_test.go | 36 +-
foreign/go/tests/e2e_helpers_test.go | 43 +
foreign/go/tests/e2e_test.go | 31 +-
foreign/java/README.md | 9 +
.../iggy/client/async/tcp/AsyncIggyTcpClient.java | 676 +-
.../async/tcp/AsyncIggyTcpClientBuilder.java | 20 +-
.../iggy/client/async/tcp/AsyncTcpConnection.java | 838 +--
.../client/async/tcp/ConsumerGroupsTcpClient.java | 11 +-
.../client/async/tcp/ConsumerOffsetsTcpClient.java | 10 +-
.../iggy/client/async/tcp/CredentialsListener.java | 28 +
.../iggy/client/async/tcp/LeaderAwareness.java | 23 -
.../iggy/client/async/tcp/LoginRoutingHook.java | 8 +-
.../iggy/client/async/tcp/MessagesTcpClient.java | 127 +-
.../apache/iggy/client/async/tcp/PollRouter.java | 305 +-
.../iggy/client/async/tcp/PoolChannelHandler.java | 93 +
.../client/async/tcp/RawPartitionRequests.java | 2 +-
.../apache/iggy/client/async/tcp/SessionLogin.java | 153 +
...tingHook.java => TransientFailoverHandler.java} | 31 +-
.../iggy/client/async/tcp/UsersTcpClient.java | 20 +-
.../apache/iggy/client/async/tcp/VsrExchange.java | 38 +
.../client/async/tcp/vsr/ConsensusSession.java | 258 +-
.../client/async/tcp/vsr/VsrResponseHandler.java | 15 +-
.../java/org/apache/iggy/serde/CommandCode.java | 2 +-
.../java/org/apache/iggy/ProtocolParityTest.java | 219 +
.../src/test/java/org/apache/iggy/RustSources.java | 43 +
.../client/async/AsyncClientIntegrationTest.java | 164 +
.../async/tcp/AsyncIggyTcpClientBuilderTest.java | 9 +
.../AsyncIggyTcpClientEndpointFailoverTest.java | 515 +-
.../tcp/AsyncIggyTcpClientFailoverSignInTest.java | 409 ++
.../tcp/AsyncIggyTcpClientLoginRoutingTest.java | 88 +-
.../tcp/AsyncIggyTcpClientLostLinkReplayTest.java | 190 +
.../tcp/AsyncIggyTcpClientNamespaceMissTest.java | 432 ++
.../tcp/AsyncIggyTcpClientPartitionWriteTest.java | 118 +
.../async/tcp/AsyncIggyTcpClientRecoveryTest.java | 339 +
.../tcp/AsyncIggyTcpClientSessionRulesTest.java | 925 +++
.../AsyncIggyTcpClientTransientFailoverTest.java | 22 +-
.../tcp/AsyncTcpConnectionConcurrencyTest.java | 82 +
.../tcp/AsyncTcpConnectionLifecycleBusyTest.java | 10 +-
.../tcp/AsyncTcpConnectionRequestTimeoutTest.java | 61 +
.../iggy/client/async/tcp/LeaderAwarenessTest.java | 30 -
.../iggy/client/async/tcp/PollRouterTest.java | 469 +-
.../client/async/tcp/vsr/VsrLoginCodecTest.java | 11 +-
foreign/node/README.md | 72 +-
foreign/node/src/client/client.connection.test.ts | 34 +-
foreign/node/src/client/client.connection.ts | 28 +-
foreign/node/src/client/client.operation.test.ts | 22 +-
foreign/node/src/client/client.operation.ts | 19 +-
foreign/node/src/client/client.socket.test.ts | 1592 ++++-
foreign/node/src/client/client.socket.ts | 463 +-
foreign/node/src/client/client.type.ts | 15 +-
foreign/node/src/stream/consumer-stream.ts | 69 +-
foreign/node/src/wire/command.code.ts | 2 +-
.../src/wire/message/poll-messages.command.test.ts | 73 +-
.../node/src/wire/message/poll-messages.command.ts | 42 +-
.../node/src/wire/message/send-messages.command.ts | 15 +-
.../src/wire/session/login-with-token.command.ts | 10 +-
foreign/node/src/wire/session/login.command.ts | 10 +-
foreign/node/src/wire/vsr/session.test.ts | 8 +-
foreign/node/src/wire/vsr/session.ts | 5 -
foreign/php/README.md | 3 +
foreign/php/src/client.rs | 2 -
foreign/python/README.md | 12 +-
foreign/python/apache_iggy.pyi | 10 +-
foreign/python/src/client.rs | 19 +-
foreign/python/src/producer.rs | 39 +-
foreign/python/tests/test_quic_config.py | 7 +
foreign/swift/README.md | 64 +-
.../Sources/Iggy/Client/ClientConfiguration.swift | 8 +-
foreign/swift/Sources/Iggy/Client/ClientCore.swift | 635 +-
.../Sources/Iggy/Client/IggyClient+Messages.swift | 106 +-
.../Sources/Iggy/Client/IggyClient+Resources.swift | 130 +-
foreign/swift/Sources/Iggy/Client/IggyClient.swift | 99 +-
foreign/swift/Sources/Iggy/Client/PollRouter.swift | 564 +-
foreign/swift/Sources/Iggy/Errors/IggyError.swift | 18 +-
.../swift/Sources/Iggy/Protocol/CommandCode.swift | 2 +-
foreign/swift/Sources/Iggy/Protocol/Requests.swift | 20 +
foreign/swift/Sources/Iggy/Protocol/VSRFrame.swift | 6 +-
.../Tests/IggyTests/ClientTransportTests.swift | 1334 +++-
foreign/swift/Tests/IggyTests/MessagingTests.swift | 1229 +++-
foreign/swift/Tests/IggyTests/ProtocolTests.swift | 6 +-
.../Tests/IggyTests/Support/FakeVSRServer.swift | 117 +-
gateways/kafka/README.md | 6 +-
gateways/kafka/src/bridge/error.rs | 19 +-
gateways/kafka/src/bridge/iggy_bridge/produce.rs | 19 +-
332 files changed, 57450 insertions(+), 8659 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 7c347cc2f..3a573d2d4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8080,6 +8080,7 @@ dependencies = [
"assert_cmd",
"async-trait",
"base64 0.23.1",
+ "blake3",
"bon",
"bytemuck",
"bytes",
@@ -12943,6 +12944,7 @@ dependencies = [
"fs2",
"futures",
"hash32 1.0.0",
+ "hex",
"human-repr",
"hwlocality",
"hyper",
@@ -12969,6 +12971,7 @@ dependencies = [
"prometheus-client",
"quote",
"rand 0.10.3",
+ "ring",
"ringbuffer",
"rmp-serde",
"rolling-file",
diff --git a/core/ai/mcp/src/service/mod.rs b/core/ai/mcp/src/service/mod.rs
index e1312290a..f12952258 100644
--- a/core/ai/mcp/src/service/mod.rs
+++ b/core/ai/mcp/src/service/mod.rs
@@ -785,14 +785,9 @@ fn id(id: &str) -> Result<Identifier, ErrorData> {
fn request(result: Result<impl Sized + Serialize, IggyError>) -> Result<CallToolResult, ErrorData> {
let result = result.map_err(|e| {
- let data = match &e {
- IggyError::OperationUncertain(op)
- | IggyError::LaneBlocked(op)
- | IggyError::OperationNotRetained(op) => {
- Some(serde_json::json!({ "operation_id": op.to_string() }))
- }
- _ => None,
- };
+ let data = e
+ .operation_id()
+ .map(|op| serde_json::json!({ "operation_id": op.to_string() }));
let message = format!("There was an error when invoking the method. {e}");
error!(message);
ErrorData::invalid_request(message, data)
diff --git a/core/bench/src/actors/producer/typed_benchmark_producer.rs b/core/bench/src/actors/producer/typed_benchmark_producer.rs
index b9ed292fa..411d1523f 100644
--- a/core/bench/src/actors/producer/typed_benchmark_producer.rs
+++ b/core/bench/src/actors/producer/typed_benchmark_producer.rs
@@ -93,7 +93,7 @@ impl TypedBenchmarkProducer {
pub async fn run(self) -> Result<BenchmarkIndividualMetrics, IggyError> {
match self {
- Self::High(producer) => producer.run().await,
+ Self::High(producer) => Box::pin(producer.run()).await,
Self::Low(producer) => producer.run().await,
}
}
diff --git a/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs b/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
index b193905f1..280d7145e 100644
--- a/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
+++ b/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
@@ -123,7 +123,7 @@ impl TypedBenchmarkProducingConsumer {
pub async fn run(self) -> Result<BenchmarkIndividualMetrics, IggyError> {
match self {
- Self::High(p) => p.run().await,
+ Self::High(p) => Box::pin(p.run()).await,
Self::Low(p) => p.run().await,
}
}
diff --git a/core/binary_protocol/src/codes.rs b/core/binary_protocol/src/codes.rs
index 4c1626696..d90f4463d 100644
--- a/core/binary_protocol/src/codes.rs
+++ b/core/binary_protocol/src/codes.rs
@@ -66,6 +66,7 @@ pub const GET_CONSUMER_OFFSET_CODE: u32 = 120;
pub const STORE_CONSUMER_OFFSET_CODE: u32 = 121;
pub const DELETE_CONSUMER_OFFSET_CODE: u32 = 122;
// 123 was GET_CONSUMER_OFFSET_ROUTING. Never reuse it: released clients still send it.
+// 124 was DELETE_CONSUMER_OFFSET_2. Never reuse it.
// -- Streams --
pub const GET_STREAM_CODE: u32 = 200;
@@ -114,7 +115,7 @@ pub const fn command_name(code: u32) -> Result<&'static str, WireError> {
mod tests {
use super::*;
- const RETIRED_CODES: &[u32] = &[14, 102, 103, 104, 123, 205, 305];
+ const RETIRED_CODES: &[u32] = &[14, 102, 103, 104, 123, 124, 205, 305];
const ALL_CODES: &[u32] = &[
PING_CODE,
diff --git a/core/binary_protocol/src/consensus/command.rs b/core/binary_protocol/src/consensus/command.rs
index 49ee96ff0..3da38b94b 100644
--- a/core/binary_protocol/src/consensus/command.rs
+++ b/core/binary_protocol/src/consensus/command.rs
@@ -92,6 +92,9 @@ pub enum Command {
// may hold no replica of that partition. No reply: the commit replicates
// back.
ForwardCompletion = 32,
+
+ // Fresh metadata and partition readiness for primary rebalancing.
+ PrimaryRebalance = 33,
}
// SAFETY: Command is #[repr(u8)] with no padding bytes.
@@ -102,7 +105,7 @@ unsafe impl CheckedBitPattern for Command {
type Bits = u8;
fn is_valid_bit_pattern(bits: &u8) -> bool {
- *bits <= Self::ForwardCompletion as u8
+ *bits <= Self::PrimaryRebalance as u8
}
}
@@ -123,7 +126,7 @@ mod tests {
#[test]
fn replica_auth_commands_are_valid_bit_patterns() {
- for command in 14u8..=32 {
+ for command in 14u8..=33 {
let mut buf: AVec<u8, ConstAlign<16>> = AVec::new(16);
buf.resize(256, 0);
buf[60] = command;
@@ -131,7 +134,7 @@ mod tests {
}
let mut buf: AVec<u8, ConstAlign<16>> = AVec::new(16);
buf.resize(256, 0);
- buf[60] = 33;
+ buf[60] = 34;
assert!(bytemuck::checked::try_from_bytes::<GenericHeader>(&buf).is_err());
}
}
diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index fa44146f5..ee095693b 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -789,6 +789,12 @@ impl ReplyHeader {
&& self.status == 0
&& self.reserved[0] == Self::COMMITTED_RESULT
}
+
+ /// Withdraw the committed-result marker and keep every other byte, for a
+ /// reply to a committed prepare whose final result is still pending.
+ pub const fn clear_committed_result(&mut self) {
+ self.reserved[0] = 0;
+ }
}
impl ConsensusHeader for ReplyHeader {
@@ -3585,6 +3591,39 @@ mod tests {
assert!(!reply.has_committed_result());
}
+ #[test]
+ fn given_committed_result_when_cleared_should_change_only_the_marker_byte() {
+ let committed = ReplyHeader::from_prepare(
+ &PrepareHeader {
+ cluster: 3,
+ view: 5,
+ release: 6,
+ replica: 7,
+ client: 9,
+ request_checksum: 11,
+ op: 12,
+ timestamp: 14,
+ request: 15,
+ operation: Operation::CreateStream,
+ partition_incarnation: 19,
+ ..PrepareHeader::default()
+ },
+ u32::try_from(HEADER_SIZE).unwrap(),
+ );
+ assert!(committed.has_committed_result());
+ let mut cleared = committed;
+ cleared.clear_committed_result();
+ assert!(!cleared.has_committed_result());
+ let changed: Vec<usize> = bytemuck::bytes_of(&committed)
+ .iter()
+ .zip(bytemuck::bytes_of(&cleared))
+ .enumerate()
+ .filter(|(_, (before, after))| before != after)
+ .map(|(offset, _)| offset)
+ .collect();
+ assert_eq!(changed, [offset_of!(ReplyHeader, reserved)]);
+ }
+
#[test]
fn reply_echoing_copies_the_frame_and_request_identity_only() {
let request = RoutedRequestHeader {
diff --git a/core/binary_protocol/src/consensus/mod.rs b/core/binary_protocol/src/consensus/mod.rs
index 8e7f398b0..07745df4e 100644
--- a/core/binary_protocol/src/consensus/mod.rs
+++ b/core/binary_protocol/src/consensus/mod.rs
@@ -41,6 +41,7 @@ mod consumer_session;
mod error;
mod header;
mod operation;
+mod primary_rebalance;
mod reply_result;
mod request;
@@ -62,6 +63,7 @@ pub use header::{
prepare_identity_checksum_bytes, read_size_field,
};
pub use operation::Operation;
+pub use primary_rebalance::{PrimaryRebalanceHeader, PrimaryRebalanceKind};
pub use reply_result::{
REJECTION_SECTION_LEN, RESULT_COUNT_LEN, RESULT_ENTRY_LEN, result_code, result_section_len,
write_rejection_section,
diff --git a/core/binary_protocol/src/consensus/primary_rebalance.rs b/core/binary_protocol/src/consensus/primary_rebalance.rs
new file mode 100644
index 000000000..2a3774efb
--- /dev/null
+++ b/core/binary_protocol/src/consensus/primary_rebalance.rs
@@ -0,0 +1,355 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::{Command, ConsensusError, ConsensusHeader, HEADER_SIZE, Operation};
+use bytemuck::{CheckedBitPattern, NoUninit};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
+#[repr(u8)]
+pub enum PrimaryRebalanceKind {
+ MetadataProbe = 0,
+ MetadataReply = 1,
+ PartitionProbe = 2,
+ PartitionReply = 3,
+ SourceProbe = 4,
+ SourceReply = 5,
+}
+
+/// Same-release readiness exchange, always routed through the metadata owner.
+///
+/// `view` identifies the metadata coordinator's view, while `partition_view`
+/// belongs to the probed partition. A ready reply supplies evidence to the
+/// coordinator; it does not transfer ownership. Ingress must authenticate
+/// `replica`, and the coordinator must correlate the nonce and history fields.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
+#[repr(C)]
+pub struct PrimaryRebalanceHeader {
+ pub checksum: u128,
+ pub checksum_body: u128,
+ pub cluster: u128,
+ pub size: u32,
+ pub view: u32,
+ pub release: u32,
+ pub command: Command,
+ pub replica: u8,
+ pub reserved_frame: [u8; 66],
+ pub nonce: u128,
+ pub namespace: u64,
+ pub created_revision: u64,
+ pub commit: u64,
+ /// Metadata primary's quorum-evidence age at send, or `NO_QUORUM_EVIDENCE`.
+ pub quorum_age_ns: u64,
+ pub partition_view: u32,
+ pub kind: PrimaryRebalanceKind,
+ pub ready: u8,
+ pub reserved: [u8; 74],
+}
+
+const _: () = assert!(size_of::<PrimaryRebalanceHeader>() == HEADER_SIZE);
+
+impl PrimaryRebalanceHeader {
+ pub const NO_QUORUM_EVIDENCE: u64 = u64::MAX;
+}
+
+impl ConsensusHeader for PrimaryRebalanceHeader {
+ const FRAME_SEALED: bool = true;
+ const COMMAND: Command = Command::PrimaryRebalance;
+
+ fn checksum(&self) -> u128 {
+ self.checksum
+ }
+
+ fn set_checksum(&mut self, checksum: u128) {
+ self.checksum = checksum;
+ }
+
+ fn operation(&self) -> Operation {
+ Operation::Reserved
+ }
+
+ fn command(&self) -> Command {
+ self.command
+ }
+
+ fn size(&self) -> u32 {
+ self.size
+ }
+
+ #[expect(
+ clippy::cast_possible_truncation,
+ reason = "HEADER_SIZE is fixed at 256 bytes"
+ )]
+ fn validate(&self) -> Result<(), ConsensusError> {
+ if self.command != Self::COMMAND {
+ return Err(ConsensusError::InvalidCommand {
+ expected: Self::COMMAND,
+ found: self.command,
+ });
+ }
+ if self.size as usize != HEADER_SIZE {
+ return Err(ConsensusError::InvalidSize {
+ expected: HEADER_SIZE as u32,
+ found: self.size,
+ });
+ }
+ if self.checksum_body != 0 || self.release != 0 {
+ return Err(ConsensusError::InvalidField(
+ "primary rebalance body checksum and release must be zero".into(),
+ ));
+ }
+ if self.reserved_frame != [0; 66] || self.reserved != [0; 74] {
+ return Err(ConsensusError::InvalidField(
+ "primary rebalance reserved bytes must be zero".into(),
+ ));
+ }
+ if self.nonce == 0 {
+ return Err(ConsensusError::InvalidField(
+ "primary rebalance nonce must be nonzero".into(),
+ ));
+ }
+ if self.ready > 1
+ || (self.ready != 0
+ && matches!(
+ self.kind,
+ PrimaryRebalanceKind::MetadataProbe
+ | PrimaryRebalanceKind::PartitionProbe
+ | PrimaryRebalanceKind::SourceProbe
+ ))
+ {
+ return Err(ConsensusError::InvalidField(
+ "primary rebalance probes cannot be ready; replies require 0 or 1".into(),
+ ));
+ }
+ if self.kind != PrimaryRebalanceKind::MetadataProbe
+ && self.quorum_age_ns != Self::NO_QUORUM_EVIDENCE
+ {
+ return Err(ConsensusError::InvalidField(
+ "only a metadata probe can advertise quorum evidence".into(),
+ ));
+ }
+ if self.kind == PrimaryRebalanceKind::MetadataReply && self.commit != 0 {
+ return Err(ConsensusError::InvalidField(
+ "metadata reply cannot advertise a commit".into(),
+ ));
+ }
+ match self.kind {
+ PrimaryRebalanceKind::MetadataProbe | PrimaryRebalanceKind::MetadataReply => {
+ if self.namespace != 0 || self.created_revision != 0 || self.partition_view != 0 {
+ return Err(ConsensusError::InvalidField(
+ "primary rebalance metadata exchange cannot carry partition fields".into(),
+ ));
+ }
+ }
+ PrimaryRebalanceKind::PartitionProbe
+ | PrimaryRebalanceKind::PartitionReply
+ | PrimaryRebalanceKind::SourceProbe
+ | PrimaryRebalanceKind::SourceReply => {
+ if self.created_revision == 0 {
+ return Err(ConsensusError::InvalidField(
+ "primary rebalance partition revision must be nonzero".into(),
+ ));
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use aligned_vec::{AVec, ConstAlign};
+ use std::mem::offset_of;
+
+ fn header(kind: PrimaryRebalanceKind) -> PrimaryRebalanceHeader {
+ let partition = matches!(
+ kind,
+ PrimaryRebalanceKind::PartitionProbe
+ | PrimaryRebalanceKind::PartitionReply
+ | PrimaryRebalanceKind::SourceProbe
+ | PrimaryRebalanceKind::SourceReply
+ );
+ PrimaryRebalanceHeader {
+ checksum: 0,
+ checksum_body: 0,
+ cluster: 7,
+ size: u32::try_from(HEADER_SIZE).unwrap(),
+ view: 11,
+ release: 0,
+ command: Command::PrimaryRebalance,
+ replica: 2,
+ reserved_frame: [0; 66],
+ nonce: u128::MAX,
+ namespace: if partition { 17 } else { 0 },
+ created_revision: u64::from(partition),
+ commit: if partition { 23 } else { 0 },
+ quorum_age_ns: PrimaryRebalanceHeader::NO_QUORUM_EVIDENCE,
+ partition_view: if partition { 5 } else { 0 },
+ kind,
+ ready: 0,
+ reserved: [0; 74],
+ }
+ }
+
+ #[test]
+ fn given_rebalance_phases_when_encoded_should_preserve_the_fixed_layout_and_seal() {
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, nonce), 128);
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, namespace), 144);
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, created_revision), 152);
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, commit), 160);
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, quorum_age_ns), 168);
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, partition_view), 176);
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, kind), 180);
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, ready), 181);
+ assert_eq!(offset_of!(PrimaryRebalanceHeader, reserved), 182);
+ for kind in [
+ PrimaryRebalanceKind::MetadataProbe,
+ PrimaryRebalanceKind::MetadataReply,
+ PrimaryRebalanceKind::PartitionProbe,
+ PrimaryRebalanceKind::PartitionReply,
+ PrimaryRebalanceKind::SourceProbe,
+ PrimaryRebalanceKind::SourceReply,
+ ] {
+ for ready in 0..=1 {
+ let mut original = header(kind);
+ original.ready = ready;
+ original.seal();
+ let decoded = bytemuck::checked::try_from_bytes::<PrimaryRebalanceHeader>(
+ bytemuck::bytes_of(&original),
+ )
+ .unwrap();
+ assert_eq!(*decoded, original);
+ decoded.verify_frame().unwrap();
+ let is_reply = matches!(
+ kind,
+ PrimaryRebalanceKind::MetadataReply
+ | PrimaryRebalanceKind::PartitionReply
+ | PrimaryRebalanceKind::SourceReply
+ );
+ assert_eq!(decoded.validate().is_ok(), ready == 0 || is_reply);
+ }
+ }
+ }
+
+ #[test]
+ fn given_metadata_quorum_evidence_when_encoded_should_preserve_commit_and_age() {
+ for age in [
+ 0,
+ 1,
+ 8_000_000_000,
+ PrimaryRebalanceHeader::NO_QUORUM_EVIDENCE,
+ ] {
+ let mut probe = header(PrimaryRebalanceKind::MetadataProbe);
+ probe.commit = 13;
+ probe.quorum_age_ns = age;
+ probe.seal();
+ let decoded = bytemuck::checked::try_from_bytes::<PrimaryRebalanceHeader>(
+ bytemuck::bytes_of(&probe),
+ )
+ .unwrap();
+ decoded.validate().unwrap();
+ decoded.verify_frame().unwrap();
+ assert_eq!(*decoded, probe);
+ }
+ for kind in [
+ PrimaryRebalanceKind::MetadataReply,
+ PrimaryRebalanceKind::PartitionProbe,
+ PrimaryRebalanceKind::PartitionReply,
+ PrimaryRebalanceKind::SourceProbe,
+ PrimaryRebalanceKind::SourceReply,
+ ] {
+ let mut invalid = header(kind);
+ invalid.quorum_age_ns = 0;
+ assert!(invalid.validate().is_err(), "{kind:?}");
+ }
+ let mut invalid = header(PrimaryRebalanceKind::MetadataReply);
+ invalid.commit = 1;
+ assert!(invalid.validate().is_err());
+ }
+
+ #[test]
+ fn given_a_rebalance_frame_when_fields_are_corrupt_should_reject_before_use() {
+ let mut valid = header(PrimaryRebalanceKind::PartitionReply);
+ valid.ready = 1;
+ valid.seal();
+ let mut bytes: AVec<u8, ConstAlign<16>> = AVec::new(16);
+ bytes.extend_from_slice(bytemuck::bytes_of(&valid));
+ for offset in [32, 52, 61, 128, 144, 152, 160, 168, 176, 180, 181] {
+ bytes[offset] ^= 1;
+ let corrupted =
+ bytemuck::checked::try_from_bytes::<PrimaryRebalanceHeader>(&bytes).unwrap();
+ assert!(corrupted.verify_frame().is_err(), "offset {offset}");
+ bytes[offset] ^= 1;
+ }
+ for kind in 6..=u8::MAX {
+ bytes[offset_of!(PrimaryRebalanceHeader, kind)] = kind;
+ assert!(bytemuck::checked::try_from_bytes::<PrimaryRebalanceHeader>(&bytes).is_err());
+ }
+ }
+
+ #[test]
+ fn given_a_rebalance_frame_when_envelope_or_phase_fields_are_invalid_should_reject() {
+ let valid = header(PrimaryRebalanceKind::PartitionReply);
+ for change in [
+ |value: &mut PrimaryRebalanceHeader| value.command = Command::Commit,
+ |value: &mut PrimaryRebalanceHeader| {
+ value.size = u32::try_from(HEADER_SIZE - 1).unwrap();
+ },
+ |value: &mut PrimaryRebalanceHeader| {
+ value.size = u32::try_from(HEADER_SIZE + 1).unwrap();
+ },
+ |value: &mut PrimaryRebalanceHeader| value.size = u32::MAX,
+ |value: &mut PrimaryRebalanceHeader| value.checksum_body = 1,
+ |value: &mut PrimaryRebalanceHeader| value.release = 1,
+ |value: &mut PrimaryRebalanceHeader| value.reserved_frame[65] = 1,
+ |value: &mut PrimaryRebalanceHeader| value.reserved[73] = 1,
+ |value: &mut PrimaryRebalanceHeader| value.nonce = 0,
+ |value: &mut PrimaryRebalanceHeader| value.ready = 2,
+ |value: &mut PrimaryRebalanceHeader| value.created_revision = 0,
+ ] {
+ let mut invalid = valid;
+ change(&mut invalid);
+ invalid.seal();
+ invalid.verify_frame().unwrap();
+ assert!(invalid.validate().is_err(), "{invalid:?}");
+ }
+ for kind in [
+ PrimaryRebalanceKind::MetadataProbe,
+ PrimaryRebalanceKind::MetadataReply,
+ ] {
+ for change in [
+ |value: &mut PrimaryRebalanceHeader| value.namespace = 1,
+ |value: &mut PrimaryRebalanceHeader| value.created_revision = 1,
+ |value: &mut PrimaryRebalanceHeader| value.partition_view = 1,
+ ] {
+ let mut invalid = header(kind);
+ change(&mut invalid);
+ assert!(invalid.validate().is_err(), "{invalid:?}");
+ }
+ }
+ for kind in [
+ PrimaryRebalanceKind::PartitionProbe,
+ PrimaryRebalanceKind::PartitionReply,
+ PrimaryRebalanceKind::SourceProbe,
+ PrimaryRebalanceKind::SourceReply,
+ ] {
+ let mut invalid = header(kind);
+ invalid.created_revision = 0;
+ assert!(invalid.validate().is_err(), "{kind:?}");
+ }
+ }
+}
diff --git a/core/binary_protocol/src/lib.rs b/core/binary_protocol/src/lib.rs
index 4626a6268..457d6670c 100644
--- a/core/binary_protocol/src/lib.rs
+++ b/core/binary_protocol/src/lib.rs
@@ -76,13 +76,13 @@ pub use consensus::{
ForwardLogoutResultHeader, ForwardRegisterHeader, ForwardRegisterOutcome,
ForwardRegisterResultHeader, GenericHeader, HEADER_SIZE, LIFECYCLE_BATCH_HEADER_SIZE,
LIFECYCLE_COMPLETION_SIZE, LIFECYCLE_COMPLETIONS_MAX, MAX_CONSUMER_SESSIONS_PER_HEARTBEAT,
- Operation, PrepareHeader, PrepareOkHeader, REJECTION_SECTION_LEN, RESERVED_COMMAND_LEN,
- RepairPrepareHeader, RepairRangeReplyHeader, ReplyHeader, RequestHeader, RequestPreparesHeader,
- RequestStartViewHeader, RequestStateChunkHeader, RequestStateTransferHeader,
- RoutedRequestHeader, SIZE_FIELD_OFFSET, StartViewChangeHeader, StartViewHeader,
- StateChunkHeader, StateTransferTargetHeader, frame_body, frame_checksum_bytes,
- prepare_identity_checksum_bytes, read_size_field, result_code, result_section_len,
- write_rejection_section,
+ Operation, PrepareHeader, PrepareOkHeader, PrimaryRebalanceHeader, PrimaryRebalanceKind,
+ REJECTION_SECTION_LEN, RESERVED_COMMAND_LEN, RepairPrepareHeader, RepairRangeReplyHeader,
+ ReplyHeader, RequestHeader, RequestPreparesHeader, RequestStartViewHeader,
+ RequestStateChunkHeader, RequestStateTransferHeader, RoutedRequestHeader, SIZE_FIELD_OFFSET,
+ StartViewChangeHeader, StartViewHeader, StateChunkHeader, StateTransferTargetHeader,
+ frame_body, frame_checksum_bytes, prepare_identity_checksum_bytes, read_size_field,
+ result_code, result_section_len, write_rejection_section,
};
pub use dispatch::{COMMAND_TABLE, CommandMeta, lookup_by_operation, lookup_command};
pub use error::WireError;
diff --git a/core/binary_protocol/src/primitives/consumer.rs b/core/binary_protocol/src/primitives/consumer.rs
index 42e52e6d4..3f94332c7 100644
--- a/core/binary_protocol/src/primitives/consumer.rs
+++ b/core/binary_protocol/src/primitives/consumer.rs
@@ -65,6 +65,26 @@ impl WireConsumer {
id,
}
}
+
+ pub(crate) fn skip(buf: &[u8]) -> Result<usize, WireError> {
+ Self::decode_kind(buf)?;
+ WireIdentifier::skip(&buf[1..]).map(|consumed| 1 + consumed)
+ }
+
+ fn decode_kind(buf: &[u8]) -> Result<u8, WireError> {
+ let kind = read_u8(buf, 0)?;
+ if !matches!(
+ kind,
+ KIND_CONSUMER | KIND_CONSUMER_GROUP | KIND_EXTERNAL_GROUP
+ ) {
+ return Err(WireError::UnknownDiscriminant {
+ type_name: "WireConsumer",
+ value: kind,
+ offset: 0,
+ });
+ }
+ Ok(kind)
+ }
}
impl WireEncode for WireConsumer {
@@ -80,17 +100,7 @@ impl WireEncode for WireConsumer {
impl WireDecode for WireConsumer {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
- let kind = read_u8(buf, 0)?;
- if !matches!(
- kind,
- KIND_CONSUMER | KIND_CONSUMER_GROUP | KIND_EXTERNAL_GROUP
- ) {
- return Err(WireError::UnknownDiscriminant {
- type_name: "WireConsumer",
- value: kind,
- offset: 0,
- });
- }
+ let kind = Self::decode_kind(buf)?;
let (id, id_consumed) = WireIdentifier::decode(&buf[1..])?;
Ok((Self { kind, id }, 1 + id_consumed))
}
diff --git a/core/binary_protocol/src/primitives/identifier.rs b/core/binary_protocol/src/primitives/identifier.rs
index 4cc288293..689113544 100644
--- a/core/binary_protocol/src/primitives/identifier.rs
+++ b/core/binary_protocol/src/primitives/identifier.rs
@@ -129,6 +129,11 @@ pub enum WireIdentifier {
String(WireName),
}
+enum BorrowedIdentifier<'a> {
+ Numeric(u32),
+ String(&'a str),
+}
+
impl WireIdentifier {
/// Kind and length bytes, then the `u32` value.
pub(crate) const NUMERIC_ENCODED_SIZE: usize = 2 * size_of::<u8>() + NUMERIC_VALUE_LEN as usize;
@@ -161,40 +166,11 @@ impl WireIdentifier {
}
}
- const fn kind_code(&self) -> u8 {
- match self {
- Self::Numeric(_) => KIND_NUMERIC,
- Self::String(_) => KIND_STRING,
- }
- }
-
- #[allow(clippy::cast_possible_truncation)]
- const fn value_len(&self) -> u8 {
- match self {
- Self::Numeric(_) => NUMERIC_VALUE_LEN,
- // WireName guarantees len <= 255, truncation impossible.
- Self::String(s) => s.len() as u8,
- }
- }
-}
-
-impl WireEncode for WireIdentifier {
- fn encoded_size(&self) -> usize {
- 2 + self.value_len() as usize
- }
-
- fn encode(&self, buf: &mut BytesMut) {
- buf.put_u8(self.kind_code());
- buf.put_u8(self.value_len());
- match self {
- Self::Numeric(id) => buf.put_u32_le(*id),
- Self::String(s) => buf.put_slice(s.as_bytes()),
- }
+ pub(crate) fn skip(buf: &[u8]) -> Result<usize, WireError> {
+ Self::decode_borrowed(buf).map(|(_, consumed)| consumed)
}
-}
-impl WireDecode for WireIdentifier {
- fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ fn decode_borrowed(buf: &[u8]) -> Result<(BorrowedIdentifier<'_>, usize), WireError> {
let kind = read_u8(buf, 0)?;
let length = read_u8(buf, 1)? as usize;
let value = read_bytes(buf, 2, length)?;
@@ -212,7 +188,7 @@ impl WireDecode for WireIdentifier {
.try_into()
.expect("length already validated as 4 bytes"),
);
- Ok((Self::Numeric(id), consumed))
+ Ok((BorrowedIdentifier::Numeric(id), consumed))
}
KIND_STRING => {
if length == 0 {
@@ -222,8 +198,7 @@ impl WireDecode for WireIdentifier {
}
let s =
std::str::from_utf8(value).map_err(|_| WireError::InvalidUtf8 { offset: 2 })?;
- let wire_name = WireName::new(s)?;
- Ok((Self::String(wire_name), consumed))
+ Ok((BorrowedIdentifier::String(s), consumed))
}
_ => Err(WireError::UnknownDiscriminant {
type_name: "WireIdentifier",
@@ -232,6 +207,48 @@ impl WireDecode for WireIdentifier {
}),
}
}
+
+ const fn kind_code(&self) -> u8 {
+ match self {
+ Self::Numeric(_) => KIND_NUMERIC,
+ Self::String(_) => KIND_STRING,
+ }
+ }
+
+ #[allow(clippy::cast_possible_truncation)]
+ const fn value_len(&self) -> u8 {
+ match self {
+ Self::Numeric(_) => NUMERIC_VALUE_LEN,
+ // WireName guarantees len <= 255, truncation impossible.
+ Self::String(s) => s.len() as u8,
+ }
+ }
+}
+
+impl WireEncode for WireIdentifier {
+ fn encoded_size(&self) -> usize {
+ 2 + self.value_len() as usize
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u8(self.kind_code());
+ buf.put_u8(self.value_len());
+ match self {
+ Self::Numeric(id) => buf.put_u32_le(*id),
+ Self::String(s) => buf.put_slice(s.as_bytes()),
+ }
+ }
+}
+
+impl WireDecode for WireIdentifier {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (identifier, consumed) = Self::decode_borrowed(buf)?;
+ let identifier = match identifier {
+ BorrowedIdentifier::Numeric(id) => Self::Numeric(id),
+ BorrowedIdentifier::String(name) => Self::String(WireName(name.to_owned())),
+ };
+ Ok((identifier, consumed))
+ }
}
impl std::fmt::Display for WireIdentifier {
diff --git a/core/binary_protocol/src/primitives/partition_assignment.rs b/core/binary_protocol/src/primitives/partition_assignment.rs
index 27e8f9e72..646c425c7 100644
--- a/core/binary_protocol/src/primitives/partition_assignment.rs
+++ b/core/binary_protocol/src/primitives/partition_assignment.rs
@@ -23,11 +23,14 @@ use bytes::{BufMut, BytesMut};
pub struct CreatedPartitionAssignment {
pub partition_id: u32,
pub consensus_group_id: u64,
+ /// Committed placement seed, sealed by the request body checksum and never
+ /// restamped when a later metadata view retransmits the create.
+ pub initial_view: u32,
}
impl CreatedPartitionAssignment {
- /// Fixed wire size: a `u32` partition id and a `u64` consensus group id.
- pub const ENCODED_SIZE: usize = 12;
+ /// Fixed wire size: partition id, consensus group id and initial view.
+ pub const ENCODED_SIZE: usize = 16;
}
impl WireEncode for CreatedPartitionAssignment {
@@ -38,6 +41,7 @@ impl WireEncode for CreatedPartitionAssignment {
fn encode(&self, buf: &mut BytesMut) {
buf.put_u32_le(self.partition_id);
buf.put_u64_le(self.consensus_group_id);
+ buf.put_u32_le(self.initial_view);
}
}
@@ -55,8 +59,9 @@ impl WireDecode for CreatedPartitionAssignment {
Self {
partition_id: read_u32_le(buf, 0)?,
consensus_group_id: read_u64_le(buf, 4)?,
+ initial_view: read_u32_le(buf, 12)?,
},
- 12,
+ Self::ENCODED_SIZE,
))
}
}
@@ -69,12 +74,21 @@ mod tests {
#[test]
fn roundtrip() {
let request = CreatedPartitionAssignment {
- partition_id: 7,
- consensus_group_id: 42,
+ partition_id: 0x0102_0304,
+ consensus_group_id: 0x0506_0708_090A_0B0C,
+ initial_view: 0x0D0E_0F10,
};
let bytes = request.to_bytes();
let (decoded, consumed) = CreatedPartitionAssignment::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, request);
+ assert_eq!(bytes.len(), CreatedPartitionAssignment::ENCODED_SIZE);
+ assert_eq!(
+ bytes.as_ref(),
+ &[4, 3, 2, 1, 12, 11, 10, 9, 8, 7, 6, 5, 16, 15, 14, 13]
+ );
+ for truncated in 0..bytes.len() {
+ assert!(CreatedPartitionAssignment::decode(&bytes[..truncated]).is_err());
+ }
}
}
diff --git a/core/binary_protocol/src/requests/messages/poll_messages.rs b/core/binary_protocol/src/requests/messages/poll_messages.rs
index 4c6413cfc..6f5194369 100644
--- a/core/binary_protocol/src/requests/messages/poll_messages.rs
+++ b/core/binary_protocol/src/requests/messages/poll_messages.rs
@@ -49,6 +49,29 @@ const STRATEGY_SIZE: usize = 9;
const COUNT_SIZE: usize = 4;
const AUTO_COMMIT_SIZE: usize = 1;
+impl PollMessagesRequest {
+ /// Read the strategy and auto-commit flag without allocating identifiers.
+ ///
+ /// # Errors
+ /// Validates the same request bytes as [`WireDecode::decode_from`], including
+ /// the fields before the flags. Trailing bytes are accepted by both paths.
+ pub fn peek_progress(buf: &[u8]) -> Result<(WirePollingStrategy, bool), WireError> {
+ let mut pos = WireConsumer::skip(buf)?;
+ pos += WireIdentifier::skip(&buf[pos..])?;
+ pos += WireIdentifier::skip(&buf[pos..])?;
+ read_u8(buf, pos)?;
+ pos += PARTITION_FLAG_SIZE;
+ read_u32_le(buf, pos)?;
+ pos += PARTITION_VALUE_SIZE;
+ let (strategy, consumed) = WirePollingStrategy::decode(&buf[pos..])?;
+ pos += consumed;
+ read_u32_le(buf, pos)?;
+ pos += COUNT_SIZE;
+ let auto_commit = read_u8(buf, pos)? != 0;
+ Ok((strategy, auto_commit))
+ }
+}
+
impl WireEncode for PollMessagesRequest {
fn encoded_size(&self) -> usize {
self.consumer.encoded_size()
@@ -124,6 +147,97 @@ impl WireDecode for PollMessagesRequest {
mod tests {
use super::*;
+ #[test]
+ fn given_poll_encodings_when_peeking_progress_should_match_full_decode() {
+ let identifiers = [
+ WireIdentifier::numeric(u32::MAX),
+ WireIdentifier::named("named-é").unwrap(),
+ ];
+ for consumer_id in &identifiers {
+ for consumer in [
+ WireConsumer::consumer(consumer_id.clone()),
+ WireConsumer::consumer_group(consumer_id.clone()),
+ WireConsumer::external_group(consumer_id.clone()),
+ ] {
+ for stream_id in &identifiers {
+ for topic_id in &identifiers {
+ for strategy in [
+ WirePollingStrategy::offset(0),
+ WirePollingStrategy::offset(u64::MAX),
+ WirePollingStrategy::timestamp(u64::MAX),
+ WirePollingStrategy::first(),
+ WirePollingStrategy::last(),
+ WirePollingStrategy::next(),
+ ] {
+ for partition_id in [None, Some(u32::MAX)] {
+ for auto_commit in [false, true] {
+ for count in [0, u32::MAX] {
+ let request = PollMessagesRequest {
+ consumer: consumer.clone(),
+ stream_id: stream_id.clone(),
+ topic_id: topic_id.clone(),
+ partition_id,
+ strategy: strategy.clone(),
+ count,
+ auto_commit,
+ };
+ let mut bytes = request.to_bytes().to_vec();
+ assert_eq!(
+ PollMessagesRequest::decode_from(&bytes).unwrap(),
+ request
+ );
+ for end in 0..=bytes.len() {
+ assert_progress_peek_matches(&bytes[..end]);
+ }
+ bytes.extend_from_slice(b"trailing\0\xff");
+ assert_progress_peek_matches(&bytes);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn given_mutated_poll_fields_when_peeking_progress_should_match_full_decode() {
+ for identifier in [
+ WireIdentifier::numeric(1),
+ WireIdentifier::named("café").unwrap(),
+ ] {
+ let request = PollMessagesRequest {
+ consumer: WireConsumer::consumer(identifier.clone()),
+ stream_id: identifier.clone(),
+ topic_id: identifier,
+ partition_id: Some(0),
+ strategy: WirePollingStrategy::offset(0),
+ count: 0,
+ auto_commit: false,
+ };
+ let mut bytes = request.to_bytes().to_vec();
+ for offset in 0..bytes.len() {
+ let original = bytes[offset];
+ for value in u8::MIN..=u8::MAX {
+ bytes[offset] = value;
+ assert_progress_peek_matches(&bytes);
+ }
+ bytes[offset] = original;
+ }
+ }
+ }
+
+ fn assert_progress_peek_matches(bytes: &[u8]) {
+ let decoded = PollMessagesRequest::decode_from(bytes)
+ .map(|request| (request.strategy, request.auto_commit));
+ assert_eq!(
+ PollMessagesRequest::peek_progress(bytes),
+ decoded,
+ "{bytes:?}"
+ );
+ }
+
#[test]
fn roundtrip_with_partition() {
let req = PollMessagesRequest {
diff --git a/core/binary_protocol/src/requests/partitions/create_partitions_with_assignments.rs b/core/binary_protocol/src/requests/partitions/create_partitions_with_assignments.rs
index 904ed6e5f..d035906b0 100644
--- a/core/binary_protocol/src/requests/partitions/create_partitions_with_assignments.rs
+++ b/core/binary_protocol/src/requests/partitions/create_partitions_with_assignments.rs
@@ -29,10 +29,6 @@ fn usize_to_u32(value: usize, context: &str) -> u32 {
pub struct CreatePartitionsWithAssignmentsRequest {
pub request: CreatePartitionsRequest,
pub partitions: Vec<CreatedPartitionAssignment>,
- /// View the admitting primary minted this create in. Rides the body, which
- /// `checksum_body` seals, so unlike the header's `view` it is never restamped
- /// by a post-view-change retransmit: every replica decodes the same value.
- pub created_view: u32,
}
impl WireEncode for CreatePartitionsWithAssignmentsRequest {
@@ -44,7 +40,6 @@ impl WireEncode for CreatePartitionsWithAssignmentsRequest {
.iter()
.map(WireEncode::encoded_size)
.sum::<usize>()
- + 4
}
fn encode(&self, buf: &mut BytesMut) {
@@ -60,15 +55,31 @@ impl WireEncode for CreatePartitionsWithAssignmentsRequest {
for partition in &self.partitions {
partition.encode(buf);
}
- buf.put_u32_le(self.created_view);
}
}
impl WireDecode for CreatePartitionsWithAssignmentsRequest {
+ fn decode_from(buf: &[u8]) -> Result<Self, WireError> {
+ let (request, consumed) = Self::decode(buf)?;
+ if consumed != buf.len() {
+ return Err(WireError::Validation(
+ "trailing create partitions assignment bytes".into(),
+ ));
+ }
+ Ok(request)
+ }
+
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
let request_size = read_u32_le(buf, 0)? as usize;
- let request_start = 4;
- let request_end = request_start + request_size;
+ let request_start: usize = 4;
+ let request_end =
+ request_start
+ .checked_add(request_size)
+ .ok_or_else(|| WireError::UnexpectedEof {
+ offset: request_start,
+ need: request_size,
+ have: buf.len().saturating_sub(request_start),
+ })?;
if buf.len() < request_end {
return Err(WireError::UnexpectedEof {
offset: request_start,
@@ -77,32 +88,35 @@ impl WireDecode for CreatePartitionsWithAssignmentsRequest {
});
}
- let request = CreatePartitionsRequest::decode_from(&buf[request_start..request_end])?;
+ let (request, consumed) =
+ CreatePartitionsRequest::decode(&buf[request_start..request_end])?;
+ if consumed != request_size {
+ return Err(WireError::Validation(
+ "trailing create partitions request bytes".into(),
+ ));
+ }
let partitions_count = read_u32_le(buf, request_end)? as usize;
+ if partitions_count != request.partitions_count as usize {
+ return Err(WireError::Validation(
+ "partition assignment count mismatch".into(),
+ ));
+ }
let mut offset = request_end + 4;
- let mut partitions = Vec::with_capacity(partitions_count);
+ let mut partitions = Vec::with_capacity(crate::codec::bounded_capacity(
+ partitions_count,
+ buf.len().saturating_sub(offset),
+ CreatedPartitionAssignment::ENCODED_SIZE,
+ ));
for _ in 0..partitions_count {
let (partition, consumed) = CreatedPartitionAssignment::decode(&buf[offset..])?;
offset += consumed;
partitions.push(partition);
}
- // Trailing field: entries journaled before it existed end here and read
- // 0, the view every plane starts in. 1-3 leftover bytes are corruption,
- // not an old shape, so they still error.
- let created_view = if offset < buf.len() {
- let view = read_u32_le(buf, offset)?;
- offset += 4;
- view
- } else {
- 0
- };
-
Ok((
Self {
request,
partitions,
- created_view,
},
offset,
))
@@ -112,10 +126,10 @@ impl WireDecode for CreatePartitionsWithAssignmentsRequest {
#[cfg(test)]
mod tests {
use super::CreatePartitionsWithAssignmentsRequest;
- use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
use crate::primitives::partition_assignment::CreatedPartitionAssignment;
use crate::requests::partitions::CreatePartitionsRequest;
+ use crate::{WireError, WireIdentifier};
#[test]
fn roundtrip() {
@@ -127,44 +141,74 @@ mod tests {
},
partitions: vec![
CreatedPartitionAssignment {
+ initial_view: 9,
partition_id: 3,
consensus_group_id: 11,
},
CreatedPartitionAssignment {
+ initial_view: 10,
partition_id: 4,
consensus_group_id: 12,
},
],
- created_view: 9,
};
let bytes = request.to_bytes();
let (decoded, consumed) = CreatePartitionsWithAssignmentsRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, request);
+ assert_eq!(bytes.len(), request.encoded_size());
}
#[test]
- fn decode_without_trailing_view_reads_zero() {
- let request = CreatePartitionsWithAssignmentsRequest {
+ fn given_malformed_assignments_when_decoding_should_reject() {
+ let mut request = CreatePartitionsWithAssignmentsRequest {
request: CreatePartitionsRequest {
stream_id: WireIdentifier::numeric(1),
topic_id: WireIdentifier::numeric(2),
partitions_count: 1,
},
partitions: vec![CreatedPartitionAssignment {
+ initial_view: 9,
partition_id: 3,
consensus_group_id: 11,
}],
- created_view: 9,
};
- // Bytes journaled before the field existed: same shape, no trailing u32.
let bytes = request.to_bytes();
- let old_bytes = &bytes[..bytes.len() - 4];
- let (decoded, consumed) =
- CreatePartitionsWithAssignmentsRequest::decode(old_bytes).unwrap();
- assert_eq!(consumed, old_bytes.len());
- assert_eq!(decoded.created_view, 0);
- assert_eq!(decoded.partitions, request.partitions);
- assert_eq!(decoded.request, request.request);
+ for truncated in 0..bytes.len() {
+ assert!(
+ CreatePartitionsWithAssignmentsRequest::decode_from(&bytes[..truncated]).is_err()
+ );
+ }
+ let mut trailing = bytes.to_vec();
+ trailing.push(0);
+ assert!(CreatePartitionsWithAssignmentsRequest::decode_from(&trailing).is_err());
+
+ let request_end = size_of::<u32>() + request.request.encoded_size();
+ let mut embedded_trailing = bytes.to_vec();
+ embedded_trailing.insert(request_end, 0);
+ embedded_trailing[..4].copy_from_slice(
+ &u32::try_from(request.request.encoded_size() + 1)
+ .unwrap()
+ .to_le_bytes(),
+ );
+ assert!(CreatePartitionsWithAssignmentsRequest::decode_from(&embedded_trailing).is_err());
+ assert!(matches!(
+ CreatePartitionsWithAssignmentsRequest::decode_from(&u32::MAX.to_le_bytes()),
+ Err(WireError::UnexpectedEof { .. })
+ ));
+
+ for count in [0, 2, u32::MAX] {
+ request.request.partitions_count = count;
+ assert!(matches!(
+ CreatePartitionsWithAssignmentsRequest::decode_from(&request.to_bytes()),
+ Err(WireError::Validation(_))
+ ));
+ }
+ let mut huge_count = request.to_bytes().to_vec();
+ huge_count[request_end..request_end + 4].copy_from_slice(&u32::MAX.to_le_bytes());
+ assert!(matches!(
+ CreatePartitionsWithAssignmentsRequest::decode_from(&huge_count),
+ Err(WireError::UnexpectedEof { .. })
+ ));
}
}
diff --git a/core/binary_protocol/src/requests/topics/create_topic_with_assignments.rs b/core/binary_protocol/src/requests/topics/create_topic_with_assignments.rs
index 632286530..a43e06a43 100644
--- a/core/binary_protocol/src/requests/topics/create_topic_with_assignments.rs
+++ b/core/binary_protocol/src/requests/topics/create_topic_with_assignments.rs
@@ -38,10 +38,6 @@ pub struct CreateTopicWithAssignmentsRequest {
pub request: CreateTopicRequest,
pub derived_options: WireOptions,
pub partitions: Vec<CreatedPartitionAssignment>,
- /// View the admitting primary minted this create in. Rides the body, which
- /// `checksum_body` seals, so unlike the header's `view` it is never restamped
- /// by a post-view-change retransmit: every replica decodes the same value.
- pub created_view: u32,
}
impl WireEncode for CreateTopicWithAssignmentsRequest {
@@ -55,7 +51,6 @@ impl WireEncode for CreateTopicWithAssignmentsRequest {
.iter()
.map(WireEncode::encoded_size)
.sum::<usize>()
- + 4
}
fn encode(&self, buf: &mut BytesMut) {
@@ -76,11 +71,20 @@ impl WireEncode for CreateTopicWithAssignmentsRequest {
for partition in &self.partitions {
partition.encode(buf);
}
- buf.put_u32_le(self.created_view);
}
}
impl WireDecode for CreateTopicWithAssignmentsRequest {
+ fn decode_from(buf: &[u8]) -> Result<Self, WireError> {
+ let (request, consumed) = Self::decode(buf)?;
+ if consumed != buf.len() {
+ return Err(WireError::Validation(
+ "trailing create topic assignment bytes".into(),
+ ));
+ }
+ Ok(request)
+ }
+
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
let request_size = read_u32_le(buf, 0)? as usize;
let request_start: usize = 4;
@@ -104,12 +108,22 @@ impl WireDecode for CreateTopicWithAssignmentsRequest {
});
}
- let request = CreateTopicRequest::decode_from(&buf[request_start..request_end])?;
+ let (request, consumed) = CreateTopicRequest::decode(&buf[request_start..request_end])?;
+ if consumed != request_size {
+ return Err(WireError::Validation(
+ "trailing create topic request bytes".into(),
+ ));
+ }
let (derived_options, derived_consumed) = decode_options_prefixed(buf, request_end)?;
let derived_end = request_end + derived_consumed;
let partitions_count = read_u32_le(buf, derived_end)? as usize;
+ if partitions_count != request.partitions_count as usize {
+ return Err(WireError::Validation(
+ "partition assignment count mismatch".into(),
+ ));
+ }
let mut offset = derived_end + 4;
let mut partitions = Vec::with_capacity(crate::codec::bounded_capacity(
partitions_count,
@@ -122,23 +136,11 @@ impl WireDecode for CreateTopicWithAssignmentsRequest {
partitions.push(partition);
}
- // Trailing field: entries journaled before it existed end here and read
- // 0, the view every plane starts in. 1-3 leftover bytes are corruption,
- // not an old shape, so they still error.
- let created_view = if offset < buf.len() {
- let view = read_u32_le(buf, offset)?;
- offset += 4;
- view
- } else {
- 0
- };
-
Ok((
Self {
request,
derived_options,
partitions,
- created_view,
},
offset,
))
@@ -148,13 +150,13 @@ impl WireDecode for CreateTopicWithAssignmentsRequest {
#[cfg(test)]
mod tests {
use super::CreateTopicWithAssignmentsRequest;
- use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
use crate::primitives::identifier::WireName;
use crate::primitives::options::WireOptions;
use crate::primitives::partition_assignment::CreatedPartitionAssignment;
use crate::primitives::user_headers::encode_user_headers;
use crate::requests::topics::CreateTopicRequest;
+ use crate::{WireError, WireIdentifier};
use bytes::BytesMut;
fn options_block(entries: &[(u8, &[u8], u8, &[u8])]) -> WireOptions {
@@ -175,25 +177,27 @@ mod tests {
derived_options: options_block(&[(2, b"max_topic_size", 12, &1024u64.to_le_bytes())]),
partitions: vec![
CreatedPartitionAssignment {
+ initial_view: 7,
partition_id: 0,
consensus_group_id: 1,
},
CreatedPartitionAssignment {
+ initial_view: 8,
partition_id: 1,
consensus_group_id: 2,
},
],
- created_view: 7,
};
let bytes = request.to_bytes();
let (decoded, consumed) = CreateTopicWithAssignmentsRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, request);
+ assert_eq!(bytes.len(), request.encoded_size());
}
#[test]
- fn decode_without_trailing_view_reads_zero() {
- let request = CreateTopicWithAssignmentsRequest {
+ fn given_malformed_assignments_when_decoding_should_reject() {
+ let mut request = CreateTopicWithAssignmentsRequest {
request: CreateTopicRequest {
stream_id: WireIdentifier::numeric(1),
partitions_count: 1,
@@ -202,19 +206,47 @@ mod tests {
},
derived_options: WireOptions::empty(),
partitions: vec![CreatedPartitionAssignment {
+ initial_view: 7,
partition_id: 0,
consensus_group_id: 1,
}],
- created_view: 7,
};
- // Bytes journaled before the field existed: same shape, no trailing u32.
let bytes = request.to_bytes();
- let old_bytes = &bytes[..bytes.len() - 4];
- let (decoded, consumed) = CreateTopicWithAssignmentsRequest::decode(old_bytes).unwrap();
- assert_eq!(consumed, old_bytes.len());
- assert_eq!(decoded.created_view, 0);
- assert_eq!(decoded.partitions, request.partitions);
- assert_eq!(decoded.request, request.request);
+ for truncated in 0..bytes.len() {
+ assert!(CreateTopicWithAssignmentsRequest::decode_from(&bytes[..truncated]).is_err());
+ }
+ let mut trailing = bytes.to_vec();
+ trailing.push(0);
+ assert!(CreateTopicWithAssignmentsRequest::decode_from(&trailing).is_err());
+
+ let request_end = size_of::<u32>() + request.request.encoded_size();
+ let mut embedded_trailing = bytes.to_vec();
+ embedded_trailing.insert(request_end, 0);
+ embedded_trailing[..4].copy_from_slice(
+ &u32::try_from(request.request.encoded_size() + 1)
+ .unwrap()
+ .to_le_bytes(),
+ );
+ assert!(CreateTopicWithAssignmentsRequest::decode_from(&embedded_trailing).is_err());
+ assert!(matches!(
+ CreateTopicWithAssignmentsRequest::decode_from(&u32::MAX.to_le_bytes()),
+ Err(WireError::UnexpectedEof { .. })
+ ));
+
+ for count in [0, 2, u32::MAX] {
+ request.request.partitions_count = count;
+ assert!(matches!(
+ CreateTopicWithAssignmentsRequest::decode_from(&request.to_bytes()),
+ Err(WireError::Validation(_))
+ ));
+ }
+ let mut huge_count = request.to_bytes().to_vec();
+ let count_offset = huge_count.len() - CreatedPartitionAssignment::ENCODED_SIZE - 4;
+ huge_count[count_offset..count_offset + 4].copy_from_slice(&u32::MAX.to_le_bytes());
+ assert!(matches!(
+ CreateTopicWithAssignmentsRequest::decode_from(&huge_count),
+ Err(WireError::UnexpectedEof { .. })
+ ));
}
#[test]
@@ -228,7 +260,6 @@ mod tests {
},
derived_options: WireOptions::empty(),
partitions: vec![],
- created_view: 0,
};
let bytes = request.to_bytes();
let (decoded, consumed) = CreateTopicWithAssignmentsRequest::decode(&bytes).unwrap();
diff --git a/core/common/src/consumer_group_client_state.rs b/core/common/src/consumer_group_client_state.rs
index 134341b23..80ecd6d2c 100644
--- a/core/common/src/consumer_group_client_state.rs
+++ b/core/common/src/consumer_group_client_state.rs
@@ -26,10 +26,10 @@
//! transport. The coordinator fences stale selections (rebalance), prompting a
//! re-sync that resets the cursor.
-use crate::Identifier;
+use crate::{Identifier, IggyError};
use iggy_binary_protocol::primitives::partition_history::PartitionContext;
use std::collections::HashMap;
-use std::sync::Mutex;
+use std::sync::{Mutex, MutexGuard, PoisonError};
#[derive(Debug, Default, Clone)]
struct GroupAssignment {
@@ -40,7 +40,6 @@ struct GroupAssignment {
#[derive(Debug, Default)]
struct TopicPartitions {
- count: Option<u32>,
contexts: HashMap<u32, PartitionContext>,
cursor: usize,
/// HTTP only: the client may send to the topic but not read its details, the only HTTP
@@ -48,16 +47,33 @@ struct TopicPartitions {
send_contexts_refused: bool,
}
-/// Per-transport cache of group assignments, producer cursors, partition counts
-/// and send contexts. Topic lookups borrow identifiers without formatting keys.
+/// Per-transport cache of group assignments, producer cursors and send
+/// contexts. Topic lookups borrow identifiers without formatting keys.
#[derive(Debug, Default)]
pub struct ConsumerGroupClientState {
- assignments: Mutex<HashMap<String, GroupAssignment>>,
+ groups: Mutex<GroupState>,
topic_partitions: Mutex<HashMap<Identifier, HashMap<Identifier, TopicPartitions>>>,
+}
+
+#[derive(Debug, Default)]
+struct GroupState {
+ generation: u64,
+ assignments: HashMap<String, GroupAssignment>,
/// Identifiers of the joined groups, so the heartbeat can rebuild a sync
/// request without re-deriving them from the cache key. Keyed by the same
/// `stream|topic|group` string as `assignments`.
- joined_groups: Mutex<HashMap<String, (Identifier, Identifier, Identifier)>>,
+ joined_groups: HashMap<String, (Identifier, Identifier, Identifier)>,
+}
+
+impl GroupState {
+ fn set_assignment(&mut self, key: String, generation: u64, partitions: Vec<u32>) {
+ let entry = self.assignments.entry(key).or_default();
+ if entry.generation != generation {
+ entry.cursor = 0;
+ }
+ entry.generation = generation;
+ entry.partitions = partitions;
+ }
}
impl ConsumerGroupClientState {
@@ -69,9 +85,8 @@ impl ConsumerGroupClientState {
/// True if a non-empty assignment is cached for the group.
#[must_use]
pub fn has_assignment(&self, key: &str) -> bool {
- self.assignments
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
+ self.groups()
+ .assignments
.get(key)
.is_some_and(|assignment| !assignment.partitions.is_empty())
}
@@ -79,35 +94,21 @@ impl ConsumerGroupClientState {
/// Replace the cached assignment for a group. A generation change (a
/// rebalance) resets the round-robin cursor so selection restarts cleanly.
pub fn set_assignment(&self, key: String, generation: u64, partitions: Vec<u32>) {
- let mut map = self
- .assignments
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
- let entry = map.entry(key).or_default();
- if entry.generation != generation {
- entry.cursor = 0;
- }
- entry.generation = generation;
- entry.partitions = partitions;
+ self.groups().set_assignment(key, generation, partitions);
}
/// Drop a group's cached assignment (after a fence rejection) so the next
/// poll re-syncs.
pub fn invalidate_assignment(&self, key: &str) {
- self.assignments
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .remove(key);
+ self.groups().assignments.remove(key);
}
/// The next assigned partition for a group poll, advancing the cursor.
/// `None` when nothing is cached / the assignment is empty.
#[must_use]
pub fn next_group_partition(&self, key: &str) -> Option<u32> {
- let mut map = self
- .assignments
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let mut groups = self.groups();
+ let map = &mut groups.assignments;
let assignment = map.get_mut(key)?;
if assignment.partitions.is_empty() {
return None;
@@ -124,9 +125,9 @@ impl ConsumerGroupClientState {
&self,
stream_id: &Identifier,
topic_id: &Identifier,
- partition_count: u32,
+ partitions_count: u32,
) -> u32 {
- if partition_count == 0 {
+ if partitions_count == 0 {
return 0;
}
let mut map = self
@@ -137,7 +138,7 @@ impl ConsumerGroupClientState {
.get_mut(stream_id)
.and_then(|topics| topics.get_mut(topic_id))
{
- let partition = (topic.cursor % partition_count as usize) as u32;
+ let partition = (topic.cursor % partitions_count as usize) as u32;
topic.cursor = topic.cursor.wrapping_add(1);
return partition;
}
@@ -151,25 +152,12 @@ impl ConsumerGroupClientState {
0
}
- #[must_use]
- pub fn partition_count(&self, stream_id: &Identifier, topic_id: &Identifier) -> Option<u32> {
- self.topic_partitions
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .get(stream_id)?
- .get(topic_id)?
- .count
- }
-
pub fn set_topic_partitions(
&self,
stream_id: &Identifier,
topic_id: &Identifier,
partitions: &[crate::Partition],
) {
- let Ok(count) = u32::try_from(partitions.len()) else {
- return;
- };
let mut map = self
.topic_partitions
.lock()
@@ -179,7 +167,6 @@ impl ConsumerGroupClientState {
.or_default()
.entry(topic_id.clone())
.or_default();
- topic.count = Some(count);
topic.contexts = partitions
.iter()
.map(|partition| (partition.id, partition.context))
@@ -204,24 +191,6 @@ impl ConsumerGroupClientState {
.copied()
}
- pub fn set_partition_context(
- &self,
- stream_id: &Identifier,
- topic_id: &Identifier,
- partition_id: u32,
- context: PartitionContext,
- ) {
- self.topic_partitions
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .entry(stream_id.clone())
- .or_default()
- .entry(topic_id.clone())
- .or_default()
- .contexts
- .insert(partition_id, context);
- }
-
/// Remember that the topic details were refused to this client, so its sends to the topic go
/// without a context instead of asking for the details again.
pub fn refuse_send_contexts(&self, stream_id: &Identifier, topic_id: &Identifier) {
@@ -253,7 +222,6 @@ impl ConsumerGroupClientState {
.get_mut(stream_id)
.and_then(|topics| topics.get_mut(topic_id))
{
- topic.count = None;
topic.contexts.clear();
topic.send_contexts_refused = false;
}
@@ -267,7 +235,6 @@ impl ConsumerGroupClientState {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for topic in map.values_mut().flat_map(HashMap::values_mut) {
- topic.count = None;
topic.contexts.clear();
topic.send_contexts_refused = false;
}
@@ -281,18 +248,14 @@ impl ConsumerGroupClientState {
topic_id: Identifier,
group_id: Identifier,
) {
- self.joined_groups
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
+ self.groups()
+ .joined_groups
.insert(key, (stream_id, topic_id, group_id));
}
/// Forget a group (after leave / delete) so the heartbeat stops re-syncing.
pub fn deregister_group(&self, key: &str) {
- self.joined_groups
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .remove(key);
+ self.groups().joined_groups.remove(key);
}
/// True if the last assignment sync observed this client as a member. A
@@ -300,39 +263,58 @@ impl ConsumerGroupClientState {
/// this is a different question than [`Self::has_assignment`].
#[must_use]
pub fn is_registered(&self, key: &str) -> bool {
- self.joined_groups
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .contains_key(key)
+ self.groups().joined_groups.contains_key(key)
}
/// Identifiers of every group the client has joined on this transport.
#[must_use]
pub fn registered_groups(&self) -> Vec<(Identifier, Identifier, Identifier)> {
- self.joined_groups
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .values()
- .cloned()
- .collect()
+ self.groups().joined_groups.values().cloned().collect()
}
/// Drop what the consensus session owned. Membership is per connection
/// and the coordinator fences assignments by a generation it tracks per
/// session, so nothing synced under the old session holds once it is
- /// reset. The balanced cursors and the partition counts stay: they belong
- /// to a topic, not to a session, and clearing them would restart the
- /// produce round-robin at partition 0 and cost a metadata round trip per
- /// topic on every reconnect.
+ /// reset. The topic state stays: it belongs to a topic, not to a session,
+ /// and clearing the balanced cursors would restart the produce round-robin
+ /// at partition 0 on every reconnect.
pub fn clear_session_scoped(&self) {
- self.assignments
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clear();
- self.joined_groups
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner)
- .clear();
+ let mut groups = self.groups();
+ groups.generation += 1;
+ groups.assignments.clear();
+ groups.joined_groups.clear();
+ }
+
+ #[doc(hidden)]
+ pub fn session_generation(&self) -> u64 {
+ self.groups().generation
+ }
+
+ /// Publishes membership and assignment together, only for the session that requested them.
+ #[doc(hidden)]
+ pub fn apply_group_assignment(
+ &self,
+ generation: u64,
+ key: String,
+ identifiers: (Identifier, Identifier, Identifier),
+ assignment: Option<(u64, Vec<u32>)>,
+ ) -> Result<(), IggyError> {
+ let mut groups = self.groups();
+ if groups.generation != generation {
+ return Err(IggyError::TransientNotAccepted);
+ }
+ let Some((generation, partitions)) = assignment else {
+ groups.assignments.remove(&key);
+ groups.joined_groups.remove(&key);
+ return Ok(());
+ };
+ groups.joined_groups.insert(key.clone(), identifiers);
+ groups.set_assignment(key, generation, partitions);
+ Ok(())
+ }
+
+ fn groups(&self) -> MutexGuard<'_, GroupState> {
+ self.groups.lock().unwrap_or_else(PoisonError::into_inner)
}
}
@@ -361,10 +343,11 @@ mod tests {
let numeric = Identifier::numeric(2).unwrap();
let named = Identifier::named("2").unwrap();
state.set_topic_partitions(&stream, &numeric, &partitions(3));
- assert_eq!(state.partition_count(&stream, &named), None);
+ assert!(state.partition_context(&stream, &numeric, 0).is_some());
+ assert_eq!(state.partition_context(&stream, &named, 0), None);
assert_eq!(state.next_balanced_partition(&stream, &numeric, 3), 0);
state.invalidate_topic_discovery();
- assert_eq!(state.partition_count(&stream, &numeric), None);
+ assert_eq!(state.partition_context(&stream, &numeric, 0), None);
state.set_topic_partitions(&stream, &numeric, &partitions(3));
assert_eq!(state.next_balanced_partition(&stream, &numeric, 3), 1);
}
@@ -405,25 +388,6 @@ mod tests {
assert_eq!(picks, vec![0, 1, 2, 0]);
}
- #[test]
- fn send_context_discovery_does_not_invent_a_partition_count() {
- let stream = Identifier::named("s").unwrap();
- let topic = Identifier::named("t").unwrap();
- let state = ConsumerGroupClientState::new();
- let context = PartitionContext::default();
- state.set_partition_context(&stream, &topic, 2, context);
- assert_eq!(state.partition_context(&stream, &topic, 2), Some(context));
- assert_eq!(state.partition_count(&stream, &topic), None);
-
- state.set_topic_partitions(&stream, &topic, &partitions(3));
- assert_eq!(state.partition_count(&stream, &topic), Some(3));
- assert_eq!(state.partition_context(&stream, &topic, 2), Some(context));
-
- state.invalidate_topic(&stream, &topic);
- assert_eq!(state.partition_context(&stream, &topic, 2), None);
- assert_eq!(state.partition_count(&stream, &topic), None);
- }
-
#[test]
fn refused_send_contexts_are_forgotten_with_the_topic_state() {
let stream = Identifier::named("s").unwrap();
@@ -491,7 +455,7 @@ mod tests {
assert!(!state.is_registered("s|t|g"));
assert!(!state.has_assignment("s|t|g"));
// Topic state outlives the session: the produce cursor carries on and
- // the partition count is still cached.
+ // the send contexts are still cached.
assert_eq!(
state.next_balanced_partition(
&Identifier::named("s").unwrap(),
@@ -500,6 +464,6 @@ mod tests {
),
1
);
- assert_eq!(state.partition_count(&stream, &topic), Some(3));
+ assert!(state.partition_context(&stream, &topic, 0).is_some());
}
}
diff --git a/core/common/src/error/iggy_error.rs b/core/common/src/error/iggy_error.rs
index 909d03eca..4cd0510a9 100644
--- a/core/common/src/error/iggy_error.rs
+++ b/core/common/src/error/iggy_error.rs
@@ -475,11 +475,15 @@ pub enum IggyError {
ProducerSendFailed {
cause: Box<IggyError>,
failed: Arc<Vec<IggyMessage>>,
+ /// How many messages at the head of `failed` the operation of an
+ /// `OperationUncertain` cause carried, which may have committed. Zero
+ /// for any other cause.
+ uncertain: usize,
/// Confirmations of the chunks that committed before `cause`; the
/// durable prefix of a send that was split into several requests.
committed: Arc<Vec<SendMessagesConfirmationResponse>>,
- stream_name: String,
- topic_name: String,
+ stream_name: Box<str>,
+ topic_name: Box<str>,
} = 4056,
#[error("Producer closed")]
ProducerClosed = 4057,
@@ -515,9 +519,9 @@ pub enum IggyError {
)]
CannotDeleteConsumerGroupInfo(usize, Identifier, Identifier) = 5008,
#[error(
- "Consumer group member with client ID: {0} does not own partition: {1} at the current generation (rebalance in progress)."
+ "Consumer group member with VSR client ID: {0} does not own partition: {1} at the current generation (rebalance in progress)."
)]
- ConsumerGroupPartitionNotOwned(u32, u32) = 5009,
+ ConsumerGroupPartitionNotOwned(u128, u32) = 5009,
#[error(
"Too many consumer groups in this topic (limit {max})",
max = CONSUMER_GROUP_OWNERS_MAX
@@ -626,6 +630,17 @@ impl IggyError {
self.into()
}
+ /// The retained operation that an `OperationUncertain`, `LaneBlocked` or
+ /// `OperationNotRetained` error names, to resolve or abandon it.
+ pub fn operation_id(&self) -> Option<u64> {
+ match self {
+ IggyError::OperationUncertain(operation_id)
+ | IggyError::LaneBlocked(operation_id)
+ | IggyError::OperationNotRetained(operation_id) => Some(*operation_id),
+ _ => None,
+ }
+ }
+
pub fn from_code(code: u32) -> Self {
IggyError::from_repr(code).unwrap_or(IggyError::Error)
}
@@ -650,6 +665,25 @@ mod tests {
const GROUP_NAME_ERROR_CODE: u32 = 5005;
const RETAINED_OPERATION: u64 = 7;
+ #[cfg(target_arch = "x86_64")]
+ #[test]
+ fn error_and_result_layout_on_x86_64() {
+ const ERROR_SIZE: usize = 80;
+ const ERROR_ALIGNMENT: usize = 16;
+ assert_eq!(std::mem::size_of::<IggyError>(), ERROR_SIZE);
+ assert_eq!(std::mem::align_of::<IggyError>(), ERROR_ALIGNMENT);
+ assert_eq!(std::mem::size_of::<Result<(), IggyError>>(), ERROR_SIZE);
+ assert_eq!(
+ std::mem::align_of::<Result<(), IggyError>>(),
+ ERROR_ALIGNMENT
+ );
+ assert_eq!(std::mem::size_of::<Result<usize, IggyError>>(), ERROR_SIZE);
+ assert_eq!(
+ std::mem::align_of::<Result<usize, IggyError>>(),
+ ERROR_ALIGNMENT
+ );
+ }
+
#[test]
fn derived_sdk_error_discriminant_keeps_codes() {
assert_eq!(
@@ -752,6 +786,22 @@ mod tests {
));
}
+ #[test]
+ fn given_recovery_errors_when_reading_operation_id_should_return_the_named_operation() {
+ let cases = [
+ (
+ IggyError::OperationUncertain(RETAINED_OPERATION),
+ Some(RETAINED_OPERATION),
+ ),
+ (IggyError::LaneBlocked(u64::MAX), Some(u64::MAX)),
+ (IggyError::OperationNotRetained(0), Some(0)),
+ (IggyError::NotPrimary, None),
+ ];
+ for (error, operation_id) in cases {
+ assert_eq!(error.operation_id(), operation_id, "{error}");
+ }
+ }
+
#[test]
fn given_too_many_consumer_groups_when_displayed_should_name_the_per_topic_limit() {
let message = IggyError::TooManyConsumerGroups.to_string();
@@ -761,4 +811,13 @@ mod tests {
"{message}"
);
}
+
+ #[test]
+ fn given_partition_not_owned_when_displayed_should_name_the_full_client_id() {
+ const CLIENT_ID: u128 = (1 << 64) + 41;
+ const PARTITION_ID: u32 = 3;
+ let message =
+ IggyError::ConsumerGroupPartitionNotOwned(CLIENT_ID, PARTITION_ID).to_string();
+ assert!(message.contains(&CLIENT_ID.to_string()), "{message}");
+ }
}
diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs
index 486a97abf..300614bb9 100644
--- a/core/common/src/lib.rs
+++ b/core/common/src/lib.rs
@@ -83,7 +83,8 @@ pub use traits::binary_client::BinaryClient;
pub use traits::binary_transport::BinaryTransport;
#[doc(hidden)]
pub use traits::binary_transport::{
- BINARY_OPERATION_TIMEOUT, binary_operation_deadline, with_binary_deadline,
+ BINARY_OPERATION_TIMEOUT, BinaryOperationLineage, binary_operation_deadline,
+ binary_operation_lineage, with_binary_deadline, with_binary_lineage,
};
pub use traits::binary_transport::{VsrSessionControl, VsrSessionSealed, decode_session_binding};
pub use traits::client::Client;
@@ -104,7 +105,7 @@ pub use traits::user_client::UserClient;
pub use traits::validatable::Validatable;
#[doc(hidden)]
pub use traits::{BinarySendPartitioning, send_binary_messages};
-pub use traits::{rust_sdk_version_info, sign_in_request};
+pub use traits::{binary_sign_in, rust_sdk_version_info, sign_in_request};
pub use types::args::*;
pub use types::client::client_info::*;
pub use types::client_state::ClientState;
diff --git a/core/common/src/traits/binary_impls/messages.rs b/core/common/src/traits/binary_impls/messages.rs
index 7a7249ac7..2eb3aad56 100644
--- a/core/common/src/traits/binary_impls/messages.rs
+++ b/core/common/src/traits/binary_impls/messages.rs
@@ -26,7 +26,7 @@ use crate::{
};
use crate::{
ConsumerKind, PartitioningKind, TopicClient, binary_operation_deadline, calculate_32,
- with_binary_deadline,
+ with_binary_deadline, with_binary_lineage,
};
use bytes::{Bytes, BytesMut};
use iggy_binary_protocol::codec::WireDecode;
@@ -56,45 +56,46 @@ async fn sync_group_assignment<B: BinaryClient>(
topic_id: &Identifier,
group_id: &Identifier,
) -> Result<(), IggyError> {
- let request = SyncConsumerGroupRequest {
- stream_id: identifier_to_wire(stream_id)?,
- topic_id: identifier_to_wire(topic_id)?,
- group_id: identifier_to_wire(group_id)?,
- };
- let response = client
- .send_raw_with_response(SYNC_CONSUMER_GROUP_CODE, request.to_bytes())
- .await?;
- let key = group_cache_key(stream_id, topic_id, group_id);
- if response.is_empty() {
- // Empty reply = not a member: the coordinator sends an assignment
- // header for any member, including one holding zero partitions.
- // Registering only on a non-empty reply keeps a non-member group from
- // leaking a `joined_groups` entry, and the deregister is the only thing
- // that observes a server-side removal (group deleted, member evicted):
- // without it `is_registered` latches true and every later poll returns
- // empty instead of surfacing 5006.
- client.consumer_group_state().invalidate_assignment(&key);
- client.consumer_group_state().deregister_group(&key);
- return Ok(());
- }
- let (assignment, _) =
- SyncConsumerGroupResponse::decode(&response).map_err(|_| IggyError::InvalidCommand)?;
- client.consumer_group_state().register_group(
- key.clone(),
- stream_id.clone(),
- topic_id.clone(),
- group_id.clone(),
- );
- client
- .consumer_group_state()
- .set_assignment(key, assignment.generation, assignment.partitions);
- Ok(())
+ with_binary_lineage(client.operation_lineage(), async {
+ let state = client.consumer_group_state();
+ let request = SyncConsumerGroupRequest {
+ stream_id: identifier_to_wire(stream_id)?,
+ topic_id: identifier_to_wire(topic_id)?,
+ group_id: identifier_to_wire(group_id)?,
+ };
+ let deadline = crate::binary_operation_deadline();
+ loop {
+ let generation = state.session_generation();
+ let response = client
+ .send_raw_with_response(SYNC_CONSUMER_GROUP_CODE, request.to_bytes())
+ .await?;
+ let assignment = if response.is_empty() {
+ None
+ } else {
+ let (assignment, _) = SyncConsumerGroupResponse::decode(&response)
+ .map_err(|_| IggyError::InvalidCommand)?;
+ Some((assignment.generation, assignment.partitions))
+ };
+ let applied = state.apply_group_assignment(
+ generation,
+ group_cache_key(stream_id, topic_id, group_id),
+ (stream_id.clone(), topic_id.clone(), group_id.clone()),
+ assignment,
+ );
+ match applied {
+ // A successful read from the replaced session cannot publish its old membership.
+ Err(IggyError::TransientNotAccepted) if tokio::time::Instant::now() < deadline => {}
+ outcome => return outcome,
+ }
+ }
+ })
+ .await
}
/// Re-sync every joined group's assignment from the coordinator. Heartbeat
/// driven so a member picks up a widened assignment (e.g. after a
/// partition-count change) without first hitting an ownership fence. A failed
-/// per-group sync is logged and skipped so one bad group can't stall the rest.
+/// per-group sync is logged. Sequential refreshes share the heartbeat's deadline.
pub(crate) async fn refresh_group_assignments<B: BinaryClient>(client: &B) {
for (stream_id, topic_id, group_id) in client.consumer_group_state().registered_groups() {
if let Err(error) = sync_group_assignment(client, &stream_id, &topic_id, &group_id).await {
@@ -105,16 +106,27 @@ pub(crate) async fn refresh_group_assignments<B: BinaryClient>(client: &B) {
}
}
-/// Resolve (and cache) the topic's partition count for client-side produce
-/// partitioning.
+/// The topic's partition count for client-side produce partitioning: the
+/// count of a usable cached route, else a fresh one.
async fn topic_partition_count<B: BinaryClient>(
client: &B,
stream_id: &Identifier,
topic_id: &Identifier,
) -> Result<u32, IggyError> {
- if let Some(count) = client.routed_partition_count(stream_id, topic_id) {
- return Ok(count);
+ match client.routed_partition_count(stream_id, topic_id) {
+ Some(count) => Ok(count),
+ None => fresh_topic_partition_count(client, stream_id, topic_id).await,
}
+}
+
+/// The partition count from a fresh read of the topic details. A send that
+/// found its partition missing refreshes through it, because a cached route
+/// can still count the partitions from before the change.
+async fn fresh_topic_partition_count<B: BinaryClient>(
+ client: &B,
+ stream_id: &Identifier,
+ topic_id: &Identifier,
+) -> Result<u32, IggyError> {
let details = TopicClient::get_topic(client, stream_id, topic_id)
.await?
.ok_or_else(|| IggyError::TopicIdNotFound(topic_id.clone(), stream_id.clone()))?;
@@ -156,59 +168,61 @@ pub async fn send_binary_messages<B: BinaryClient>(
partitioning: &mut BinarySendPartitioning<'_>,
messages: &mut [IggyMessage],
) -> Result<SendMessagesResponse, IggyError> {
- with_binary_deadline(binary_operation_deadline(), async {
- fail_if_not_authenticated(client).await?;
- if partitioning.selected.is_none() {
- partitioning.selected = Some(match partitioning.original.kind {
- PartitioningKind::PartitionId => Cow::Borrowed(partitioning.original),
- _ => {
- let count = topic_partition_count(client, stream_id, topic_id).await?;
- Cow::Owned(select_partition(
- client,
- stream_id,
- topic_id,
- partitioning.original,
- count,
- )?)
- }
- });
- }
- loop {
- let selected = partitioning
- .selected
- .as_deref()
- .ok_or(IggyError::InvalidCommand)?;
- let payload = encode_messages(stream_id, topic_id, selected, messages)?;
- match client
- .send_raw_with_context(
- SEND_MESSAGES_CODE,
- payload,
- crate::PartitionContext::default(),
- )
- .await
- {
- Err(IggyError::PartitionNotFound(..))
- if !partitioning.refreshed
- && partitioning.original.kind != PartitioningKind::PartitionId =>
+ with_binary_lineage(
+ client.operation_lineage(),
+ with_binary_deadline(binary_operation_deadline(), async {
+ fail_if_not_authenticated(client).await?;
+ if partitioning.selected.is_none() {
+ partitioning.selected = Some(match partitioning.original.kind {
+ PartitioningKind::PartitionId => Cow::Borrowed(partitioning.original),
+ _ => {
+ let count = topic_partition_count(client, stream_id, topic_id).await?;
+ Cow::Owned(select_partition(
+ client,
+ stream_id,
+ topic_id,
+ partitioning.original,
+ count,
+ )?)
+ }
+ });
+ }
+ loop {
+ let selected = partitioning
+ .selected
+ .as_deref()
+ .ok_or(IggyError::InvalidCommand)?;
+ let payload = encode_messages(stream_id, topic_id, selected, messages)?;
+ match client
+ .send_raw_with_context(
+ SEND_MESSAGES_CODE,
+ payload,
+ crate::PartitionContext::default(),
+ )
+ .await
{
- partitioning.refreshed = true;
- let details = TopicClient::get_topic(client, stream_id, topic_id)
- .await?
- .ok_or_else(|| {
- IggyError::TopicIdNotFound(topic_id.clone(), stream_id.clone())
- })?;
- partitioning.selected = Some(Cow::Owned(select_partition(
- client,
- stream_id,
- topic_id,
- partitioning.original,
- details.partitions_count,
- )?));
+ Err(IggyError::PartitionNotFound(..))
+ if !partitioning.refreshed
+ && partitioning.original.kind != PartitioningKind::PartitionId =>
+ {
+ partitioning.refreshed = true;
+ let count =
+ fresh_topic_partition_count(client, stream_id, topic_id).await?;
+ partitioning.selected = Some(Cow::Owned(select_partition(
+ client,
+ stream_id,
+ topic_id,
+ partitioning.original,
+ count,
+ )?));
+ }
+ result => {
+ return result.map(|response| committed_send_confirmations(&response));
+ }
}
- result => return result.map(|response| committed_send_confirmations(&response)),
}
- }
- })
+ }),
+ )
.await
}
@@ -384,36 +398,39 @@ impl<B: BinaryClient> MessageClient for B {
count: u32,
auto_commit: bool,
) -> Result<PolledMessages, IggyError> {
- with_binary_deadline(binary_operation_deadline(), async {
- fail_if_not_authenticated(self).await?;
- // VSR: a consumer-group poll without an explicit partition is resolved
- // client-side from the member's cached assignment (the broker routes
- // explicit partitions only).
- if consumer.kind == ConsumerKind::ConsumerGroup && partition_id.is_none() {
- return poll_group_messages(
- self,
- stream_id,
- topic_id,
- consumer,
- strategy_for,
+ with_binary_lineage(
+ self.operation_lineage(),
+ with_binary_deadline(binary_operation_deadline(), async {
+ fail_if_not_authenticated(self).await?;
+ // VSR: a consumer-group poll without an explicit partition is resolved
+ // client-side from the member's cached assignment (the broker routes
+ // explicit partitions only).
+ if consumer.kind == ConsumerKind::ConsumerGroup && partition_id.is_none() {
+ return poll_group_messages(
+ self,
+ stream_id,
+ topic_id,
+ consumer,
+ strategy_for,
+ count,
+ auto_commit,
+ )
+ .await;
+ }
+ let strategy = strategy_for(partition_id.unwrap_or(0));
+ let req = PollMessagesRequest {
+ consumer: consumer_to_wire(consumer)?,
+ stream_id: identifier_to_wire(stream_id)?,
+ topic_id: identifier_to_wire(topic_id)?,
+ partition_id,
+ strategy: polling_strategy_to_wire(&strategy),
count,
auto_commit,
- )
- .await;
- }
- let strategy = strategy_for(partition_id.unwrap_or(0));
- let req = PollMessagesRequest {
- consumer: consumer_to_wire(consumer)?,
- stream_id: identifier_to_wire(stream_id)?,
- topic_id: identifier_to_wire(topic_id)?,
- partition_id,
- strategy: polling_strategy_to_wire(&strategy),
- count,
- auto_commit,
- };
- let response = self.send_poll_with_response(&req, strategy.context).await?;
- PolledMessages::from_bytes(response)
- })
+ };
+ let response = self.send_poll_with_response(&req, strategy.context).await?;
+ PolledMessages::from_bytes(response)
+ }),
+ )
.await
}
diff --git a/core/common/src/traits/binary_impls/mod.rs b/core/common/src/traits/binary_impls/mod.rs
index 26b21b8ca..80723f0b1 100644
--- a/core/common/src/traits/binary_impls/mod.rs
+++ b/core/common/src/traits/binary_impls/mod.rs
@@ -33,12 +33,13 @@ use crate::IggyError;
use crate::http::users::defaults::{
MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
};
-use crate::{BinaryClient, ClientState, Credentials};
+use crate::{BinaryClient, ClientState, Credentials, IdentityInfo};
use bytes::Bytes;
use iggy_binary_protocol::codec::WireEncode;
use iggy_binary_protocol::codes::{LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE};
use iggy_binary_protocol::requests::users::login_register::BindSecret;
use iggy_binary_protocol::requests::users::{LoginRegisterRequest, LoginRegisterWithPatRequest};
+use iggy_binary_protocol::responses::users::LoginRegisterResponse;
use iggy_binary_protocol::{
ClientVersionInfo, IGGY_PROTOCOL_VERSION, MAX_WIRE_NAME_LENGTH, WireDecode, WireName,
};
@@ -95,6 +96,38 @@ pub fn sign_in_request(
}
}
+/// Signs in with `credentials` once any session of the connection is
+/// released. The caller validates them first, so invalid credentials leave
+/// the live session alone.
+#[doc(hidden)]
+pub async fn binary_sign_in<B: BinaryClient>(
+ client: &B,
+ credentials: Credentials,
+) -> Result<IdentityInfo, IggyError> {
+ logout_before_relogin(client).await?;
+ let fence = client.session_fence();
+ let (code, payload) = sign_in_request(
+ &credentials,
+ rust_sdk_version_info(client.sdk_version())?,
+ client.session_bind_secret().await?,
+ )?;
+ let response = client.send_raw_with_response(code, payload).await?;
+ let wire_resp = decode_response::<LoginRegisterResponse>(&response)?;
+ client.bind_vsr_session_at(fence, wire_resp.session).await?;
+ tracing::debug!(
+ server_version = %wire_resp.server_version,
+ server_protocol_version = wire_resp.server_protocol_version,
+ "authenticated against iggy server"
+ );
+ client
+ .finish_sign_in(credentials, wire_resp.user_id)
+ .await?;
+ Ok(IdentityInfo {
+ user_id: wire_resp.user_id,
+ access_token: None,
+ })
+}
+
/// Prepare the connection before login, then release any existing session.
///
/// The server binds a connection to one `(client, session)` and resolves the
@@ -109,8 +142,10 @@ pub(crate) async fn logout_before_relogin<B: BinaryClient>(client: &B) -> Result
timeout_at(crate::binary_operation_deadline(), client.prepare_sign_in())
.await
.map_err(|_| IggyError::TransientNotAccepted)??;
- if client.get_state().await == ClientState::Authenticated {
- client.logout_user().await?;
+ if client.get_state().await == ClientState::Authenticated
+ || client.session_identity().await.is_ok()
+ {
+ users::logout(client, true).await?;
}
Ok(())
}
diff --git a/core/common/src/traits/binary_impls/partitions.rs b/core/common/src/traits/binary_impls/partitions.rs
index e2c04df15..dedf63b48 100644
--- a/core/common/src/traits/binary_impls/partitions.rs
+++ b/core/common/src/traits/binary_impls/partitions.rs
@@ -45,7 +45,6 @@ impl<B: BinaryClient> PartitionClient for B {
.to_bytes(),
)
.await?;
- self.consumer_group_state().invalidate_topic_discovery();
Ok(())
}
@@ -68,7 +67,6 @@ impl<B: BinaryClient> PartitionClient for B {
.to_bytes(),
)
.await?;
- self.consumer_group_state().invalidate_topic_discovery();
Ok(())
}
}
diff --git a/core/common/src/traits/binary_impls/personal_access_tokens.rs b/core/common/src/traits/binary_impls/personal_access_tokens.rs
index 48ad1d157..92e3105ed 100644
--- a/core/common/src/traits/binary_impls/personal_access_tokens.rs
+++ b/core/common/src/traits/binary_impls/personal_access_tokens.rs
@@ -18,9 +18,8 @@
use crate::traits::binary_auth::fail_if_not_authenticated;
use crate::wire_conversions::personal_access_tokens_from_wire;
use crate::{
- BinaryClient, ClientState, Credentials, DiagnosticEvent, IdentityInfo, IggyError,
- PersonalAccessTokenClient, PersonalAccessTokenExpiry, PersonalAccessTokenInfo,
- RawPersonalAccessToken,
+ BinaryClient, Credentials, IdentityInfo, IggyError, PersonalAccessTokenClient,
+ PersonalAccessTokenExpiry, PersonalAccessTokenInfo, RawPersonalAccessToken,
};
use iggy_binary_protocol::WireName;
use iggy_binary_protocol::codec::WireEncode;
@@ -34,7 +33,6 @@ use iggy_binary_protocol::requests::personal_access_tokens::{
};
use iggy_binary_protocol::responses::personal_access_tokens::create_personal_access_token::RawPersonalAccessTokenResponse;
use iggy_binary_protocol::responses::personal_access_tokens::get_personal_access_tokens::GetPersonalAccessTokensResponse;
-use iggy_binary_protocol::responses::users::LoginRegisterResponse;
use secrecy::SecretString;
#[async_trait::async_trait]
@@ -92,31 +90,9 @@ impl<B: BinaryClient> PersonalAccessTokenClient for B {
) -> Result<IdentityInfo, IggyError> {
crate::with_binary_deadline(crate::binary_operation_deadline(), async {
super::validate_token(token)?;
- super::logout_before_relogin(self).await?;
let credentials =
Credentials::PersonalAccessToken(SecretString::from(token.to_string()));
- let fence = self.session_fence();
- let (code, payload) = super::sign_in_request(
- &credentials,
- super::rust_sdk_version_info(self.sdk_version())?,
- self.session_bind_secret().await?,
- )?;
- let response = self.send_raw_with_response(code, payload).await?;
- let wire_resp = super::decode_response::<LoginRegisterResponse>(&response)?;
- self.bind_vsr_session_at(fence, wire_resp.session).await?;
- tracing::debug!(
- server_version = %wire_resp.server_version,
- server_protocol_version = wire_resp.server_protocol_version,
- "authenticated against iggy server"
- );
- self.set_state(ClientState::Authenticated).await;
- self.remember_session_credentials(credentials, wire_resp.user_id)
- .await;
- self.publish_event(DiagnosticEvent::SignedIn).await;
- Ok(IdentityInfo {
- user_id: wire_resp.user_id,
- access_token: None,
- })
+ self.sign_in_with_credentials(credentials).await
})
.await
}
diff --git a/core/common/src/traits/binary_impls/streams.rs b/core/common/src/traits/binary_impls/streams.rs
index 6b8133f27..2667341d9 100644
--- a/core/common/src/traits/binary_impls/streams.rs
+++ b/core/common/src/traits/binary_impls/streams.rs
@@ -109,7 +109,6 @@ impl<B: BinaryClient> StreamClient for B {
DeleteStreamRequest { stream_id: wire_id }.to_bytes(),
)
.await?;
- self.consumer_group_state().invalidate_topic_discovery();
Ok(())
}
}
diff --git a/core/common/src/traits/binary_impls/topics.rs b/core/common/src/traits/binary_impls/topics.rs
index 868a3119d..7ce2f88f4 100644
--- a/core/common/src/traits/binary_impls/topics.rs
+++ b/core/common/src/traits/binary_impls/topics.rs
@@ -56,10 +56,7 @@ impl<B: BinaryClient> TopicClient for B {
return Ok(None);
}
let wire_resp = super::decode_response::<GetTopicResponse>(&response)?;
- let details = TopicDetails::try_from(wire_resp)?;
- self.consumer_group_state()
- .set_topic_partitions(stream_id, topic_id, &details.partitions);
- Ok(Some(details))
+ Ok(Some(TopicDetails::try_from(wire_resp)?))
}
async fn get_topics(&self, stream_id: &Identifier) -> Result<Vec<Topic>, IggyError> {
@@ -148,7 +145,6 @@ impl<B: BinaryClient> TopicClient for B {
.to_bytes(),
)
.await?;
- self.consumer_group_state().invalidate_topic_discovery();
Ok(())
}
}
diff --git a/core/common/src/traits/binary_impls/users.rs b/core/common/src/traits/binary_impls/users.rs
index b6533611e..9d873191c 100644
--- a/core/common/src/traits/binary_impls/users.rs
+++ b/core/common/src/traits/binary_impls/users.rs
@@ -18,8 +18,8 @@
use crate::traits::binary_auth::fail_if_not_authenticated;
use crate::wire_conversions::{identifier_to_wire, permissions_to_wire, users_from_wire};
use crate::{
- BinaryClient, ClientState, Credentials, DiagnosticEvent, Identifier, IdentityInfo, IggyError,
- Permissions, UserClient, UserInfo, UserInfoDetails, UserStatus, UserUpdateOptions,
+ BinaryClient, Credentials, Identifier, IdentityInfo, IggyError, Permissions, UserClient,
+ UserInfo, UserInfoDetails, UserStatus, UserUpdateOptions,
};
use iggy_binary_protocol::codec::WireEncode;
use iggy_binary_protocol::codes::{
@@ -30,7 +30,6 @@ use iggy_binary_protocol::requests::users::{
ChangePasswordRequest, CreateUserRequest, DeleteUserRequest, GetUserRequest, GetUsersRequest,
LogoutUserRequest, UpdatePermissionsRequest, UpdateUserRequest,
};
-use iggy_binary_protocol::responses::users::LoginRegisterResponse;
use iggy_binary_protocol::responses::users::{GetUsersResponse, UserDetailsResponse};
use iggy_binary_protocol::{WireName, WireOptions};
use secrecy::SecretString;
@@ -179,45 +178,26 @@ impl<B: BinaryClient> UserClient for B {
crate::with_binary_deadline(crate::binary_operation_deadline(), async {
super::validate_username(username)?;
super::validate_password(password)?;
- super::logout_before_relogin(self).await?;
let credentials = Credentials::UsernamePassword(
username.to_owned(),
SecretString::from(password.to_string()),
);
- let fence = self.session_fence();
- let (code, payload) = super::sign_in_request(
- &credentials,
- super::rust_sdk_version_info(self.sdk_version())?,
- self.session_bind_secret().await?,
- )?;
- let response = self.send_raw_with_response(code, payload).await?;
- let wire_resp = super::decode_response::<LoginRegisterResponse>(&response)?;
- self.bind_vsr_session_at(fence, wire_resp.session).await?;
- tracing::debug!(
- server_version = %wire_resp.server_version,
- server_protocol_version = wire_resp.server_protocol_version,
- "authenticated against iggy server"
- );
- self.set_state(ClientState::Authenticated).await;
- self.remember_session_credentials(credentials, wire_resp.user_id)
- .await;
- self.publish_event(DiagnosticEvent::SignedIn).await;
- Ok(IdentityInfo {
- user_id: wire_resp.user_id,
- access_token: None,
- })
+ self.sign_in_with_credentials(credentials).await
})
.await
}
async fn logout_user(&self) -> Result<(), IggyError> {
- fail_if_not_authenticated(self).await?;
- self.send_raw_with_response(LOGOUT_USER_CODE, LogoutUserRequest.to_bytes())
- .await?;
- self.forget_session_credentials().await;
- self.reset_vsr_session().await?;
- self.set_state(ClientState::Connected).await;
- self.publish_event(DiagnosticEvent::SignedOut).await;
- Ok(())
+ crate::with_binary_lineage(self.operation_lineage(), logout(self, false)).await
}
}
+
+pub(super) async fn logout<B: BinaryClient>(client: &B, signing_in: bool) -> Result<(), IggyError> {
+ if !signing_in {
+ fail_if_not_authenticated(client).await?;
+ }
+ client
+ .send_raw_with_response(LOGOUT_USER_CODE, LogoutUserRequest.to_bytes())
+ .await?;
+ client.finish_sign_out(signing_in).await
+}
diff --git a/core/common/src/traits/binary_transport.rs b/core/common/src/traits/binary_transport.rs
index 42528b496..e7d9dcfe3 100644
--- a/core/common/src/traits/binary_transport.rs
+++ b/core/common/src/traits/binary_transport.rs
@@ -40,6 +40,45 @@ pub const BINARY_OPERATION_TIMEOUT: Duration = Duration::from_secs(30);
tokio::task_local! {
static OPERATION_DEADLINE: Instant;
+ static OPERATION_LINEAGE: Option<BinaryOperationLineage>;
+}
+
+/// The caller sign-in shared by every request of one public operation.
+/// The owner is stable across session recovery and distinguishes nested clients.
+#[doc(hidden)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct BinaryOperationLineage {
+ owner: u128,
+ lineage: u64,
+}
+
+impl BinaryOperationLineage {
+ pub fn new(owner: u128, lineage: u64) -> Self {
+ Self { owner, lineage }
+ }
+
+ pub fn capture(owner: u128, lineage: u64) -> Self {
+ binary_operation_lineage()
+ .filter(|current| current.owner == owner)
+ .unwrap_or(Self { owner, lineage })
+ }
+
+ pub fn lineage(self) -> u64 {
+ self.lineage
+ }
+}
+
+#[doc(hidden)]
+pub fn binary_operation_lineage() -> Option<BinaryOperationLineage> {
+ OPERATION_LINEAGE.try_get().ok().flatten()
+}
+
+#[doc(hidden)]
+pub async fn with_binary_lineage<F: Future>(
+ lineage: Option<BinaryOperationLineage>,
+ future: F,
+) -> F::Output {
+ OPERATION_LINEAGE.scope(lineage, future).await
}
#[doc(hidden)]
@@ -59,6 +98,11 @@ pub fn binary_operation_deadline() -> Instant {
#[async_trait]
pub trait BinaryTransport {
+ #[doc(hidden)]
+ fn operation_lineage(&self) -> Option<BinaryOperationLineage> {
+ None
+ }
+
/// Gets the state of the client.
async fn get_state(&self) -> ClientState;
/// Sets the state of the client.
@@ -155,6 +199,41 @@ mod vsr_session_sealed {
/// mutation corrupts the dedup counter or silently breaks at-most-once.
#[async_trait]
pub trait VsrSessionControl: vsr_session_sealed::Sealed + BinaryTransport {
+ /// Runs one explicit attempt, including cleanup if the caller cancels it.
+ #[doc(hidden)]
+ async fn sign_in_with_credentials(
+ &self,
+ credentials: Credentials,
+ ) -> Result<crate::IdentityInfo, IggyError>
+ where
+ Self: crate::BinaryClient + Sized,
+ {
+ crate::binary_sign_in(self, credentials).await
+ }
+
+ #[doc(hidden)]
+ async fn finish_sign_in(
+ &self,
+ credentials: Credentials,
+ user_id: u32,
+ ) -> Result<(), IggyError> {
+ self.set_state(ClientState::Authenticated).await;
+ self.remember_session_credentials(credentials, user_id)
+ .await;
+ self.publish_event(DiagnosticEvent::SignedIn).await;
+ Ok(())
+ }
+
+ /// Completes only the captured caller's Logout. Preparatory Logout keeps its lineage.
+ #[doc(hidden)]
+ async fn finish_sign_out(&self, _signing_in: bool) -> Result<(), IggyError> {
+ self.forget_session_credentials().await;
+ self.reset_vsr_session().await?;
+ self.set_state(ClientState::Connected).await;
+ self.publish_event(DiagnosticEvent::SignedOut).await;
+ Ok(())
+ }
+
/// Establish a connection for explicit credentials before capturing the
/// sign-in fence. Binary SDK transports suppress automatic sign-in here.
#[doc(hidden)]
@@ -261,13 +340,16 @@ pub fn decode_session_binding(
#[cfg(test)]
mod tests {
use super::*;
- use crate::ConsumerGroupClientState;
+ use crate::{Client, Consumer, ConsumerGroupClientState, MessageClient, PollingStrategy};
use bytes::BytesMut;
+ use iggy_binary_protocol::codes::SYNC_CONSUMER_GROUP_CODE;
use iggy_binary_protocol::codes::{GET_PARTITION_ROUTE_CODE, PING_CODE, SEND_MESSAGES_CODE};
use iggy_binary_protocol::requests::consumer_offsets::{
DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
};
use iggy_binary_protocol::requests::messages::{RawMessage, SendMessagesEncoder};
+ use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
+ use iggy_binary_protocol::responses::messages::PollMessagesResponseHeader;
use iggy_binary_protocol::{
AckLevel, WireConsumer, WireIdentifier, WirePartitioning, WirePollingStrategy,
};
@@ -285,10 +367,18 @@ mod tests {
}
/// Keeps the provided `send_raw_with_response`, the behavior under test.
- #[derive(Default)]
+ #[derive(Debug, Default)]
struct Recorder {
calls: Mutex<Vec<Call>>,
state: Arc<ConsumerGroupClientState>,
+ sync: Mutex<Option<SyncPublication>>,
+ }
+
+ #[derive(Debug)]
+ struct SyncPublication {
+ caller_changed: bool,
+ empty: bool,
+ replaced: bool,
}
impl Recorder {
@@ -318,6 +408,43 @@ mod tests {
context: PartitionContext,
) -> Result<Bytes, IggyError> {
self.record(Call::Raw(code, payload, context));
+ if code == SYNC_CONSUMER_GROUP_CODE {
+ let mut scenario = self.sync.lock().unwrap();
+ let scenario = scenario.as_mut().unwrap();
+ if scenario.replaced {
+ assert!(self.state.is_registered("1|orders|3"));
+ assert_eq!(self.state.next_group_partition("1|orders|3"), Some(7));
+ if scenario.caller_changed {
+ return Err(IggyError::Unauthenticated);
+ }
+ } else {
+ // The transport has accepted the reply; replacement wins before common cache publication.
+ scenario.replaced = true;
+ self.state.clear_session_scoped();
+ self.state.register_group(
+ "1|orders|3".to_owned(),
+ Identifier::numeric(STREAM).unwrap(),
+ Identifier::named(TOPIC).unwrap(),
+ Identifier::numeric(3).unwrap(),
+ );
+ self.state
+ .set_assignment("1|orders|3".to_owned(), 9, vec![7]);
+ return Ok(if scenario.empty {
+ Bytes::new()
+ } else {
+ SyncConsumerGroupResponse {
+ generation: 1,
+ partitions: vec![2],
+ }
+ .to_bytes()
+ });
+ }
+ return Ok(SyncConsumerGroupResponse {
+ generation: 9,
+ partitions: vec![7],
+ }
+ .to_bytes());
+ }
Ok(Bytes::from_static(RECEIPT))
}
@@ -337,6 +464,15 @@ mod tests {
context: Option<PartitionContext>,
) -> Result<Bytes, IggyError> {
self.record(Call::Poll(request.clone(), context));
+ if self.sync.lock().unwrap().is_some() {
+ return Ok(PollMessagesResponseHeader {
+ partition_id: request.partition_id.unwrap(),
+ current_offset: 0,
+ messages_count: 0,
+ context: Default::default(),
+ }
+ .to_bytes());
+ }
Ok(Bytes::from_static(RECEIPT))
}
@@ -349,6 +485,130 @@ mod tests {
}
}
+ impl VsrSessionSealed for Recorder {}
+ impl crate::BinaryClient for Recorder {}
+
+ #[async_trait]
+ impl VsrSessionControl for Recorder {
+ async fn session_bind_secret(&self) -> Result<BindSecret, IggyError> {
+ Err(IggyError::FeatureUnavailable)
+ }
+ async fn session_identity(&self) -> Result<SessionIdentity, IggyError> {
+ Err(IggyError::FeatureUnavailable)
+ }
+ async fn bind_vsr_session(&self, _session: u64) -> Result<(), IggyError> {
+ Err(IggyError::FeatureUnavailable)
+ }
+ async fn reset_vsr_session(&self) -> Result<(), IggyError> {
+ Err(IggyError::FeatureUnavailable)
+ }
+ fn sdk_version(&self) -> &'static str {
+ "0.0.0"
+ }
+ }
+
+ #[async_trait]
+ impl Client for Recorder {
+ async fn connect(&self) -> Result<(), IggyError> {
+ Ok(())
+ }
+ async fn disconnect(&self) -> Result<(), IggyError> {
+ Ok(())
+ }
+ async fn shutdown(&self) -> Result<(), IggyError> {
+ Ok(())
+ }
+ async fn subscribe_events(&self) -> async_broadcast::Receiver<DiagnosticEvent> {
+ async_broadcast::broadcast(1).1
+ }
+ }
+
+ async fn given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership(
+ caller_changed: bool,
+ empty: bool,
+ ) {
+ let transport = Recorder {
+ sync: Mutex::new(Some(SyncPublication {
+ caller_changed,
+ empty,
+ replaced: false,
+ })),
+ ..Default::default()
+ };
+ let result = transport
+ .poll_messages(
+ &Identifier::numeric(STREAM).unwrap(),
+ &Identifier::named(TOPIC).unwrap(),
+ None,
+ &Consumer::group(Identifier::numeric(3).unwrap()),
+ &PollingStrategy::offset(0),
+ 1,
+ false,
+ )
+ .await;
+ if caller_changed {
+ assert_eq!(result.unwrap_err(), IggyError::Unauthenticated);
+ } else {
+ assert!(
+ result.is_ok(),
+ "automatic recovery must re-sync: {result:?}"
+ );
+ }
+ assert!(transport.state.is_registered("1|orders|3"));
+ assert_eq!(transport.state.next_group_partition("1|orders|3"), Some(7));
+ let calls = transport.take_calls();
+ assert_eq!(
+ calls
+ .iter()
+ .filter(|call| matches!(call, Call::Raw(SYNC_CONSUMER_GROUP_CODE, ..)))
+ .count(),
+ 2
+ );
+ assert_eq!(
+ calls
+ .iter()
+ .filter(|call| matches!(call, Call::Poll(..)))
+ .count(),
+ usize::from(!caller_changed)
+ );
+ }
+
+ #[tokio::test]
+ async fn given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership_recovery_assignment()
+ {
+ given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership(
+ false, false,
+ )
+ .await;
+ }
+
+ #[tokio::test]
+ async fn given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership_recovery_empty()
+ {
+ given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership(
+ false, true,
+ )
+ .await;
+ }
+
+ #[tokio::test]
+ async fn given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership_caller_assignment()
+ {
+ given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership(
+ true, false,
+ )
+ .await;
+ }
+
+ #[tokio::test]
+ async fn given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership_caller_empty()
+ {
+ given_a_sync_reply_when_session_changes_before_publication_should_keep_new_membership(
+ true, true,
+ )
+ .await;
+ }
+
fn wire_topic() -> WireIdentifier {
WireIdentifier::named(TOPIC).unwrap()
}
@@ -470,10 +730,29 @@ mod tests {
#[tokio::test]
async fn given_undecodable_partition_payload_when_sent_should_fail_before_sending() {
let transport = Recorder::default();
- for (code, payload) in [(POLL_MESSAGES_CODE, Bytes::from_static(b"lost-poll"))] {
- let result = transport.send_raw_with_response(code, payload).await;
- assert_eq!(result, Err(IggyError::InvalidCommand), "code {code}");
- }
+
+ let result = transport
+ .send_raw_with_response(POLL_MESSAGES_CODE, Bytes::from_static(b"lost-poll"))
+ .await;
+
+ assert_eq!(result, Err(IggyError::InvalidCommand));
assert!(transport.take_calls().is_empty());
}
+
+ #[tokio::test]
+ async fn given_nested_clients_when_capturing_lineage_should_preserve_each_owner() {
+ let outer = BinaryOperationLineage::capture(1, 10);
+ with_binary_lineage(Some(outer), async {
+ assert_eq!(BinaryOperationLineage::capture(1, 11), outer);
+ let inner = BinaryOperationLineage::capture(2, 20);
+ assert_ne!(inner, outer);
+ with_binary_lineage(Some(inner), async {
+ assert_eq!(BinaryOperationLineage::capture(2, 21), inner);
+ })
+ .await;
+ assert_eq!(BinaryOperationLineage::capture(1, 12), outer);
+ })
+ .await;
+ assert!(binary_operation_lineage().is_none());
+ }
}
diff --git a/core/common/src/traits/mod.rs b/core/common/src/traits/mod.rs
index b18529ec5..72a2cc27d 100644
--- a/core/common/src/traits/mod.rs
+++ b/core/common/src/traits/mod.rs
@@ -36,4 +36,4 @@ pub(crate) mod user_client;
pub(crate) mod validatable;
pub use binary_impls::{BinarySendPartitioning, decode_send_confirmations, send_binary_messages};
-pub use binary_impls::{rust_sdk_version_info, sign_in_request};
+pub use binary_impls::{binary_sign_in, rust_sdk_version_info, sign_in_request};
diff --git a/core/common/src/types/identifier/mod.rs b/core/common/src/types/identifier/mod.rs
index 8cf8e33a5..cce573787 100644
--- a/core/common/src/types/identifier/mod.rs
+++ b/core/common/src/types/identifier/mod.rs
@@ -19,13 +19,11 @@ use crate::Sizeable;
use crate::Validatable;
use crate::error::IggyError;
use crate::utils::byte_size::IggyByteSize;
-use iggy_binary_protocol::WireIdentifier;
use serde::{Deserialize, Serialize};
use serde_with::base64::Base64;
use serde_with::serde_as;
use std::borrow::Cow;
use std::fmt::Display;
-use std::hash::{Hash, Hasher};
use std::str::FromStr;
/// `Identifier` represents the unique identifier of the resources such as stream, topic, partition, user etc.
@@ -34,7 +32,7 @@ use std::str::FromStr;
/// - `length`: the length of the identifier payload.
/// - `value`: the binary value of the identifier payload.
#[serde_as]
-#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq)]
+#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq, Hash)]
pub struct Identifier {
/// The kind of the identifier.
pub kind: IdKind,
@@ -47,7 +45,7 @@ pub struct Identifier {
}
/// `IdKind` represents the kind of the identifier.
-#[derive(Debug, Serialize, Deserialize, PartialEq, Default, Copy, Clone, Eq)]
+#[derive(Debug, Serialize, Deserialize, PartialEq, Default, Copy, Clone, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum IdKind {
/// The identifier is numeric.
@@ -101,7 +99,9 @@ impl Identifier {
return Err(IggyError::InvalidIdentifier);
}
- Ok(u32::from_le_bytes(self.value.clone().try_into().unwrap()))
+ <[u8; 4]>::try_from(self.value.as_slice())
+ .map(u32::from_le_bytes)
+ .map_err(|_| IggyError::InvalidIdentifier)
}
/// Returns the string value of the identifier.
@@ -260,17 +260,6 @@ impl TryFrom<u32> for Identifier {
}
}
-impl TryFrom<&WireIdentifier> for Identifier {
- type Error = IggyError;
-
- fn try_from(value: &WireIdentifier) -> Result<Self, Self::Error> {
- match value {
- WireIdentifier::Numeric(id) => Self::numeric(*id),
- WireIdentifier::String(name) => Self::named(name),
- }
- }
-}
-
impl TryFrom<String> for Identifier {
type Error = IggyError;
fn try_from(value: String) -> Result<Self, Self::Error> {
@@ -307,22 +296,59 @@ impl Display for IdKind {
}
}
-impl Hash for Identifier {
- fn hash<H: Hasher>(&self, state: &mut H) {
- match self.kind {
- IdKind::Numeric => {
- self.get_u32_value().unwrap().hash(state);
- }
- IdKind::String => {
- self.get_cow_str_value().unwrap().hash(state);
- }
- }
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
+ use std::collections::HashMap;
+ use std::hash::{BuildHasher, RandomState};
+
+ #[test]
+ fn equal_identifiers_should_hash_equally() {
+ let state = RandomState::new();
+ let named = Identifier::named("orders").unwrap();
+ let mut named_bytes = vec![named.kind.as_code(), named.length];
+ named_bytes.extend_from_slice(&named.value);
+ for (left, right) in [
+ (
+ Identifier::numeric(7).unwrap(),
+ Identifier::from_str("7").unwrap(),
+ ),
+ (named, Identifier::from_raw_bytes(&named_bytes).unwrap()),
+ ] {
+ assert_eq!(left, right);
+ assert_eq!(state.hash_one(&left), state.hash_one(&right));
+ }
+ }
+
+ #[test]
+ fn malformed_identifier_should_miss_a_map_without_panicking() {
+ let id = Identifier::numeric(7).unwrap();
+ let map = HashMap::from([(id.clone(), ())]);
+ // serde skips the length, so it reads back as 0.
+ let read_back: Identifier =
+ serde_json::from_str(&serde_json::to_string(&id).unwrap()).unwrap();
+ let short = Identifier {
+ kind: IdKind::Numeric,
+ length: 4,
+ value: vec![7, 0, 0],
+ };
+ for malformed in [read_back, short] {
+ assert!(malformed.validate().is_err(), "{malformed:?}");
+ assert!(!map.contains_key(&malformed), "{malformed:?}");
+ }
+ }
+
+ #[test]
+ fn numeric_identifier_with_a_value_of_another_size_should_fail_to_read_without_panicking() {
+ let short = Identifier {
+ kind: IdKind::Numeric,
+ length: 4,
+ value: vec![7, 0, 0],
+ };
+
+ assert_eq!(short.get_u32_value(), Err(IggyError::InvalidIdentifier));
+ assert_eq!(Identifier::numeric(7).unwrap().get_u32_value(), Ok(7));
+ }
#[test]
fn identifier_with_a_value_of_greater_than_zero_should_be_valid() {
diff --git a/core/common/src/wire_conversions.rs b/core/common/src/wire_conversions.rs
index 52fa6f39a..c5d0f9353 100644
--- a/core/common/src/wire_conversions.rs
+++ b/core/common/src/wire_conversions.rs
@@ -25,11 +25,11 @@ use crate::{
CacheMetrics, CacheMetricsKey, ClientInfo, ClientInfoDetails, ClusterMetadata, ClusterNode,
ClusterNodeRole, ClusterNodeStatus, CompressionAlgorithm, Consumer, ConsumerGroup,
ConsumerGroupDetails, ConsumerGroupInfo, ConsumerGroupMember, ConsumerOffsetInfo,
- GlobalPermissions, HeaderKey, HeaderKind, HeaderValue, IdKind, IdentityInfo, IggyByteSize,
- IggyError, IggyExpiry, MaxTopicSize, OptionSpec, OptionValue, OptionsProvenance, Partition,
- Permissions, PersonalAccessTokenInfo, RawPersonalAccessToken, ResourceOptions, Stats, Stream,
- StreamDetails, StreamPermissions, Topic, TopicDetails, TopicPermissions, TransportEndpoints,
- UserInfo, UserInfoDetails, UserStatus,
+ GlobalPermissions, HeaderKey, HeaderKind, HeaderValue, IdKind, Identifier, IdentityInfo,
+ IggyByteSize, IggyError, IggyExpiry, MaxTopicSize, OptionSpec, OptionValue, OptionsProvenance,
+ Partition, Permissions, PersonalAccessTokenInfo, RawPersonalAccessToken, ResourceOptions,
+ Stats, Stream, StreamDetails, StreamPermissions, Topic, TopicDetails, TopicPermissions,
+ TransportEndpoints, UserInfo, UserInfoDetails, UserStatus,
};
use bytes::{BufMut, BytesMut};
use iggy_binary_protocol::primitives::options::{MAX_OPTIONS, MAX_OPTIONS_BYTES, WireOptions};
@@ -63,7 +63,7 @@ use iggy_binary_protocol::responses::topics::get_topics::GetTopicsResponse;
use iggy_binary_protocol::responses::users::login_user::IdentityResponse;
use iggy_binary_protocol::responses::users::user_response::UserResponse;
use iggy_binary_protocol::responses::users::{GetUsersResponse, UserDetailsResponse};
-use iggy_binary_protocol::{WireConsumer, WireUserHeaderIterator};
+use iggy_binary_protocol::{WireConsumer, WireIdentifier, WireUserHeaderIterator};
use std::collections::{BTreeMap, HashMap};
/// Sentinel value in the wire protocol indicating no authenticated user.
@@ -543,6 +543,21 @@ pub fn cluster_metadata_from_wire(
ClusterMetadata::try_from(w)
}
+// ---------------------------------------------------------------------------
+// Identifier (wire -> domain)
+// ---------------------------------------------------------------------------
+
+impl TryFrom<&WireIdentifier> for Identifier {
+ type Error = IggyError;
+
+ fn try_from(value: &WireIdentifier) -> Result<Self, Self::Error> {
+ match value {
+ WireIdentifier::Numeric(id) => Self::numeric(*id),
+ WireIdentifier::String(name) => Self::named(name),
+ }
+ }
+}
+
// ---------------------------------------------------------------------------
// Identifier (domain -> wire)
// ---------------------------------------------------------------------------
diff --git a/core/configs/src/server_config/cluster.rs b/core/configs/src/server_config/cluster.rs
index ae773577c..280d1eb74 100644
--- a/core/configs/src/server_config/cluster.rs
+++ b/core/configs/src/server_config/cluster.rs
@@ -852,20 +852,31 @@ impl fmt::Display for AdvertisedAddressError {
impl std::error::Error for AdvertisedAddressError {}
-/// Whether cluster-wide JWT key material exists: a configured `http.jwt`
-/// secret, or the signing key derived from the cluster PSK. When it does, a
-/// bearer minted on any node verifies on every node - the invariant
-/// follower-to-primary HTTP forwarding depends on. Callers gate `http.enabled`
-/// themselves; this covers only the key material.
+/// The cluster-wide JWT key material, when it exists: a configured `http.jwt`
+/// secret, else the cluster PSK the signing key derives from - the precedence
+/// the JWT manager applies. With it, a bearer minted on any node verifies on
+/// every node - the invariant follower-to-primary HTTP forwarding depends on.
+/// The key that proves a relay comes from a node derives from the same
+/// material, so relay proofs verify exactly where bearers do. Callers gate
+/// `http.enabled` themselves; this covers only the key material.
///
/// Forwarding targets resolve from the roster (`ip:ports.http`); the config
/// validator unconditionally requires a roster port for every enabled
/// transport, so a forward never dials a node without a declared http port.
-pub fn http_forwarding_key_material(jwt: &HttpJwtConfig, cluster: &ClusterConfig) -> bool {
- cluster.enabled
- && ((cluster.auth.enabled && !cluster.auth.shared_secret.is_empty())
- || !jwt.encoding_secret.is_empty()
- || !jwt.decoding_secret.is_empty())
+pub fn http_forwarding_key_material<'a>(
+ jwt: &'a HttpJwtConfig,
+ cluster: &'a ClusterConfig,
+) -> Option<&'a str> {
+ if !cluster.enabled {
+ return None;
+ }
+ let psk = (cluster.auth.enabled && !cluster.auth.shared_secret.is_empty())
+ .then_some(&cluster.auth.shared_secret);
+ [&jwt.encoding_secret, &jwt.decoding_secret]
+ .into_iter()
+ .find(|secret| !secret.is_empty())
+ .or(psk)
+ .map(String::as_str)
}
impl Validatable<ConfigurationError> for ClusterConfig {
@@ -1947,6 +1958,40 @@ mod cluster_validate_tests {
}
}
+ /// Every node must pick the same material, or nodes refuse each other's
+ /// relay proofs: the secret the JWT manager signs with, else the PSK.
+ #[test]
+ fn given_jwt_secrets_and_a_psk_when_choosing_key_material_should_follow_the_jwt_signing_secret()
+ {
+ let jwt = |encoding: &str, decoding: &str| HttpJwtConfig {
+ encoding_secret: encoding.to_owned(),
+ decoding_secret: decoding.to_owned(),
+ ..HttpJwtConfig::default()
+ };
+ let mut cluster = cfg(Vec::new());
+ cluster.auth = ClusterAuthConfig {
+ enabled: true,
+ shared_secret: "psk".to_owned(),
+ previous_shared_secret: String::new(),
+ };
+ let material = |jwt: &HttpJwtConfig, cluster: &ClusterConfig| {
+ http_forwarding_key_material(jwt, cluster).map(str::to_owned)
+ };
+ assert_eq!(
+ material(&jwt("enc", "dec"), &cluster).as_deref(),
+ Some("enc")
+ );
+ assert_eq!(material(&jwt("", "dec"), &cluster).as_deref(), Some("dec"));
+ assert_eq!(material(&jwt("", ""), &cluster).as_deref(), Some("psk"));
+
+ cluster.auth.enabled = false;
+ assert_eq!(material(&jwt("", ""), &cluster), None);
+ assert_eq!(material(&jwt("enc", ""), &cluster).as_deref(), Some("enc"));
+
+ cluster.enabled = false;
+ assert_eq!(material(&jwt("enc", "dec"), &cluster), None);
+ }
+
#[test]
fn validate_rejects_sub_minimum_heartbeat_timeout() {
let mut c = cfg(vec![node("n1", 0), node("n2", 1)]);
diff --git a/core/configs/src/server_config/metadata.rs b/core/configs/src/server_config/metadata.rs
index 1fad9c91f..651e4fd27 100644
--- a/core/configs/src/server_config/metadata.rs
+++ b/core/configs/src/server_config/metadata.rs
@@ -122,9 +122,8 @@ pub struct MetadataConfig {
/// `PartitionsLimitReached` before it enters consensus. Zero is no cap.
///
/// The metadata primary checks it with its own value, so every node needs
- /// the same value. A soft cap: the check counts committed partitions only.
- /// Up to 3 x `prepare_queue_depth` creates can be in flight, with up to
- /// 1000 partitions each, and each one passes the check on its own.
+ /// the same value. The check counts the committed partitions and those of
+ /// the creates still in flight, so creates never take the count past it.
#[serde(default)]
pub partitions_max: u32,
}
diff --git a/core/connectors/runtime/src/main.rs b/core/connectors/runtime/src/main.rs
index a2cb46b91..b4c92d14a 100644
--- a/core/connectors/runtime/src/main.rs
+++ b/core/connectors/runtime/src/main.rs
@@ -46,7 +46,6 @@ use state::StateStorage;
use std::{
collections::HashMap,
env,
- num::NonZeroU32,
sync::{Arc, atomic::AtomicU32},
};
use system_stats::capture_allowed_cpus;
@@ -584,7 +583,6 @@ struct SourceConnectorPlugin {
struct SourceConnectorProducer {
encoder: Arc<dyn StreamEncoder>,
producer: IggyProducer,
- batch_length: NonZeroU32,
}
struct SourceConnectorWrapper {
diff --git a/core/connectors/runtime/src/manager/source.rs b/core/connectors/runtime/src/manager/source.rs
index eb8e8d07e..db24df245 100644
--- a/core/connectors/runtime/src/manager/source.rs
+++ b/core/connectors/runtime/src/manager/source.rs
@@ -266,7 +266,7 @@ impl SourceManager {
let instance_guard =
source::SourceInstanceGuard::for_container(container.clone(), plugin_id, key);
- let (producer, batch_length, encoder, transforms) =
+ let (producer, transforms) =
match source::setup_source_producer(key, config, iggy_client).await {
Ok(parts) => parts,
Err(error) => {
@@ -304,8 +304,6 @@ impl SourceManager {
config.verbose,
config.benchmark,
producer,
- batch_length,
- encoder,
transforms,
state_storage,
handle_callback,
diff --git a/core/connectors/runtime/src/source.rs b/core/connectors/runtime/src/source.rs
index 1eb90b33c..f6ab81a11 100644
--- a/core/connectors/runtime/src/source.rs
+++ b/core/connectors/runtime/src/source.rs
@@ -18,10 +18,9 @@
use dashmap::DashMap;
use dlopen2::wrapper::Container;
use flume::{Receiver, Sender};
-use iggy::clients::MAX_BATCH_LENGTH;
use iggy::prelude::{
Client, DirectConfig, HeaderKey, HeaderValue, Identifier, IggyClient, IggyDuration, IggyError,
- IggyMessage, IggyProducer, StreamClient, TopicClient, TopicCreateOptions,
+ IggyMessage, StreamClient, TopicClient, TopicCreateOptions,
};
use iggy_common::{BINARY_OPERATION_TIMEOUT, Durability, TopicRuntimeOptions};
use iggy_connector_sdk::encoders::avro::{AvroEncoderConfig, AvroStreamEncoder};
@@ -37,7 +36,6 @@ use iggy_connector_sdk::{
use std::{
collections::{BTreeMap, HashMap},
future::Future,
- num::NonZeroU32,
str::FromStr,
sync::{Arc, LazyLock, atomic::Ordering},
time::Instant,
@@ -257,7 +255,7 @@ pub async fn init(
};
match setup_source_producer(&key, &config, iggy_client).await {
- Ok((producer, batch_length, encoder, transforms)) => {
+ Ok((producer, transforms)) => {
let connector = source_connectors
.get_mut(&path)
.expect("source connector was inserted above");
@@ -266,11 +264,7 @@ pub async fn init(
.iter_mut()
.find(|plugin| plugin.id == plugin_id)
.expect("source plugin was pushed above");
- plugin.producer = Some(SourceConnectorProducer {
- encoder,
- producer,
- batch_length,
- });
+ plugin.producer = Some(producer);
plugin.transforms = transforms;
instance_guard.disarm();
info!(
@@ -444,21 +438,12 @@ impl Drop for SourceInstanceGuard {
}
}
-/// Returns the last stream's producer, the batch length that splits its
-/// sends into requests, its encoder and the transforms.
+/// Returns the last stream's producer with its encoder, and the transforms.
pub(crate) async fn setup_source_producer(
key: &str,
config: &SourceConfig,
iggy_client: &IggyClient,
-) -> Result<
- (
- IggyProducer,
- NonZeroU32,
- Arc<dyn StreamEncoder>,
- Vec<Arc<dyn Transform>>,
- ),
- RuntimeError,
-> {
+) -> Result<(SourceConnectorProducer, Vec<Arc<dyn Transform>>), RuntimeError> {
let transforms = if let Some(transforms_config) = &config.transforms {
let loaded = transform::load(transforms_config).map_err(|error| {
RuntimeError::InvalidConfiguration(format!("Failed to load transforms: {error}"))
@@ -479,10 +464,7 @@ pub(crate) async fn setup_source_producer(
RuntimeError::InvalidConfiguration(format!("Invalid linger time: {error}"))
})?;
ensure_durable_source_topic(iggy_client, &stream.stream, &stream.topic).await?;
- // Passed explicitly for 0 as well, so the producer splits a send
- // exactly where `send_with_failed_tail_retries` expects.
- let batch_length =
- NonZeroU32::new(stream.batch_length.unwrap_or(1000)).unwrap_or(MAX_BATCH_LENGTH);
+ let batch_length = stream.batch_length.unwrap_or(1000);
let producer = iggy_client
.producer(&stream.stream, &stream.topic)?
// The topic was validated above. If it disappears before init,
@@ -491,7 +473,7 @@ pub(crate) async fn setup_source_producer(
.do_not_create_topic_if_not_exists()
.direct(
DirectConfig::builder()
- .batch_length(batch_length.get())
+ .batch_length(batch_length)
.linger_time(linger_time)
.build(),
)
@@ -514,17 +496,17 @@ pub(crate) async fn setup_source_producer(
other => other.encoder(),
};
last_encoder = Some(encoder);
- last_producer = Some((producer, batch_length));
+ last_producer = Some(producer);
}
- let (producer, batch_length) = last_producer.ok_or_else(|| {
+ let producer = last_producer.ok_or_else(|| {
RuntimeError::InvalidConfiguration("No streams configured for source".to_string())
})?;
let encoder = last_encoder.ok_or_else(|| {
RuntimeError::InvalidConfiguration("No encoder configured for source".to_string())
})?;
- Ok((producer, batch_length, encoder, transforms))
+ Ok((SourceConnectorProducer { encoder, producer }, transforms))
}
async fn ensure_durable_source_topic(
@@ -584,9 +566,7 @@ pub(crate) async fn source_forwarding_loop(
plugin_key: String,
verbose: bool,
benchmark: bool,
- producer: IggyProducer,
- batch_length: NonZeroU32,
- encoder: Arc<dyn StreamEncoder>,
+ producer: SourceConnectorProducer,
transforms: Vec<Arc<dyn Transform>>,
state_storage: StateStorage,
receiver: Receiver<ProducedBatch>,
@@ -595,6 +575,7 @@ pub(crate) async fn source_forwarding_loop(
context: Arc<RuntimeContext>,
labels: Arc<SourceLabels>,
) {
+ let SourceConnectorProducer { encoder, producer } = producer;
info!("Source connector with ID: {plugin_id} started.");
if benchmark {
info!(
@@ -708,7 +689,6 @@ pub(crate) async fn source_forwarding_loop(
} else if processing_errors == 0 {
send_with_failed_tail_retries(
processed.messages,
- batch_length,
plugin_id,
&context.iggy_clients.producer,
|messages| producer.send(messages),
@@ -912,9 +892,7 @@ pub(crate) fn spawn_source_handler(
plugin_key: &str,
verbose: bool,
benchmark: bool,
- producer: IggyProducer,
- batch_length: NonZeroU32,
- encoder: Arc<dyn StreamEncoder>,
+ producer: SourceConnectorProducer,
transforms: Vec<Arc<dyn Transform>>,
state_storage: StateStorage,
handle_callback: HandleCallback,
@@ -945,8 +923,6 @@ pub(crate) fn spawn_source_handler(
verbose,
benchmark,
producer,
- batch_length,
- encoder,
transforms,
state_storage,
receiver,
@@ -1005,7 +981,7 @@ pub fn handle(
}
info!("Starting handler for source connector with ID: {plugin_id}...");
- let Some(producer_wrapper) = plugin.producer else {
+ let Some(producer) = plugin.producer else {
error!("Producer not initialized for source connector with ID: {plugin_id}");
continue;
};
@@ -1015,9 +991,7 @@ pub fn handle(
&plugin_key,
plugin.verbose,
plugin.benchmark,
- producer_wrapper.producer,
- producer_wrapper.batch_length,
- producer_wrapper.encoder,
+ producer,
plugin.transforms,
plugin.state_storage,
source.handle_callback,
@@ -1114,9 +1088,16 @@ fn process_messages(
}
}
+/// Sends `messages`, then resends only the failed tail of a partial failure, at most
+/// [`MAX_FAILED_TAIL_RETRIES`] times.
+///
+/// The caller NACKs the batch on any error returned here. The plugin can then poll the same
+/// messages again, and all of them are sent again. Chunks that committed before the error land
+/// twice, and so does an uncertain chunk that committed: delivery is at-least-once. At the retry
+/// limit the uncertain chunk is not resolved first, because its outcome would not change the
+/// NACK. The producer resolves it before its next send to that partition.
async fn send_with_failed_tail_retries<S, SendFut, T, R, ResolveFut>(
mut messages: Vec<IggyMessage>,
- batch_length: NonZeroU32,
plugin_id: u32,
client: &IggyClient,
mut send: S,
@@ -1135,6 +1116,7 @@ where
Err(IggyError::ProducerSendFailed {
cause,
failed,
+ uncertain,
committed,
stream_name,
topic_name,
@@ -1158,6 +1140,7 @@ where
return Err(IggyError::ProducerSendFailed {
cause,
failed,
+ uncertain,
committed,
stream_name,
topic_name,
@@ -1170,18 +1153,19 @@ where
return Err(IggyError::ProducerSendFailed {
cause,
failed,
+ uncertain,
committed,
stream_name,
topic_name,
});
}
};
- // The tail starts with the chunk that `op` sent. A resend under
- // a new request id would append it twice if it committed, so
- // only a proved commit lets the rest go out.
+ // The tail starts with the `uncertain` messages that `op` sent. A
+ // resend under a new request id would append them twice if they
+ // committed, so only a proved commit lets the rest go out.
if let IggyError::OperationUncertain(op) = *cause {
resolve(op).await?;
- messages.drain(..messages.len().min(batch_length.get() as usize));
+ messages.drain(..uncertain.min(messages.len()));
if messages.is_empty() {
return Ok(());
}
@@ -1321,7 +1305,7 @@ mod tests {
plugin_id: u32,
plugin_key: &str,
directory: &std::path::Path,
- ) -> (Arc<RuntimeContext>, StateStorage, IggyProducer) {
+ ) -> (Arc<RuntimeContext>, StateStorage, SourceConnectorProducer) {
let config_provider =
create_connectors_config_provider(&ConnectorsConfig::Local(LocalConnectorsConfig {
config_dir: directory.display().to_string(),
@@ -1332,10 +1316,13 @@ mod tests {
let state_storage = state_factory
.storage_for(plugin_key)
.expect("file state storage should initialize");
- let producer = IggyClient::default()
- .producer("stream", "topic")
- .expect("producer builder should initialize")
- .build();
+ let producer = SourceConnectorProducer {
+ encoder: Schema::Raw.encoder(),
+ producer: IggyClient::default()
+ .producer("stream", "topic")
+ .expect("producer builder should initialize")
+ .build(),
+ };
let context = Arc::new(RuntimeContext {
sinks: SinkManager::new(vec![]),
sources: SourceManager::new(vec![SourceDetails {
@@ -1387,8 +1374,6 @@ mod tests {
false,
false,
producer,
- NonZeroU32::MIN,
- Schema::Raw.encoder(),
vec![],
state_storage,
reject_source_handle_untracked,
@@ -1442,18 +1427,23 @@ mod tests {
IggyError::ProducerSendFailed {
cause: Box::new(IggyError::Error),
failed: Arc::new(messages),
+ uncertain: 0,
committed: Arc::new(Vec::new()),
- stream_name: "test-stream".to_string(),
- topic_name: "test-topic".to_string(),
+ stream_name: "test-stream".into(),
+ topic_name: "test-topic".into(),
}
}
- /// A send whose first chunk of `messages` was operation `op` and may
+ /// A send whose first `chunk_length` messages were operation `op` and may
/// have committed.
- fn uncertain_send(op: u64, messages: Vec<IggyMessage>) -> IggyError {
+ fn uncertain_send(op: u64, chunk_length: usize, messages: Vec<IggyMessage>) -> IggyError {
let mut failure = failed_send(messages);
- if let IggyError::ProducerSendFailed { cause, .. } = &mut failure {
+ if let IggyError::ProducerSendFailed {
+ cause, uncertain, ..
+ } = &mut failure
+ {
**cause = IggyError::OperationUncertain(op);
+ *uncertain = chunk_length;
}
failure
}
@@ -1813,8 +1803,6 @@ mod tests {
false,
false,
producer,
- NonZeroU32::MIN,
- Schema::Raw.encoder(),
vec![],
state_storage,
receiver,
@@ -1907,8 +1895,6 @@ mod tests {
false,
false,
producer,
- NonZeroU32::MIN,
- Schema::Raw.encoder(),
vec![],
state_storage,
receiver,
@@ -2043,7 +2029,6 @@ mod tests {
let result = send_with_failed_tail_retries(
vec![test_message(1), test_message(2), test_message(3)],
- NonZeroU32::MIN,
31,
&IggyClient::default(),
|messages| {
@@ -2077,7 +2062,6 @@ mod tests {
let result = send_with_failed_tail_retries(
vec![test_message(1)],
- NonZeroU32::MIN,
37,
&IggyClient::default(),
|_| {
@@ -2102,15 +2086,15 @@ mod tests {
let mut response = Some(Err::<(), _>(IggyError::ProducerSendFailed {
cause: Box::new(IggyError::Error),
failed,
+ uncertain: 0,
committed: Arc::new(Vec::new()),
- stream_name: "test-stream".to_string(),
- topic_name: "test-topic".to_string(),
+ stream_name: "test-stream".into(),
+ topic_name: "test-topic".into(),
}));
let mut attempts = 0;
let result = send_with_failed_tail_retries(
vec![test_message(1), test_message(2)],
- NonZeroU32::MIN,
41,
&IggyClient::default(),
|_| {
@@ -2144,7 +2128,6 @@ mod tests {
let result = send_with_failed_tail_retries(
vec![test_message(1), test_message(2), test_message(3)],
- NonZeroU32::MIN,
43,
&client,
|_| {
@@ -2161,6 +2144,7 @@ mod tests {
committed,
stream_name,
topic_name,
+ ..
}) = result
else {
panic!("reconnect failure must preserve the producer failure");
@@ -2174,8 +2158,8 @@ mod tests {
[2, 3]
);
assert!(committed.is_empty());
- assert_eq!(stream_name, "test-stream");
- assert_eq!(topic_name, "test-topic");
+ assert_eq!(stream_name.as_ref(), "test-stream");
+ assert_eq!(topic_name.as_ref(), "test-topic");
assert_eq!(attempts, 1, "reconnect failure must not resubmit the tail");
}
});
@@ -2194,7 +2178,6 @@ mod tests {
let mut attempts = Vec::new();
let result = send_with_failed_tail_retries(
vec![test_message(1), test_message(2), test_message(3)],
- NonZeroU32::MIN,
47,
&IggyClient::default(),
|messages| {
@@ -2227,7 +2210,6 @@ mod tests {
let mut resolved = Vec::new();
let result = send_with_failed_tail_retries(
vec![test_message(1), test_message(2), test_message(3)],
- NonZeroU32::MIN,
47,
&IggyClient::default(),
|messages| {
@@ -2251,14 +2233,23 @@ mod tests {
fn given_uncertain_chunk_when_resolve_proves_it_should_resend_only_the_rest() {
let runtime = tokio::runtime::Runtime::new().expect("failed to create test runtime");
runtime.block_on(async {
- let failed = vec![test_message(1), test_message(2), test_message(3)];
- let mut responses = VecDeque::from([Err(uncertain_send(7, failed)), Ok(())]);
+ let failed = vec![
+ test_message(1),
+ test_message(2),
+ test_message(3),
+ test_message(4),
+ ];
+ let mut responses = VecDeque::from([Err(uncertain_send(7, 2, failed)), Ok(())]);
let mut attempts = Vec::new();
let mut resolved = Vec::new();
let result = send_with_failed_tail_retries(
- vec![test_message(1), test_message(2), test_message(3)],
- NonZeroU32::MIN,
+ vec![
+ test_message(1),
+ test_message(2),
+ test_message(3),
+ test_message(4),
+ ],
47,
&IggyClient::default(),
|messages| {
@@ -2273,7 +2264,7 @@ mod tests {
.await;
assert_eq!(result, Ok(()));
- assert_eq!(attempts, [vec![1, 2, 3], vec![2, 3]]);
+ assert_eq!(attempts, [vec![1, 2, 3, 4], vec![3, 4]]);
assert_eq!(resolved, [7]);
});
}
@@ -2283,12 +2274,11 @@ mod tests {
let runtime = tokio::runtime::Runtime::new().expect("failed to create test runtime");
runtime.block_on(async {
let failed = vec![test_message(1), test_message(2), test_message(3)];
- let mut responses = VecDeque::from([Err(uncertain_send(7, failed)), Ok(())]);
+ let mut responses = VecDeque::from([Err(uncertain_send(7, 1, failed)), Ok(())]);
let mut attempts = Vec::new();
let result = send_with_failed_tail_retries(
vec![test_message(1), test_message(2), test_message(3)],
- NonZeroU32::MIN,
53,
&IggyClient::default(),
|messages| {
@@ -2309,12 +2299,11 @@ mod tests {
let runtime = tokio::runtime::Runtime::new().expect("failed to create test runtime");
runtime.block_on(async {
let failed = vec![test_message(1), test_message(2), test_message(3)];
- let mut responses = VecDeque::from([Err(uncertain_send(7, failed)), Ok(())]);
+ let mut responses = VecDeque::from([Err(uncertain_send(7, 3, failed)), Ok(())]);
let mut attempts = Vec::new();
let result = send_with_failed_tail_retries(
vec![test_message(1), test_message(2), test_message(3)],
- NonZeroU32::new(1000).expect("batch length is not zero"),
59,
&IggyClient::default(),
|messages| {
diff --git a/core/consensus/src/client_table.rs b/core/consensus/src/client_table.rs
index 8d249f59e..39a569a47 100644
--- a/core/consensus/src/client_table.rs
+++ b/core/consensus/src/client_table.rs
@@ -427,12 +427,16 @@ enum SliceRequestStatus {
/// Result of retaining a committed metadata receipt.
///
-/// Real client operations fail closed on a non-`Cached` outcome;
+/// Real client operations fail closed on `NoEntry` and `SkippedRegression`;
/// server-originated operations have no registered entry or receipt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommitReply {
/// Reply cached and the watermark advanced (or refreshed in place).
Cached,
+ /// The op committed after its session ended at `ended_op`. Nothing is
+ /// retained, so its outcome stays uncertain and the end marker still
+ /// bounds the latest receipt.
+ SessionEnded { ended_op: u64 },
/// No registered entry, including for server-originated operations.
NoEntry,
/// A replayed op is older than the restored protection frontier.
@@ -1074,7 +1078,8 @@ impl ClientTable {
/// Retain a committed metadata result before advancing the applied frontier.
/// Missing ownership or regressing protection is a commit failure.
- /// Server-originated operations return [`CommitReply::NoEntry`].
+ /// Server-originated operations return [`CommitReply::NoEntry`]. An op
+ /// committed after its session ended returns [`CommitReply::SessionEnded`].
/// # Panics
/// If the client id differs from the reply or the private index is inconsistent.
pub fn commit_reply(
@@ -1121,6 +1126,12 @@ impl ClientTable {
received: new_request,
};
}
+ // A request admitted before its session ended can commit after the end.
+ // Decoders refuse a receipt above the end marker, so that op stays
+ // uncertain. The op that ended the session commits at the marker.
+ if let Some(ended_op) = entry.ended_op.filter(|&ended_op| new_commit > ended_op) {
+ return CommitReply::SessionEnded { ended_op };
+ }
// Freeze once; later dedup-hit clones Arc-bump.
let cached = CachedReply::from(reply);
@@ -2285,6 +2296,80 @@ mod tests {
}
}
+ /// A request admitted before its user's revocation committed commits
+ /// right behind it. Its session already ended, so the op stays uncertain.
+ #[test]
+ fn given_revoked_user_when_a_pipelined_request_commits_should_keep_the_tombstone() {
+ const REVOKER: u128 = 7;
+ const PIPELINED: u128 = 8;
+ const REVOKER_EPOCH: u64 = 10;
+ const PIPELINED_EPOCH: u64 = 11;
+ const END: u64 = 40;
+ let secret = [0x5a; BIND_SECRET_BYTES];
+ for self_revocation in [
+ None,
+ Some(Operation::UpdateUser),
+ Some(Operation::DeleteUser),
+ ] {
+ let mut table = ClientTable::new(2);
+ for (client, epoch) in [(REVOKER, REVOKER_EPOCH), (PIPELINED, PIPELINED_EPOCH)] {
+ table
+ .commit_register(
+ client,
+ TEST_USER_ID,
+ bind_verifier(client, TEST_USER_ID, &secret),
+ make_register_reply(client, epoch),
+ )
+ .unwrap();
+ }
+ let ended = table.end_user_sessions(TEST_USER_ID, END);
+ if let Some(operation) = self_revocation {
+ let revocation = make_reply_for(REVOKER, 1, END).transmute_header(|old, header| {
+ *header = old;
+ header.operation = operation;
+ });
+ assert_eq!(
+ table.commit_reply(REVOKER, TEST_USER_ID, revocation),
+ CommitReply::Cached,
+ "the revoking op commits at the end marker itself"
+ );
+ }
+ let at_end = table.encode();
+ assert_eq!(
+ table.commit_reply(
+ PIPELINED,
+ TEST_USER_ID,
+ make_reply_for(PIPELINED, 1, END + 1)
+ ),
+ CommitReply::SessionEnded { ended_op: END },
+ "{self_revocation:?}"
+ );
+ let transferred = table.encode();
+ assert!(
+ transferred == at_end,
+ "{self_revocation:?}: an op after the end must leave the table unchanged"
+ );
+ let revoker_receipt = if self_revocation.is_some() {
+ END
+ } else {
+ REVOKER_EPOCH
+ };
+ for restored in [
+ ClientTable::from_snapshot(table.to_snapshot()).expect("checkpoint must restore"),
+ ClientTable::decode(&transferred).expect("state transfer must decode"),
+ ] {
+ assert_eq!(restored.ended_sessions().collect::<Vec<_>>(), ended);
+ assert_eq!(
+ restored
+ .get_reply(REVOKER)
+ .map(|receipt| receipt.header().commit),
+ Some(revoker_receipt)
+ );
+ assert_eq!(restored.get_watermark(PIPELINED), Some(REGISTER_REQUEST_ID));
+ }
+ }
+ }
+
/// Register client 1 (register commit stamped at op 10). Returns
/// (table, epoch=1).
fn table_with_client() -> (ClientTable, u64) {
diff --git a/core/consensus/src/dvc_merge.rs b/core/consensus/src/dvc_merge.rs
index 0f8dfca4d..adc35846c 100644
--- a/core/consensus/src/dvc_merge.rs
+++ b/core/consensus/src/dvc_merge.rs
@@ -144,9 +144,13 @@ struct CanonicalAt<'a> {
header: Option<&'a PrepareHeader>,
/// Two senders at the canonical `log_view` disagree here.
conflict: bool,
+ /// A sender at the canonical `log_view` reported the op blank without a nack:
+ /// it cannot describe the op, which is no proof that it never prepared it.
+ abstained: bool,
}
-/// The canonical header at `op`, and whether the canonical senders agree.
+/// The canonical header at `op`, whether the canonical senders agree, and whether
+/// one of them abstained.
///
/// Every canonical sender is consulted, not just the first: they were all in
/// normal status in that view and a primary prepares one thing per op, so a
@@ -155,11 +159,13 @@ struct CanonicalAt<'a> {
fn canonical_header_at<'a>(canonical_senders: &[&'a StoredDvc], op: u64) -> CanonicalAt<'a> {
let mut header: Option<&'a PrepareHeader> = None;
let mut conflict = false;
+ let mut abstained = false;
for dvc in canonical_senders {
let Some(index) = dvc.suffix.index_of(dvc.op, op) else {
continue;
};
let Some(candidate) = dvc.suffix.valid_header_at(index) else {
+ abstained |= !dvc.suffix.nacks(index);
continue;
};
match header {
@@ -168,7 +174,11 @@ fn canonical_header_at<'a>(canonical_senders: &[&'a StoredDvc], op: u64) -> Cano
None => header = Some(candidate),
}
}
- CanonicalAt { header, conflict }
+ CanonicalAt {
+ header,
+ conflict,
+ abstained,
+ }
}
/// Tally every sender's position on `op`.
@@ -181,6 +191,7 @@ fn tally_op<'a>(
let CanonicalAt {
header: canonical,
conflict,
+ abstained,
} = canonical_header_at(canonical_senders, op);
let mut copies = 0;
let mut nacks = 0;
@@ -244,7 +255,9 @@ fn tally_op<'a>(
}
// Implicit: no canonical sender holds anything here, so a newer
// view already truncated this op and the sender holds a corpse.
- None if may_nack_implicitly => nacks += 1,
+ // Not when a canonical sender abstained: it may hold this very
+ // prepare, and then the sender's copy is no corpse.
+ None if may_nack_implicitly && !abstained => nacks += 1,
_ => {}
}
}
@@ -796,6 +809,52 @@ mod tests {
);
}
+ #[test]
+ fn given_an_abstaining_canonical_sender_when_lower_senders_hold_the_op_should_not_count_them_as_nacks()
+ {
+ // Seven replicas: replication 4, nack 4. Replica 1 alone is at the newest
+ // log_view and reports op 5 blank without a nack: it holds the op but cannot
+ // vouch for it, as while it awaits a state transfer. Replicas 3 to 5 hold
+ // op 5 with a body and replica 6 only its header. Reading the three copies
+ // as corpses of a truncated op makes four nacks against an op that four
+ // replicas, replica 1 among them, may have acked.
+ let quorums = MergeQuorums {
+ view_change: 4,
+ nack_prepare: 4,
+ replica_count: 7,
+ prepare_queue_ceiling: 32,
+ };
+ let mut quorum = dvc_quorum_array_empty();
+ let mut abstaining = suffix_headers(4, 5, 7);
+ abstaining[0] = dvc_blank(5);
+ dvc_record(
+ &mut quorum,
+ dvc(1, 8, 5, 4, DvcSuffix::new(abstaining, 0, 0b10)),
+ );
+ for replica in 3..=5 {
+ dvc_record(
+ &mut quorum,
+ dvc(
+ replica,
+ 7,
+ 5,
+ 4,
+ suffix_all_present(suffix_headers(4, 5, 7)),
+ ),
+ );
+ }
+ dvc_record(
+ &mut quorum,
+ dvc(6, 7, 5, 4, DvcSuffix::new(suffix_headers(4, 5, 7), 0, 0b10)),
+ );
+
+ let outcome = merge_dvc_quorum(&quorum, quorums);
+ assert!(
+ matches!(outcome, MergeOutcome::AwaitingRepair { undecided_op: 5 }),
+ "op 5 waits for a replica that can describe it, got {outcome:?}"
+ );
+ }
+
#[test]
fn given_committed_header_on_a_stale_sender_should_be_installed_anyway() {
// Replica 0 is behind on log_view but reports op 2 committed. The canonical
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index fd96e580a..30bd7ec78 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -19,16 +19,17 @@ use crate::oneshot::{self, Receiver, Sender};
use crate::vsr_timeout::{TimeoutKind, TimeoutManager};
use crate::{
AckLogEvent, Consensus, ControlActionLogEvent, DvcHeaderKind, DvcQuorumArray, DvcSuffix,
- IgnoreReason, MergeOutcome, MergeQuorums, MergedLog, Pipeline, PlaneKind, PrepareLogEvent,
- Project, ReplicaLogContext, SimEventKind, StoredDvc, ViewChangeLogEvent, ViewChangeReason,
- VsrState, dvc_count, dvc_header_kind, dvc_iter, dvc_quorum_array_empty, dvc_record, dvc_reset,
- dvc_suffix_decode, emit_replica_event, emit_sim_event, merge_dvc_quorum, seal_prepare_checksum,
+ HistoryCertificate, HistoryRecovery, IgnoreReason, MergeOutcome, MergeQuorums, MergedLog,
+ Pipeline, PlaneKind, PrepareLogEvent, Project, RepairEvidence, ReplicaLogContext, SimEventKind,
+ StoredDvc, ViewChangeLogEvent, ViewChangeReason, VsrState, dvc_count, dvc_header_kind,
+ dvc_iter, dvc_quorum_array_empty, dvc_record, dvc_reset, dvc_suffix_decode, emit_replica_event,
+ emit_sim_event, merge_dvc_quorum, seal_prepare_checksum,
};
use bit_set::BitSet;
use clock::{Clock, IggySystemClock};
use iggy_binary_protocol::{
- Command, ConsensusHeader, DoViewChangeHeader, GenericHeader, Operation, PrepareHeader,
- PrepareOkHeader, ReplyHeader, RequestStartViewHeader, RoutedRequestHeader,
+ CHECKSUM_UNSEALED, Command, ConsensusHeader, DoViewChangeHeader, GenericHeader, Operation,
+ PrepareHeader, PrepareOkHeader, ReplyHeader, RequestStartViewHeader, RoutedRequestHeader,
StartViewChangeHeader, StartViewHeader, frame_body,
};
use iggy_common::IggyTimestamp;
@@ -685,11 +686,13 @@ impl LocalPipeline {
tail_header.op + 1,
header.op
);
+ // No view ordering: `view` is outside the prepare identity and a
+ // retransmit restamps it, so a chain rebuilt from the journal can step
+ // down a view between two linked ops.
assert_eq!(
header.parent, tail_header.checksum,
"parent must chain to previous checksum"
);
- assert!(header.view >= tail_header.view, "view cannot go backwards");
}
self.prepare_queue.push_back(entry);
@@ -1136,15 +1139,17 @@ pub enum VsrAction {
/// journal. Emitted during view change completion so the new primary
/// is fully caught up before accepting new requests.
CommitJournal,
- /// Primary heartbeat: send current commit point to all backups.
+ /// Send a commit point to `target`, or to every backup when `None`.
///
- /// Emitted when the `CommitMessage` timeout fires. Prevents backups
- /// from starting a view change during idle periods.
+ /// The `CommitMessage` heartbeat goes to every backup and keeps them from
+ /// starting a view change during idle periods. An eager `Commit` goes only
+ /// to a backup known to hold the op it announces.
SendCommit {
view: u32,
commit: u64,
group: u64,
timestamp_monotonic: u64,
+ target: Option<u8>,
},
}
@@ -1169,6 +1174,83 @@ impl PrepareOkOutcome {
}
}
+/// What the primary of the current view knows of each replica's log and has
+/// told it of the commit point, so an eager `Commit` reaches a backup only once
+/// that backup holds the op it announces.
+///
+/// Chain replication forwards a prepare to the last backups after the quorum
+/// commits it. A `Commit` sent to every backup at once overtakes the prepare
+/// there, and the backup repairs an op that is still on its way to it.
+#[derive(Debug)]
+struct CommitAnnouncements {
+ /// Commit point the planes asked the backups to learn without waiting for
+ /// the heartbeat; see [`VsrConsensus::request_eager_commit`].
+ eager: u64,
+ /// Indexed by replica id. Reset on every view change.
+ replicas: Vec<ReplicaCommitProgress>,
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+struct ReplicaCommitProgress {
+ /// Highest op the replica acknowledged in this view.
+ acked: u64,
+ /// Highest commit point sent to the replica in this view.
+ sent: u64,
+}
+
+impl ReplicaCommitProgress {
+ /// Whether the replica acked an op up to `eager` that no `Commit` sent to
+ /// it covers.
+ fn owes(self, eager: u64) -> bool {
+ self.sent < eager.min(self.acked)
+ }
+}
+
+impl CommitAnnouncements {
+ fn new(replica_count: u8) -> Self {
+ Self {
+ eager: 0,
+ replicas: vec![ReplicaCommitProgress::default(); usize::from(replica_count)],
+ }
+ }
+
+ fn reset(&mut self) {
+ self.eager = 0;
+ self.replicas.fill(ReplicaCommitProgress::default());
+ }
+
+ fn record_ack(&mut self, replica: u8, op: u64) {
+ if let Some(progress) = self.replicas.get_mut(usize::from(replica)) {
+ progress.acked = progress.acked.max(op);
+ }
+ }
+
+ fn record_broadcast(&mut self, commit: u64) {
+ for progress in &mut self.replicas {
+ progress.sent = progress.sent.max(commit);
+ }
+ }
+
+ fn owes(&self, replica: u8) -> bool {
+ self.replicas
+ .get(usize::from(replica))
+ .is_some_and(|progress| progress.owes(self.eager))
+ }
+
+ /// The commit point to send `replica` if it is owed one, recorded as sent.
+ ///
+ /// Clamped to the replica's acks, so the `Commit` names no op it lacks.
+ fn take_owed(&mut self, replica: u8, commit_min: u64) -> Option<u64> {
+ let eager = self.eager;
+ let progress = self
+ .replicas
+ .get_mut(usize::from(replica))
+ .filter(|progress| progress.owes(eager))?;
+ progress.sent = commit_min.min(progress.acked);
+ Some(progress.sent)
+ }
+}
+
#[allow(unused)]
#[derive(Debug)]
pub struct VsrConsensus<B = IggyMessageBus, P = LocalPipeline>
@@ -1317,13 +1399,34 @@ where
/// this replica prepares and announces nothing. Cleared by
/// [`VsrConsensus::start_pending_view`], or by `reset_view_change_state`.
pending_view_log: RefCell<Option<MergedLog>>,
+ /// The log the last adopted `StartView` announced, for this replica's
+ /// `DoViewChange` until its journal holds it or a newer view's log replaces
+ /// it.
+ ///
+ /// The parked copy in `pending_view_log` goes at the next view change. Without
+ /// this one, a backup that has not repaired the announced ops reads blanks
+ /// from its journal in every later view change, and at the commit point a
+ /// blank leaves the op undecidable when no other sender reports it. Above the
+ /// commit point the merge counts the kept header as a nack, as it would the
+ /// blank: the journal holds no copy of the op.
+ adopted_view: RefCell<Option<crate::AdoptedView>>,
+ adopted_view_dirty: Cell<bool>,
/// The installed view must prove the local suffix before it can be applied.
/// Survives election resets until the plane successfully reconciles its journal.
view_log_unproven: Cell<bool>,
+ /// Partition-plane claim while an adopted log is unproven. Metadata uses
+ /// `history_certificate`, including its durable WAL binding, instead.
+ log_view_proven: Cell<u32>,
/// The prefix below a repair gap is unknown; entries above it can be proven.
view_log_unproven_through: Cell<u64>,
/// Set only after adoption has removed the superseded tail.
view_log_missing_op: Cell<Option<u64>>,
+ /// METADATA plane: the newest view whose log the WAL holds, which lags
+ /// `log_view` until the adopted log is proven. Its binding follows the WAL head
+ /// while the view is proven, and only moves down during a rewrite.
+ history_certificate: Cell<Option<HistoryCertificate>>,
+ /// The certificate the superblock last recorded.
+ history_certificate_durable: Cell<Option<HistoryCertificate>>,
/// Tracks DVC messages received (only used by primary candidate)
/// Stores metadata; actual log comes from message
@@ -1340,12 +1443,28 @@ where
/// View and counter of the last emitted or accepted heartbeat. A new
/// primary's counter can be lower, so freshness is scoped to its view.
heartbeat_watermark: Cell<(u32, u64)>,
+ /// The next tick looks for backups owed an eager `Commit`; see
+ /// [`Self::request_eager_commit`].
+ eager_commit: Cell<bool>,
+ commit_announcements: RefCell<CommitAnnouncements>,
+ /// Views whose logs were refused for naming a different prepare at an op
+ /// this replica applied; see [`Self::contradicts_applied_log`].
+ applied_log_contradictions: Cell<u64>,
+ /// Newest view counted in `applied_log_contradictions`.
+ contradiction_reported_view: Cell<Option<u32>>,
/// Time source for [`Self::next_monotonic_timestamp`]; see
/// [`ConsensusClock`].
clock: ConsensusClock,
}
+/// What carried a log that contradicts an op this replica applied.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ContradictionSource {
+ StartView,
+ Prepare,
+}
+
/// Boot-time timer set for a consensus group, one struct so every plane's
/// restore path applies the same values in the same place.
#[derive(Debug, Clone, Copy)]
@@ -1649,6 +1768,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
// (SplitMix64) decorrelates the streams of nearby seeds.
let timeout_seed = u128::from(replica) ^ u128::from(group);
let prepare_queue_max = pipeline.prepare_queue_max();
+ // A metadata replica with no history holds view 0's empty log until boot
+ // restores what recovery decided. Partitions keep no certificate.
+ let empty_history = (group == METADATA_GROUP).then_some(HistoryCertificate::new(0, 0, 0));
Self {
cluster,
replica,
@@ -1684,15 +1806,24 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
local_dvc_suffix: RefCell::new(None),
journal_mutations: Cell::new(0),
pending_view_log: RefCell::new(None),
+ adopted_view: RefCell::new(None),
+ adopted_view_dirty: Cell::new(false),
view_log_unproven: Cell::new(false),
+ log_view_proven: Cell::new(0),
view_log_unproven_through: Cell::new(u64::MAX),
view_log_missing_op: Cell::new(None),
+ history_certificate: Cell::new(empty_history),
+ history_certificate_durable: Cell::new(empty_history),
do_view_change_from_all_replicas: RefCell::new(dvc_quorum_array_empty()),
do_view_change_quorum: Cell::new(false),
sent_own_start_view_change: Cell::new(false),
sent_own_do_view_change: Cell::new(false),
timeouts: RefCell::new(TimeoutManager::new(timeout_seed)),
heartbeat_watermark: Cell::new((0, 0)),
+ eager_commit: Cell::new(false),
+ commit_announcements: RefCell::new(CommitAnnouncements::new(replica_count)),
+ applied_log_contradictions: Cell::new(0),
+ contradiction_reported_view: Cell::new(None),
clock,
}
}
@@ -1811,6 +1942,12 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
self.ceded_primaryship.get()
}
+ /// Normal status can persist while a newer view is being confirmed.
+ #[must_use]
+ pub const fn has_observed_newer_view(&self) -> bool {
+ self.observed_newer_view.get() > self.view.get()
+ }
+
/// Set the per-boot incarnation nonce, at boot before `init`. Production
/// supplies a random `u128`; the deterministic simulator supplies a
/// seed-derived value bumped per restart. See the `incarnation` field.
@@ -1838,6 +1975,49 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
self.primary_index(self.view.get()) == self.replica
}
+ /// The primary of a Normal view, unless it ceded at boot
+ /// (`init_as_backup`): then it is a backup by role however the view math
+ /// reads, so it neither retransmits prepares nor announces commits.
+ fn is_acting_primary(&self) -> bool {
+ self.is_primary() && self.status.get() == Status::Normal && !self.ceded_primaryship.get()
+ }
+
+ /// Ask the partition quorum to elect `target` in the next view assigned to it.
+ /// A ready backup may initiate this too. Refusal leaves consensus unchanged.
+ /// The caller fences the incarnation, drains history work, refreshes the DVC
+ /// suffix and persists the new view before dispatching the returned actions.
+ pub fn request_primary_transfer(&self, expected_view: u32, target: u8) -> Vec<VsrAction> {
+ let view = self.view();
+ if self.group == METADATA_GROUP
+ || target >= self.replica_count
+ || view != expected_view
+ || !self.is_normal()
+ || self.has_ceded_primaryship()
+ || self.is_transferring()
+ || self.view_log_unproven()
+ || self.observed_newer_view.get() > view
+ || self.commit_min() != self.commit_max()
+ || self.commit_max() < self.recovery_barrier()
+ || !self.applied_view_start()
+ {
+ return Vec::new();
+ }
+ let primary = self.primary_index(view);
+ if target == primary {
+ return Vec::new();
+ }
+ let replicas = u32::from(self.replica_count);
+ let distance = (u32::from(target) + replicas - u32::from(primary)) % replicas;
+ let Some(next_view) = view.checked_add(distance) else {
+ return Vec::new();
+ };
+ self.enter_view_change(
+ PlaneKind::Partitions,
+ next_view,
+ ViewChangeReason::PrimaryTransfer,
+ )
+ }
+
/// Advance `commit_max` - the highest op known to be committed by the cluster.
///
/// Called when the replica learns about new commits from the primary
@@ -2460,21 +2640,58 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
self.log_view.get()
}
+ /// Restore or seed `log_view`: a value read back from disk or chosen at
+ /// creation, so the journal already backs it.
pub fn set_log_view(&self, log_view: u32) {
self.log_view.set(log_view);
+ if self.group != METADATA_GROUP {
+ self.log_view_proven.set(log_view);
+ }
+ }
+
+ /// The latest log identity this replica can reproduce after a crash.
+ /// Adopted headers name a log independently of the certificate proving its
+ /// bodies. The dispatcher persists both claims before any view-scoped send.
+ #[must_use]
+ pub fn log_view_claim(&self) -> u32 {
+ let proven = if self.group == METADATA_GROUP {
+ self.history_certificate
+ .get()
+ .map_or(0, |certificate| certificate.view)
+ } else if self.view_log_unproven.get() {
+ self.log_view_proven.get()
+ } else {
+ self.log_view.get()
+ };
+ self.adopted_view
+ .borrow()
+ .as_ref()
+ .map_or(proven, |adopted| proven.max(adopted.view))
+ }
+
+ /// Metadata keeps the adopted view beside its bound certificate; partitions
+ /// store their proven claim and bind their history in the partition journal.
+ const fn persisted_log_view(&self) -> u32 {
+ if self.group == METADATA_GROUP {
+ self.log_view.get()
+ } else if self.view_log_unproven.get() {
+ self.log_view_proven.get()
+ } else {
+ self.log_view.get()
+ }
}
/// Snapshot the durable VSR state for the superblock. `view`, `log_view`,
/// `commit_max`, and the replica identity come from consensus; the caller
/// supplies the paired checkpoint, which consensus does not own.
#[must_use]
- pub const fn vsr_state(&self, checkpoint_op: u64, checkpoint_checksum: u128) -> VsrState {
+ pub fn vsr_state(&self, checkpoint_op: u64, checkpoint_checksum: u128) -> VsrState {
VsrState {
cluster: self.cluster,
replica_id: self.replica,
replica_count: self.replica_count,
view: self.view.get(),
- log_view: self.log_view.get(),
+ log_view: self.persisted_log_view(),
commit_max: self.commit_max.get(),
checkpoint_op,
checkpoint_checksum,
@@ -2483,6 +2700,143 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
// (`IggyPartition::write_superblock`).
offset_frontier: 0,
offset_reserved: 0,
+ adopted_view: self.adopted_view.borrow().clone(),
+ }
+ .with_history_certificate(self.history_certificate.get())
+ }
+
+ /// METADATA plane: the newest view whose log the WAL holds.
+ #[must_use]
+ pub const fn history_certificate(&self) -> Option<HistoryCertificate> {
+ self.history_certificate.get()
+ }
+
+ /// Whether this replica may acknowledge in its `log_view`. On the metadata plane
+ /// only once the WAL holds that view's log, since a restart restores the
+ /// certified view rather than the adopted one, and an ack the restored view does
+ /// not cover is a vote the next view change cannot see.
+ #[must_use]
+ pub fn history_certified(&self) -> bool {
+ self.group != METADATA_GROUP
+ || self
+ .history_certificate
+ .get()
+ .is_some_and(|certificate| certificate.view == self.log_view.get())
+ }
+
+ /// Certify `log_view`: the WAL holds its log, with `checksum` at head `op`.
+ /// A proven log may vote again, so an unproven restart's election ban ends.
+ pub fn certify_view_log(&self, op: u64, checksum: u128) {
+ self.history_certificate.set(Some(HistoryCertificate::new(
+ self.log_view.get(),
+ op,
+ checksum,
+ )));
+ self.recovery_election_allowed.set(true);
+ }
+
+ /// Follow the WAL head while the certified view is the proven normal one, so a
+ /// later view finds the binding above every op this replica acknowledged.
+ pub fn extend_history_certificate(&self, op: u64, checksum: u128) {
+ if let Some(certificate) = self.history_certificate.get()
+ && certificate.view == self.log_view.get()
+ && certificate.op < op
+ && self.status.get() == Status::Normal
+ && !self.view_log_unproven.get()
+ {
+ self.history_certificate.set(Some(HistoryCertificate::new(
+ certificate.view,
+ op,
+ checksum,
+ )));
+ }
+ }
+
+ /// Bind the certificate to `op` when a rewrite is about to remove the entries
+ /// from `from` up and the binding is one of them. The caller persists the
+ /// lowered binding before it removes anything.
+ pub fn lower_history_certificate(&self, from: u64, op: u64, checksum: u128) {
+ if let Some(certificate) = self.history_certificate.get()
+ && certificate.op >= from
+ {
+ self.history_certificate.set(Some(HistoryCertificate::new(
+ certificate.view,
+ op,
+ checksum,
+ )));
+ }
+ }
+
+ /// Record the certificate the superblock now holds, the one the write carried.
+ pub fn mark_history_certificate_durable(&self, certificate: Option<HistoryCertificate>) {
+ self.history_certificate_durable.set(certificate);
+ }
+
+ /// Install what recovery decided about the WAL. `head` is the WAL head after
+ /// recovery truncated it, `record` the durable state it decided from.
+ ///
+ /// An unproven WAL is fenced as a log adopted from a view change is until it is
+ /// proven, but with no adopted headers to stand in for it. Without a record the
+ /// certificate is derived the same way on every boot, so it counts as durable.
+ ///
+ /// An unproven replica also stays out of elections when its view probe goes
+ /// unanswered. It may hold ops a quorum committed in a view it can no longer
+ /// claim, and voting under a lower `log_view` lets a peer's older log replace
+ /// them. A `StartView` gives it a log to prove against instead.
+ pub fn restore_history(
+ &self,
+ recovery: HistoryRecovery,
+ head: (u64, u128),
+ record: Option<&VsrState>,
+ ) {
+ let certificate = match recovery {
+ HistoryRecovery::Certified { view, .. } => {
+ // A retained repaired body belongs to the adopted log, not the
+ // older certificate's prefix. Only whole-log proof may extend it.
+ record
+ .filter(|state| {
+ state
+ .adopted_view
+ .as_ref()
+ .is_some_and(|adopted| adopted.view > view)
+ })
+ .and_then(VsrState::history_certificate)
+ .or_else(|| Some(HistoryCertificate::new(view, head.0, head.1)))
+ }
+ HistoryRecovery::Unproven => {
+ tracing::warn!(
+ replica = self.replica,
+ view = self.view.get(),
+ head = head.0,
+ "metadata WAL has no history certificate that binds it; staying out of \
+ elections until a StartView proves it"
+ );
+ self.recovery_election_allowed.set(false);
+ self.view_log_unproven.set(true);
+ self.view_log_unproven_through.set(u64::MAX);
+ self.view_log_missing_op.set(None);
+ self.invalidate_local_dvc_suffix();
+ None
+ }
+ };
+ self.history_certificate.set(certificate);
+ self.history_certificate_durable
+ .set(record.map_or(certificate, VsrState::history_certificate));
+ }
+
+ /// Whether the certificate moved to a view, or down to a binding, the superblock
+ /// does not hold yet.
+ #[must_use]
+ pub const fn history_certificate_needs_persist(&self) -> bool {
+ match (
+ self.history_certificate.get(),
+ self.history_certificate_durable.get(),
+ ) {
+ (Some(certificate), Some(durable)) => {
+ certificate.view != durable.view || certificate.op < durable.op
+ }
+ (Some(_), None) => true,
+ (None, _) => false,
}
}
@@ -2567,10 +2921,16 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
/// gate: the dispatcher persists first when this holds. `commit_max` is
/// excluded deliberately, since it advances on every commit and rides the
/// checkpoint write rather than each view change.
+ ///
+ /// A newly certified view counts too, since an ack in it must survive a restart,
+ /// and so does a binding lowered for a rewrite, which must not remove the entry
+ /// the durable certificate still names.
#[must_use]
pub const fn needs_superblock_persist(&self) -> bool {
self.view.get() != self.view_durable.get()
- || self.log_view.get() != self.log_view_durable.get()
+ || self.persisted_log_view() != self.log_view_durable.get()
+ || self.history_certificate_needs_persist()
+ || self.adopted_view_dirty.get()
}
/// Record that the superblock now durably holds `(view, log_view)`: the exact
@@ -2648,6 +3008,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
// drop it and let the new attempt re-derive from the DVCs it collects.
self.pending_view_log.borrow_mut().take();
self.loopback_queue.borrow_mut().clear();
+ self.commit_announcements.borrow_mut().reset();
let mut pipeline = self.pipeline.borrow_mut();
pipeline.cancel_all_subscribers();
pipeline.clear_request_queue();
@@ -2698,10 +3059,15 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
timeouts = self.timeouts.borrow_mut();
}
+ // The heartbeat carries `commit_min` too, so it also answers a pending
+ // eager request.
+ let eager_commit = self.eager_commit.take();
if timeouts.fired(TimeoutKind::CommitMessage) {
drop(timeouts);
actions.extend(self.handle_commit_message_timeout());
timeouts = self.timeouts.borrow_mut();
+ } else if eager_commit {
+ actions.extend(self.eager_commit_actions());
}
if !history_fenced && timeouts.fired(TimeoutKind::RequestStartViewMessage) {
@@ -3028,10 +3394,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
// with the fired state intact turns the next pipeline push into
// an instant spurious retransmit on the following tick (the push
// sees `is_ticking` and does not restart the timer).
- // A replica that ceded at boot (`init_as_backup`) is a backup by role however
- // the view math reads, so it must not retransmit either.
- if !self.is_primary() || self.status.get() != Status::Normal || self.has_ceded_primaryship()
- {
+ if !self.is_acting_primary() {
self.timeouts.borrow_mut().stop(TimeoutKind::Prepare);
return Vec::new();
}
@@ -3091,19 +3454,74 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
self.timeouts.borrow_mut().reset(TimeoutKind::CommitMessage);
+ vec![self.commit_heartbeat()]
+ }
+
+ /// Ask the next tick to send `commit_min` to the backups instead of waiting
+ /// for the `CommitMessage` heartbeat.
+ ///
+ /// A backup learns a commit from the next prepare or from that heartbeat,
+ /// so on a quiet group it applies a committed op up to a heartbeat interval
+ /// late. The tick sends a `Commit` to each backup whose `PrepareOk` shows it
+ /// holds the op, and a backup that acks it later gets one on the tick after
+ /// its ack. Requests and acks before a tick share one `Commit` per backup,
+ /// and none is sent while the newest prepare already carries `commit_min`.
+ pub fn request_eager_commit(&self) {
+ if self.replica_count > 1 && self.is_acting_primary() {
+ self.commit_announcements.borrow_mut().eager = self.commit_min.get();
+ self.eager_commit.set(true);
+ }
+ }
+
+ /// The `Commit`s owed since [`Self::request_eager_commit`], unless the
+ /// backups learn `commit_min` from a prepare already sent.
+ fn eager_commit_actions(&self) -> Vec<VsrAction> {
+ // A stale primary leaves the probe to `handle_commit_message_timeout`.
+ if !self.is_acting_primary() || self.has_observed_newer_view() {
+ return Vec::new();
+ }
+ let commit_min = self.commit_min.get();
+ let carried = self
+ .pipeline
+ .borrow()
+ .entry_by_op(self.sequencer.current_sequence())
+ .is_some_and(|entry| entry.header.commit >= commit_min);
+ if carried {
+ return Vec::new();
+ }
+ let mut announcements = self.commit_announcements.borrow_mut();
+ (0..self.replica_count)
+ .filter(|&replica| replica != self.replica)
+ .filter_map(|replica| {
+ let commit = announcements.take_owed(replica, commit_min)?;
+ Some(self.commit_message(Some(replica), commit))
+ })
+ .collect()
+ }
+
+ fn commit_heartbeat(&self) -> VsrAction {
// Don't advertise a commit point we haven't locally executed yet.
// After view change the new primary may have commit_min < commit_max
// until commit_journal catches up. Send commit_min (what we've
// actually applied) so backups don't advance past us.
+ let commit = self.commit_min.get();
+ self.commit_announcements
+ .borrow_mut()
+ .record_broadcast(commit);
+ self.commit_message(None, commit)
+ }
+
+ fn commit_message(&self, target: Option<u8>, commit: u64) -> VsrAction {
let ts = self.heartbeat_watermark.get().1 + 1;
self.heartbeat_watermark.set((self.view.get(), ts));
- vec![VsrAction::SendCommit {
+ VsrAction::SendCommit {
view: self.view.get(),
- commit: self.commit_min.get(),
+ commit,
group: self.group,
timestamp_monotonic: ts,
- }]
+ target,
+ }
}
/// Handle a received `StartViewChange` message.
@@ -3181,7 +3599,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
if primary_candidate == self.replica {
let own_dvc = StoredDvc {
replica: self.replica,
- log_view: self.log_view.get(),
+ log_view: self.log_view_claim(),
op: current_op,
commit,
suffix: self.local_dvc_suffix(),
@@ -3228,7 +3646,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
VsrAction::SendDoViewChange {
view: self.view.get(),
target,
- log_view: self.log_view.get(),
+ log_view: self.log_view_claim(),
op: self.sequencer.current_sequence(),
commit: self.dvc_commit(),
group: self.group,
@@ -3328,7 +3746,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
let own_dvc = StoredDvc {
replica: self.replica,
- log_view: self.log_view.get(),
+ log_view: self.log_view_claim(),
op: current_op,
commit,
suffix: self.local_dvc_suffix(),
@@ -3517,6 +3935,20 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
"commit floor {floor} may not rewind commit_min {current}"
);
self.commit_min.set(floor);
+ // The view-log proof was taken from the old commit point. A missing op the
+ // floor now covers is applied state that peers have compacted: every repair
+ // window opened at it draws `RangeEvicted`, and the proof it would close
+ // keeps the commit walk fenced. Drop it so the plane re-derives the proof
+ // from the new floor.
+ if self
+ .view_log_missing_op
+ .get()
+ .is_some_and(|missing| missing <= floor)
+ {
+ self.view_log_missing_op.set(None);
+ self.view_log_unproven_through.set(u64::MAX);
+ self.invalidate_local_dvc_suffix();
+ }
}
fn finish_view_probe(&self) {
@@ -3536,20 +3968,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
///
/// Falls back to the announced `op` on an empty body (probe answer, stale-view
/// correction).
- fn adopt_start_view_suffix(&self, header: &StartViewHeader, suffix_body: &[u8]) -> u64 {
- let suffix = match dvc_suffix_decode(suffix_body, header.op, 0, 0) {
- Ok(suffix) => suffix,
- Err(error) => {
- tracing::warn!(
- replica = self.replica,
- from_replica = header.replica,
- view = header.view,
- op = header.op,
- "start_view suffix did not decode, falling back to the announced op: {error}"
- );
- return header.op;
- }
- };
+ fn adopt_start_view_suffix(&self, header: &StartViewHeader, suffix: &DvcSuffix) -> u64 {
let headers = suffix.headers();
if headers.is_empty() && !self.view_log_unproven() {
return header.op;
@@ -3563,6 +3982,99 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
header.op
}
+ /// True when `headers` name a different prepare than this replica applied at
+ /// some op at or below `commit_min`. The caller refuses the log that carried
+ /// them.
+ ///
+ /// Quorum intersection puts every committed op in each later view's log, so a
+ /// mismatch means a quorum decided a log that contradicts a committed op.
+ /// Adopting it, or reading its prepare as an old retransmit, hides the loss.
+ ///
+ /// `applied_at` answers the header of the prepare this replica applied at an
+ /// op, `None` once the journal no longer holds it. Only ops it answers for are
+ /// compared, and an unsealed checksum on either side carries no identity, so
+ /// a healthy replica never trips this.
+ ///
+ /// `view` is the view of the message that carried the log. The first
+ /// contradiction in each newer view is logged with both checksums and
+ /// counted. A redelivered `StartView` or a retransmitted prepare of that view
+ /// is refused without either, so a peer that keeps resending cannot flood
+ /// the log.
+ pub fn contradicts_applied_log<'a>(
+ &self,
+ plane: PlaneKind,
+ source: ContradictionSource,
+ view: u32,
+ headers: impl IntoIterator<Item = &'a PrepareHeader>,
+ applied_at: impl Fn(u64) -> Option<PrepareHeader>,
+ ) -> bool {
+ let commit_min = self.commit_min();
+ let contradiction = headers
+ .into_iter()
+ .filter(|header| {
+ header.op <= commit_min && dvc_header_kind(header) == DvcHeaderKind::Valid
+ })
+ .find_map(|header| {
+ applied_at(header.op)
+ .filter(|applied| {
+ applied.checksum != header.checksum
+ && applied.checksum != CHECKSUM_UNSEALED
+ && header.checksum != CHECKSUM_UNSEALED
+ })
+ .map(|applied| (header, applied))
+ });
+ let Some((header, applied)) = contradiction else {
+ return false;
+ };
+ if self
+ .contradiction_reported_view
+ .get()
+ .is_some_and(|reported| reported >= view)
+ {
+ return true;
+ }
+ self.contradiction_reported_view.set(Some(view));
+ self.applied_log_contradictions
+ .set(self.applied_log_contradictions.get().saturating_add(1));
+ tracing::error!(
+ plane = plane.as_str(),
+ group = self.group,
+ replica = self.replica,
+ view = self.view.get(),
+ message_view = view,
+ ?source,
+ op = header.op,
+ commit_min,
+ applied_checksum = applied.checksum,
+ announced_checksum = header.checksum,
+ "refusing a log that names a different prepare at op {} than this replica \
+ applied: a quorum decided a log that contradicts a committed op",
+ header.op
+ );
+ true
+ }
+
+ /// Views whose logs [`Self::contradicts_applied_log`] refused since boot.
+ #[must_use]
+ pub const fn applied_log_contradictions(&self) -> u64 {
+ self.applied_log_contradictions.get()
+ }
+
+ /// [`Self::handle_start_view_guarded`] for a caller that cannot read back the
+ /// prepares it applied.
+ ///
+ /// # Panics
+ /// If `header.group` does not match this replica's namespace.
+ pub fn handle_start_view(
+ &self,
+ plane: PlaneKind,
+ header: &StartViewHeader,
+ suffix_body: &[u8],
+ ) -> Vec<VsrAction> {
+ self.handle_start_view_guarded(plane, header, suffix_body, |_| None)
+ .unwrap_or_default()
+ }
+
/// Handle a received `StartView` message (backups only).
///
/// "When other replicas receive the STARTVIEW message, they replace their log
@@ -3583,16 +4095,22 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
/// `commit_journal` stops at the gap. Requires message repair.
/// `suffix_body` is the message body: the view's canonical headers, empty
/// when the announcement carries numbers only.
+ ///
+ /// `None` refuses a `StartView` that would be adopted but whose headers
+ /// contradict a prepare `applied_at` reports (see
+ /// [`Self::contradicts_applied_log`]): nothing changed, and nothing may act
+ /// on it.
#[expect(
clippy::too_many_lines,
reason = "keep StartView validation and both commit paths together"
)]
- pub fn handle_start_view(
+ pub fn handle_start_view_guarded(
&self,
plane: PlaneKind,
header: &StartViewHeader,
suffix_body: &[u8],
- ) -> Vec<VsrAction> {
+ applied_at: impl Fn(u64) -> Option<PrepareHeader>,
+ ) -> Option<Vec<VsrAction>> {
assert_eq!(header.group, self.group, "SV routed to wrong group");
let from_replica = header.replica;
let msg_view = header.view;
@@ -3601,12 +4119,12 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
// Verify sender is the primary for this view
if self.primary_index(msg_view) != from_replica {
- return Vec::new();
+ return Some(Vec::new());
}
// Ignore old views
if msg_view < self.view.get() {
- return Vec::new();
+ return Some(Vec::new());
}
// Incarnation guard. While Recovering (probing after a restart), only adopt
@@ -3638,7 +4156,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
self_incarnation,
"ignoring StartView while recovering: stale incarnation"
);
- return Vec::new();
+ return Some(Vec::new());
}
// Within one view the primary's log only grows. A Normal replica already
@@ -3656,9 +4174,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
{
if msg_commit > self.commit_max() {
self.advance_commit_max(msg_commit);
- return vec![VsrAction::CommitJournal];
+ return Some(vec![VsrAction::CommitJournal]);
}
- return Vec::new();
+ return Some(Vec::new());
}
// Adoption may discard an uncommitted WAL suffix, never a committed prefix. A
@@ -3683,12 +4201,41 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
commit_max = self.commit_max(),
"ignoring StartView that omits known committed entries"
);
- return Vec::new();
+ return Some(Vec::new());
}
// We shouldn't process our own StartView
if from_replica == self.replica {
- return Vec::new();
+ return Some(Vec::new());
+ }
+
+ let suffix = match dvc_suffix_decode(suffix_body, msg_op, 0, 0) {
+ Ok(suffix) => suffix,
+ Err(error) => {
+ tracing::warn!(view = msg_view, op = msg_op, %error, "refusing malformed StartView evidence");
+ return None;
+ }
+ };
+ let adopted = crate::AdoptedView {
+ view: msg_view,
+ group: self.group,
+ partition_incarnation: self.partition_incarnation.get(),
+ op_head: msg_op,
+ commit_max: msg_commit,
+ headers: suffix.headers().to_vec(),
+ };
+ if let Err(error) = adopted.validate(self.cluster, msg_view) {
+ tracing::warn!(view = msg_view, op = msg_op, %error, "refusing mismatched StartView evidence");
+ return None;
+ }
+ if self.contradicts_applied_log(
+ plane,
+ ContradictionSource::StartView,
+ msg_view,
+ suffix.headers(),
+ applied_at,
+ ) {
+ return None;
}
// Accept the StartView and transition to normal
@@ -3707,6 +4254,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
let needs_reconciliation = self.view_log_unproven.get()
|| self.status.get() != Status::Normal
|| self.log_view.get() != msg_view;
+ if self.group != METADATA_GROUP && !self.view_log_unproven.get() {
+ self.log_view_proven.set(self.log_view.get());
+ }
self.view.set(msg_view);
self.log_view.set(msg_view);
self.status.set(Status::Normal);
@@ -3723,7 +4273,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
// Cross-check the announced head against the headers published with it: a
// suffix head disagreeing with `header.op` means an inconsistently built
// frame, and either value leaves this replica chasing an unservable head.
- let announced = self.adopt_start_view_suffix(header, suffix_body);
+ let announced = self.adopt_start_view_suffix(header, &suffix);
+ *self.adopted_view.borrow_mut() = Some(adopted);
+ self.adopted_view_dirty.set(true);
self.sequencer.set_sequence(announced);
// Settle a gated suffix's fate as a backup too, so a later election inherits
// a decided barrier rather than a latched one.
@@ -3770,7 +4322,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
);
actions.push(send_prepare_ok);
}
- actions
+ Some(actions)
}
/// Handle a `Commit` (heartbeat) message from the primary.
@@ -3938,7 +4490,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
let new_op = merged.op_head;
self.advance_commit_max(max_commit);
*self.pending_view_log.borrow_mut() = Some(merged);
- self.view_log_unproven.set(true);
+ self.mark_view_log_unproven();
self.view_log_unproven_through.set(u64::MAX);
self.view_log_missing_op.set(None);
@@ -4002,6 +4554,70 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
self.pending_view_log.borrow().clone()
}
+ /// The log the last adopted `StartView` announced, while this replica's
+ /// journal does not hold it yet. Unlike the parked copy it survives view
+ /// changes, and it is never this replica's own merge.
+ #[must_use]
+ pub fn adopted_view_log(&self) -> Option<MergedLog> {
+ self.view_log_unproven()
+ .then(|| {
+ self.adopted_view
+ .borrow()
+ .as_ref()
+ .map(crate::AdoptedView::merged_log)
+ })
+ .flatten()
+ }
+
+ /// The accepted `StartView`'s view and advertised head, including after repair.
+ #[must_use]
+ pub fn adopted_view_head(&self) -> Option<(u32, u64)> {
+ self.adopted_view
+ .borrow()
+ .as_ref()
+ .map(|adopted| (adopted.view, adopted.op_head))
+ }
+
+ /// Mark only the evidence captured by the completed write. Adoption may have
+ /// advanced while the writer awaited disk I/O.
+ pub fn mark_adopted_view_durable(&self, written: Option<&crate::AdoptedView>) {
+ if self.adopted_view.borrow().as_ref() == written {
+ self.adopted_view_dirty.set(false);
+ }
+ }
+
+ /// Load validated evidence before joining the message pump. Body recovery
+ /// follows separately, then `resume_adopted_view` installs its repair fence.
+ pub fn restore_adopted_view(&self, adopted: Option<&crate::AdoptedView>) {
+ *self.adopted_view.borrow_mut() = adopted.cloned();
+ self.adopted_view_dirty.set(false);
+ }
+
+ /// Re-establish the announced head after recovering the physical body log.
+ pub fn resume_adopted_view(&self) {
+ let adopted = self.adopted_view.borrow();
+ let Some(adopted) = adopted.as_ref() else {
+ return;
+ };
+ let proven = if self.group == METADATA_GROUP {
+ self.history_certificate
+ .get()
+ .map_or(0, |certificate| certificate.view)
+ } else {
+ self.log_view_proven.get()
+ };
+ if adopted.view > proven {
+ self.log_view.set(adopted.view);
+ self.view_log_unproven.set(true);
+ self.view_log_unproven_through.set(u64::MAX);
+ self.view_log_missing_op.set(None);
+ self.sequencer.set_sequence(adopted.op_head);
+ *self.pending_view_log.borrow_mut() = Some(adopted.merged_log());
+ self.advance_commit_max(adopted.commit_max);
+ self.invalidate_local_dvc_suffix();
+ }
+ }
+
#[must_use]
pub const fn view_log_unproven(&self) -> bool {
self.view_log_unproven.get()
@@ -4009,11 +4625,23 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
pub fn settle_view_log_proof(&self) {
self.view_log_unproven.set(false);
+ if self.group != METADATA_GROUP {
+ self.log_view_proven.set(self.log_view.get());
+ }
self.invalidate_local_dvc_suffix();
}
- pub fn fence_view_log(&self, op: u64, unproven_through: u64) {
+ /// Enter the unproven state, keeping the current `log_view` as the claim when
+ /// it was proven.
+ fn mark_view_log_unproven(&self) {
+ if self.group != METADATA_GROUP && !self.view_log_unproven.get() {
+ self.log_view_proven.set(self.log_view.get());
+ }
self.view_log_unproven.set(true);
+ }
+
+ pub fn fence_view_log(&self, op: u64, unproven_through: u64) {
+ self.mark_view_log_unproven();
self.view_log_unproven_through.set(unproven_through);
self.view_log_missing_op.set(Some(op));
self.invalidate_local_dvc_suffix();
@@ -4026,6 +4654,18 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
|| op > self.view_log_unproven_through.get()
}
+ /// Whether `view_log_proof` still has to walk `op` before anything commits,
+ /// serves or reports it: unproven, and at or below the head the walk starts at.
+ #[must_use]
+ pub fn view_log_proof_covers(&self, op: u64) -> bool {
+ !self.view_log_entry_proven(op)
+ && op
+ <= self.pending_view_log.borrow().as_ref().map_or_else(
+ || self.sequencer.current_sequence(),
+ |pending| pending.op_head,
+ )
+ }
+
#[must_use]
pub const fn view_log_unproven_range(&self) -> std::ops::RangeInclusive<u64> {
self.commit_min().saturating_add(1)..=if self.view_log_unproven() {
@@ -4035,16 +4675,24 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
}
}
+ /// The op the view-log proof is waiting on. A bridged proof lets the
+ /// partition commit walk pass a missing body that a live retransmit filled.
+ /// Named at or below `commit_min`, that op opens every repair below the
+ /// peers' retained floor, and the shard re-runs the proof only once no op is
+ /// named. [`Self::set_commit_floor`] clears an op that a floor jump covers.
#[must_use]
pub const fn view_log_repair_op(&self) -> Option<u64> {
- if self.view_log_unproven() {
- self.view_log_missing_op.get()
- } else {
- None
+ match self.view_log_missing_op.get() {
+ Some(op) if self.view_log_unproven() && op > self.commit_min() => Some(op),
+ _ => None,
}
}
- pub fn note_repaired_view_header(&self, header: PrepareHeader) {
+ pub fn note_repaired_view_header(&self, header: PrepareHeader, evidence: RepairEvidence) {
+ // A body awaiting proof cannot become the checksum that proves itself.
+ if evidence != RepairEvidence::Proven {
+ return;
+ }
if let Some(pending) = self.pending_view_log.borrow_mut().as_mut()
&& pending.op_head == header.op
&& pending
@@ -4138,8 +4786,13 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
let Some(merged) = self.pending_view_log.borrow_mut().take() else {
return Vec::new();
};
+ if self.adopted_view.borrow_mut().take().is_some() {
+ self.adopted_view_dirty.set(true);
+ }
let new_op = merged.op_head;
let max_commit = merged.commit_max;
+ // Repair can apply beyond the commit point frozen in the DVC merge.
+ let rebuild_after = self.commit_min().max(max_commit);
// The one view-change exit that skips `reset_view_change_state`, so the DVCs
// (a suffix `Vec` per sender, per group led) would be held for the whole
@@ -4178,6 +4831,15 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
// header is installed there; parking installs nothing and the repair ingest
// only fills holes, so a parked replica still holds its old view's log.
self.log_view.set(self.view.get());
+ // The view starts only once the merged log is proven against the WAL.
+ if self.group == METADATA_GROUP {
+ self.certify_view_log(
+ new_op,
+ merged.headers.first().map_or(0, |head| head.checksum),
+ );
+ } else {
+ self.log_view_proven.set(self.view.get());
+ }
// Update timeouts for normal primary operation
{
@@ -4189,7 +4851,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
timeouts.start(TimeoutKind::CommitMessage);
// If there are uncommitted ops in the rebuilt pipeline, start the
// Prepare timeout so that lost PrepareOks trigger retransmission.
- if max_commit < new_op {
+ if rebuild_after < new_op {
timeouts.start(TimeoutKind::Prepare);
}
}
@@ -4226,20 +4888,20 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
actions.push(VsrAction::CommitJournal);
// The new primary must rebuild its pipeline from the journal so that
// incoming PrepareOk messages can be matched and commits can proceed.
- if max_commit < new_op {
+ if rebuild_after < new_op {
// `complete_view_change_as_primary` already refused a non-fitting
// range. Asserted so the sites cannot drift; this one cannot decline.
debug_assert!(
- (new_op - max_commit) <= self.prepare_queue_max as u64,
+ (new_op - rebuild_after) <= self.prepare_queue_max as u64,
"view change: uncommitted range {}..={} ({} ops) exceeds pipeline capacity ({}); \
the merged log claims more in-flight ops than the pipeline can hold",
- max_commit + 1,
+ rebuild_after + 1,
new_op,
- new_op - max_commit,
+ new_op - rebuild_after,
self.prepare_queue_max,
);
actions.push(VsrAction::RebuildPipeline {
- from_op: max_commit + 1,
+ from_op: rebuild_after + 1,
to_op: new_op,
});
}
@@ -4322,6 +4984,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
}
drop(pipeline);
+ self.note_backup_ack(header.replica, header.op);
emit_sim_event(
SimEventKind::PrepareAcked,
@@ -4342,6 +5005,33 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
}
}
+ /// Handle a `PrepareOk` for an op this primary already committed.
+ ///
+ /// It counts toward no quorum, but it shows the backup holds the op, so a
+ /// `Commit` withheld from it until then goes out on the next tick. The
+ /// caller ran `ack_preflight`; the pipeline no longer holds the op.
+ pub fn handle_late_prepare_ok(&self, header: &PrepareOkHeader) {
+ if header.view == self.view()
+ && header.op <= self.commit_min.get()
+ && !self.is_transferring()
+ {
+ self.note_backup_ack(header.replica, header.op);
+ }
+ }
+
+ /// Record that `replica` holds `op`, and wake the next tick if that makes
+ /// it owed a `Commit`.
+ fn note_backup_ack(&self, replica: u8, op: u64) {
+ if replica == self.replica {
+ return;
+ }
+ let mut announcements = self.commit_announcements.borrow_mut();
+ announcements.record_ack(replica, op);
+ if announcements.owes(replica) {
+ self.eager_commit.set(true);
+ }
+ }
+
/// Enqueue a self-ack for the next owner service round.
pub(crate) fn push_loopback(&self, message: Message<GenericHeader>) {
let notify = {
@@ -4632,9 +5322,8 @@ mod fresh_group_start_tests {
const CREATED_VIEW: u32 = 4;
- /// The case the seed exists for: a group created after the metadata plane
- /// elected must not start at view 0, or it names a replica the roster does
- /// not advertise and no client can be routed to it.
+ /// Fresh replicas must agree on the committed assignment's initial view,
+ /// including a chosen view above zero, before the group starts serving.
#[test]
fn given_a_fresh_group_when_created_after_an_election_should_seed_the_creation_view() {
let start = fresh_group_start(false, None, CREATED_VIEW);
@@ -5170,6 +5859,7 @@ mod timestamp_clamp_tests {
use crate::LocalPipeline;
use crate::test_bus::{NoopBus, make_commit, make_start_view};
+ use aligned_vec::{AVec, ConstAlign};
use iggy_binary_protocol::CommitHeader;
#[test]
@@ -5332,8 +6022,8 @@ mod timestamp_clamp_tests {
assert_eq!(
seal(1, Operation::SendMessages),
0,
- "a partition prepare must be left unsealed, since its sealed region is \
- rewritten before it is journaled"
+ "a produce body checksum stays zero because persistence rewrites the body; \
+ its prepare header identity is still sealed"
);
}
@@ -5526,6 +6216,8 @@ mod timestamp_clamp_tests {
assert!(consensus.view_log_unproven());
let mut canonical = PrepareHeader {
command: Command::Prepare,
+ cluster: 1,
+ group: METADATA_GROUP,
operation: Operation::CreateStream,
size: u32::try_from(size_of::<PrepareHeader>()).unwrap(),
op: HEAD,
@@ -5549,11 +6241,14 @@ mod timestamp_clamp_tests {
assert_eq!(consensus.commit_min(), 0);
}
+ /// A state-transfer install jumps `commit_min` past the op a view-log fence
+ /// named. Peers answer a repair for that op with `RangeEvicted` at an op the
+ /// receiver already applied, and the proof re-runs only once no repair op is
+ /// named, so the receiver withholds acks for good. Here the floor lands on the
+ /// missing op itself, the boundary the test below steps over.
#[test]
- fn given_unreconciled_start_view_when_head_grows_and_times_out_should_keep_commit_fenced() {
- const VIEW: u32 = 4;
- const FIRST_HEAD: u64 = 20;
- const NEXT_HEAD: u64 = FIRST_HEAD + 1;
+ fn given_fenced_missing_op_when_transfer_floor_covers_it_should_stop_naming_it() {
+ const MISSING: u64 = 5;
let consensus = VsrConsensus::with_clock(
1,
0,
@@ -5563,15 +6258,132 @@ mod timestamp_clamp_tests {
LocalPipeline::new(),
ConsensusClock::system(),
);
- let primary = u8::try_from(VIEW % 3).unwrap();
+ consensus.fence_view_log(MISSING, MISSING);
+ assert_eq!(consensus.view_log_repair_op(), Some(MISSING));
- for head in [FIRST_HEAD, NEXT_HEAD] {
- let start_view = make_start_view(VIEW, head, head, primary, 0);
- assert!(
- !consensus
- .handle_start_view(PlaneKind::Metadata, start_view.header(), &[])
- .is_empty()
- );
+ consensus.set_commit_floor(MISSING);
+ assert_eq!(
+ consensus.view_log_repair_op(),
+ None,
+ "an op at the commit floor is applied, so no repair can still need it"
+ );
+ assert!(
+ consensus.view_log_unproven(),
+ "the floor jump proves nothing above it; the proof has to re-run"
+ );
+ }
+
+ /// A state-transfer install jumps `commit_min` past the op the view-log proof
+ /// was fetching. Kept, that op opens every tail repair below the peers'
+ /// retained floor: they answer `RangeEvicted`, the requester reads that as a
+ /// peer behind it, and the session rotates between replicas forever.
+ #[test]
+ fn given_fenced_view_log_when_commit_floor_covers_missing_op_should_rederive_proof() {
+ const VIEW: u32 = 4;
+ const HEAD: u64 = 51;
+ const FLOOR: u64 = 46;
+ let fenced_backup = |missing: u64| {
+ let consensus = VsrConsensus::with_clock(
+ 1,
+ 0,
+ 3,
+ METADATA_GROUP,
+ NoopBus,
+ LocalPipeline::new(),
+ ConsensusClock::system(),
+ );
+ let primary = u8::try_from(VIEW % 3).unwrap();
+ consensus.handle_start_view(
+ PlaneKind::Metadata,
+ make_start_view(VIEW, HEAD, HEAD - 1, primary, 0).header(),
+ &[],
+ );
+ consensus.fence_view_log(missing, 0);
+ consensus
+ };
+
+ let covered = fenced_backup(FLOOR - 1);
+ covered.set_commit_floor(FLOOR);
+ assert_eq!(
+ covered.view_log_repair_op(),
+ None,
+ "a missing op the floor covers opens repair below every peer's retained floor"
+ );
+ assert!(
+ covered.view_log_unproven(),
+ "the ops above the floor still need proving against the view"
+ );
+ assert!(!covered.view_log_entry_proven(FLOOR + 1));
+
+ let above = fenced_backup(FLOOR + 1);
+ above.set_commit_floor(FLOOR);
+ assert_eq!(
+ above.view_log_repair_op(),
+ Some(FLOOR + 1),
+ "a missing op above the floor is still the one to fetch"
+ );
+ }
+
+ /// A bridged proof vouches for every held entry, so the partition commit walk
+ /// applies the missing body once a live retransmit fills it, while the fence
+ /// still names that op. Named, it opens every repair at an op the peers may
+ /// have evicted, and the shard re-runs the proof only once no op is named.
+ #[test]
+ fn given_bridged_fence_when_commit_walks_past_missing_op_should_stop_naming_it() {
+ const BACKUP: u8 = 1;
+ const MISSING: u64 = 5;
+ // What `view_log_proof` reports for a body behind a canonical header.
+ const BRIDGED_UNPROVEN_THROUGH: u64 = 0;
+ let consensus = VsrConsensus::with_clock(
+ 1,
+ BACKUP,
+ 3,
+ IggyNamespace::new(0, 0, 0).inner(),
+ NoopBus,
+ LocalPipeline::new(),
+ ConsensusClock::system(),
+ );
+ consensus.restore_commit_state(MISSING - 1, MISSING);
+ consensus.fence_view_log(MISSING, BRIDGED_UNPROVEN_THROUGH);
+ assert_eq!(consensus.view_log_repair_op(), Some(MISSING));
+ assert!(
+ consensus.view_log_entry_proven(MISSING),
+ "the commit walk may apply a bridged op as soon as its body arrives"
+ );
+
+ consensus.advance_commit_min(MISSING);
+ assert_eq!(
+ consensus.view_log_repair_op(),
+ None,
+ "a repair opened at an applied op draws `RangeEvicted` once the peers \
+ evict it, and the proof re-runs only once no op is named"
+ );
+ assert!(consensus.view_log_unproven());
+ }
+
+ #[test]
+ fn given_unreconciled_start_view_when_head_grows_and_times_out_should_keep_commit_fenced() {
+ const VIEW: u32 = 4;
+ const FIRST_HEAD: u64 = 20;
+ const NEXT_HEAD: u64 = FIRST_HEAD + 1;
+ let consensus = VsrConsensus::with_clock(
+ 1,
+ 0,
+ 3,
+ METADATA_GROUP,
+ NoopBus,
+ LocalPipeline::new(),
+ ConsensusClock::system(),
+ );
+ let primary = u8::try_from(VIEW % 3).unwrap();
+
+ for head in [FIRST_HEAD, NEXT_HEAD] {
+ let start_view = make_start_view(VIEW, head, head, primary, 0);
+ assert!(
+ !consensus
+ .handle_start_view(PlaneKind::Metadata, start_view.header(), &[])
+ .is_empty()
+ );
assert!(consensus.view_log_unproven());
assert_eq!(consensus.commit_max(), head);
}
@@ -5590,6 +6402,53 @@ mod timestamp_clamp_tests {
assert!(!consensus.view_log_unproven());
}
+ #[test]
+ fn given_a_new_adoption_during_a_write_when_the_old_write_completes_should_keep_the_gate_closed()
+ {
+ for group in [METADATA_GROUP, IggyNamespace::new(1, 1, 1).inner()] {
+ let consensus = VsrConsensus::new(1, 0, 3, group, NoopBus, LocalPipeline::new());
+ let start_view = |head| {
+ let message = make_start_view(4, head, 0, 1, 0);
+ let original = *message.header();
+ message.transmute_header(|_, header: &mut StartViewHeader| {
+ *header = StartViewHeader { group, ..original };
+ })
+ };
+ consensus.handle_start_view(PlaneKind::Metadata, start_view(2).header(), &[]);
+ let first = consensus.vsr_state(0, 0);
+ consensus.handle_start_view(PlaneKind::Metadata, start_view(3).header(), &[]);
+ consensus.mark_superblock_durable(first.view, first.log_view);
+ consensus.mark_history_certificate_durable(first.history_certificate());
+ consensus.mark_adopted_view_durable(first.adopted_view.as_ref());
+ assert!(consensus.needs_superblock_persist());
+ let latest = consensus.vsr_state(0, 0);
+ consensus.mark_superblock_durable(latest.view, latest.log_view);
+ consensus.mark_history_certificate_durable(latest.history_certificate());
+ consensus.mark_adopted_view_durable(latest.adopted_view.as_ref());
+ assert!(!consensus.needs_superblock_persist());
+ assert_eq!(consensus.adopted_view_head(), Some((4, 3)));
+ assert!(consensus.view_log_unproven());
+ assert!(!consensus.view_log_entry_proven(3));
+ }
+ }
+
+ #[test]
+ fn given_malformed_start_view_evidence_when_adopting_should_leave_the_old_identity_intact() {
+ const BODY_ALIGN: usize = align_of::<PrepareHeader>();
+ let consensus = VsrConsensus::new(1, 0, 3, METADATA_GROUP, NoopBus, LocalPipeline::new());
+ let header = make_start_view(4, 2, 0, 1, 0);
+ let mut body: AVec<u8, ConstAlign<BODY_ALIGN>> = AVec::new(BODY_ALIGN);
+ body.push(0);
+ assert!(
+ consensus
+ .handle_start_view_guarded(PlaneKind::Metadata, header.header(), &body, |_| None)
+ .is_none()
+ );
+ assert_eq!(consensus.view(), 0);
+ assert_eq!(consensus.adopted_view_head(), None);
+ assert!(!consensus.needs_superblock_persist());
+ }
+
/// The split-brain gate's predicate: `view` and `log_view` each independently
/// make the superblock stale, and only a matching `mark_superblock_durable`
/// clears it. The simulator proves the withheld-send behavior end to end; this
@@ -5629,6 +6488,7 @@ mod timestamp_clamp_tests {
#[cfg(test)]
mod vsr_consensus_tests {
use super::*;
+ use futures::FutureExt;
use message_bus::BusMessage;
#[test]
@@ -5705,6 +6565,237 @@ mod vsr_consensus_tests {
assert!(!consensus.is_transferring());
}
+ #[test]
+ fn given_ready_replica_when_requesting_primary_transfer_should_select_next_target_view() {
+ const REPLICAS: u8 = 5;
+ const INCARNATION: u64 = 17;
+ for (replica, view, target, next_view) in [
+ (1, 0, 3, 3),
+ (3, 6, 3, 8),
+ (1, 6, 4, 9),
+ (2, 9, 1, 11),
+ (2, 4, 0, 5),
+ (2, u32::MAX - 1, 0, u32::MAX),
+ ] {
+ let mut consensus =
+ VsrConsensus::new(1, replica, REPLICAS, 0, StageNoopBus, LocalPipeline::new());
+ consensus.set_view(view);
+ consensus.set_log_view(view);
+ consensus.set_partition_incarnation(INCARNATION);
+ consensus.mark_superblock_durable(view, view);
+ consensus.init();
+
+ let actions = consensus.request_primary_transfer(view, target);
+
+ assert!(matches!(
+ actions.as_slice(),
+ [VsrAction::SendStartViewChange { view, group: 0 }] if *view == next_view
+ ));
+ assert_eq!(consensus.view(), next_view);
+ assert_eq!(consensus.primary_index(next_view), target);
+ assert_eq!(consensus.status(), Status::ViewChange);
+ assert_eq!(consensus.log_view(), view);
+ assert_eq!(consensus.partition_incarnation(), INCARNATION);
+ assert!(consensus.needs_superblock_persist());
+ assert_eq!(consensus.svc_count_excluding_self(), 0);
+ assert!(consensus.sent_own_start_view_change.get());
+ }
+ }
+
+ #[test]
+ fn given_backup_transfer_when_primary_receives_svc_should_join_the_target_election() {
+ const TARGET: u8 = 2;
+ let backup = VsrConsensus::new(1, 1, 3, 0, StageNoopBus, LocalPipeline::new());
+ let primary = VsrConsensus::new(1, 0, 3, 0, StageNoopBus, LocalPipeline::new());
+ backup.init();
+ primary.init();
+ assert!(!backup.request_primary_transfer(0, TARGET).is_empty());
+ assert_eq!(primary.status(), Status::Normal);
+
+ let message = Message::<StartViewChangeHeader>::new(size_of::<StartViewChangeHeader>())
+ .transmute_header(|_, header: &mut StartViewChangeHeader| {
+ header.command = Command::StartViewChange;
+ header.cluster = 1;
+ header.replica = backup.replica();
+ header.view = backup.view();
+ header.group = backup.group();
+ header.size = u32::try_from(size_of::<StartViewChangeHeader>()).unwrap();
+ header.seal();
+ });
+ let actions = primary.handle_start_view_change(PlaneKind::Partitions, message.header());
+
+ assert_eq!(primary.view(), u32::from(TARGET));
+ assert_eq!(primary.status(), Status::ViewChange);
+ assert_eq!(primary.log_view(), 0);
+ assert!(
+ actions.iter().any(|action| matches!(
+ action,
+ VsrAction::SendStartViewChange { view: 2, group: 0 }
+ ))
+ );
+ assert!(actions.iter().any(|action| matches!(
+ action,
+ VsrAction::SendDoViewChange {
+ target: TARGET,
+ view: 2,
+ ..
+ }
+ )));
+ assert!(primary.pending_view_log().is_none());
+ }
+
+ #[test]
+ fn given_pending_mutation_when_requesting_primary_transfer_should_preserve_identity_and_wake_waiter()
+ {
+ const COMMITTED: u64 = 7;
+ const INCARNATION: u64 = 17;
+ let consensus = VsrConsensus::new(1, 0, 3, 0, StageNoopBus, LocalPipeline::new());
+ consensus.init();
+ consensus.set_partition_incarnation(INCARNATION);
+ consensus.sequencer().set_sequence(COMMITTED);
+ consensus.restore_commit_state(COMMITTED, COMMITTED);
+ let message = projected_prepare(COMMITTED + 1, 0xfeed).transmute_header(
+ |previous, header: &mut PrepareHeader| {
+ *header = previous;
+ header.operation = Operation::SendMessages;
+ header.client = 31;
+ header.session = 37;
+ header.request = 41;
+ header.partition_incarnation = INCARNATION;
+ },
+ );
+ let receiver = consensus.pipeline_message_with_subscriber(PlaneKind::Partitions, &message);
+ let pending = *message.header();
+ let checksum = consensus.last_prepare_checksum();
+
+ assert!(!consensus.request_primary_transfer(0, 2).is_empty());
+
+ assert_eq!(consensus.commit_min(), COMMITTED);
+ assert_eq!(consensus.commit_max(), COMMITTED);
+ assert_eq!(consensus.sequencer().current_sequence(), pending.op);
+ assert_eq!(consensus.last_prepare_checksum(), checksum);
+ consensus.with_pipeline(|pipeline| {
+ let entry = pipeline.entry_by_op(pending.op).unwrap();
+ assert_eq!(
+ bytemuck::bytes_of(&entry.header),
+ bytemuck::bytes_of(&pending)
+ );
+ assert!(!entry.has_reply_sender());
+ });
+ assert!(matches!(
+ receiver.now_or_never(),
+ Some(Err(crate::oneshot::Canceled))
+ ));
+ assert_eq!(consensus.log_view(), 0);
+ }
+
+ #[test]
+ fn given_unready_or_stale_transfer_when_requested_should_leave_consensus_unchanged() {
+ const VIEW: u32 = 6;
+ for reason in [
+ "stale view",
+ "existing primary",
+ "unknown target",
+ "recovering",
+ "view change",
+ "ceded",
+ "state transfer",
+ "unproved history",
+ "newer view observed",
+ "unapplied commit",
+ "recovery barrier",
+ "view start head",
+ ] {
+ let mut consensus = VsrConsensus::new(1, 2, 5, 0, StageNoopBus, LocalPipeline::new());
+ consensus.set_view(VIEW);
+ consensus.set_log_view(VIEW);
+ consensus.mark_superblock_durable(VIEW, VIEW);
+ consensus.init();
+ let mut expected_view = VIEW;
+ let mut target = 3;
+ match reason {
+ "stale view" => expected_view -= 1,
+ "existing primary" => target = consensus.primary_index(VIEW),
+ "unknown target" => target = consensus.replica_count(),
+ "recovering" => consensus.begin_view_probe(),
+ "view change" => {
+ let _ = consensus.enter_view_change(
+ PlaneKind::Partitions,
+ VIEW,
+ ViewChangeReason::ReceivedStartViewChange,
+ );
+ }
+ "ceded" => consensus.init_as_backup(),
+ "state transfer" => consensus.begin_state_transfer_await(),
+ "unproved history" => consensus.fence_view_log(1, 1),
+ "newer view observed" => consensus.observe_newer_view(VIEW + 1),
+ "unapplied commit" => consensus.advance_commit_max(1),
+ "recovery barrier" => consensus.set_recovery_barrier(1),
+ "view start head" => consensus.view_start_head.set(1),
+ _ => unreachable!(),
+ }
+ let before = (
+ consensus.view(),
+ consensus.log_view(),
+ consensus.status(),
+ consensus.commit_min(),
+ consensus.commit_max(),
+ consensus.sequencer().current_sequence(),
+ consensus.svc_count_excluding_self(),
+ consensus.sent_own_start_view_change.get(),
+ consensus.needs_superblock_persist(),
+ );
+ let timers = format!("{:?}", consensus.timeouts.borrow());
+
+ assert!(
+ consensus
+ .request_primary_transfer(expected_view, target)
+ .is_empty(),
+ "accepted {reason}"
+ );
+
+ assert_eq!(
+ before,
+ (
+ consensus.view(),
+ consensus.log_view(),
+ consensus.status(),
+ consensus.commit_min(),
+ consensus.commit_max(),
+ consensus.sequencer().current_sequence(),
+ consensus.svc_count_excluding_self(),
+ consensus.sent_own_start_view_change.get(),
+ consensus.needs_superblock_persist(),
+ ),
+ "mutated {reason}"
+ );
+ assert_eq!(
+ timers,
+ format!("{:?}", consensus.timeouts.borrow()),
+ "rearmed {reason}"
+ );
+ }
+ }
+
+ #[test]
+ fn given_metadata_or_exhausted_view_when_requesting_primary_transfer_should_refuse() {
+ for (group, view) in [(METADATA_GROUP, 0), (0, u32::MAX)] {
+ let mut consensus =
+ VsrConsensus::new(1, 0, 5, group, StageNoopBus, LocalPipeline::new());
+ consensus.set_view(view);
+ consensus.set_log_view(view);
+ consensus.mark_superblock_durable(view, view);
+ consensus.init();
+
+ assert!(consensus.request_primary_transfer(view, 1).is_empty());
+ assert_eq!(consensus.view(), view);
+ assert_eq!(consensus.log_view(), view);
+ assert_eq!(consensus.status(), Status::Normal);
+ assert!(!consensus.needs_superblock_persist());
+ assert!(!consensus.sent_own_start_view_change.get());
+ }
+ }
+
// A backup left behind on VIEW (partition healed across an election, or a
// fresh node that never saw it) keeps getting newer-view heartbeats it
// cannot process -- they never reset its heartbeat timer, so the timer
@@ -5935,6 +7026,183 @@ mod vsr_consensus_tests {
);
}
+ #[test]
+ fn given_a_commit_advance_when_the_next_tick_runs_should_announce_it_once_unless_a_prepare_carries_it()
+ {
+ let consensus = VsrConsensus::new(1, 0, 3, 0, StageNoopBus, LocalPipeline::new());
+ consensus.set_commit_message_ticks(u64::MAX);
+ consensus.init();
+ let announced = || -> Vec<u64> {
+ tick_commits(&consensus)
+ .into_iter()
+ .map(|(_, commit)| commit)
+ .collect()
+ };
+ let commit_head = |op: u64| commit_acked_head(&consensus, op);
+ let stamped = |op: u64, commit: u64| {
+ projected_prepare(op, 0).transmute_header(|mut header, stamped: &mut PrepareHeader| {
+ header.commit = commit;
+ *stamped = header;
+ })
+ };
+
+ pipeline(&consensus, &projected_prepare(1, 0));
+ commit_head(1);
+ consensus.request_eager_commit();
+ assert_eq!(announced(), [1], "requests before a tick share one Commit");
+ assert!(announced().is_empty(), "one Commit per commit advance");
+
+ pipeline(&consensus, &projected_prepare(2, 0));
+ commit_head(2);
+ // A queued request promoted by the commit, stamped with the new commit point.
+ pipeline(&consensus, &stamped(3, 2));
+ assert!(
+ announced().is_empty(),
+ "the promoted prepare already tells the backups"
+ );
+
+ commit_head(3);
+ assert_eq!(announced(), [3]);
+
+ // Prepared while op 4 was in flight, so it carries the older commit point.
+ pipeline(&consensus, &projected_prepare(4, 0));
+ pipeline(&consensus, &stamped(5, 3));
+ commit_head(4);
+ assert_eq!(
+ announced(),
+ [4],
+ "a prepare stamped before the commit does not tell the backups"
+ );
+
+ // Op 6 is prepared before the commit of op 5 and op 7 after it.
+ pipeline(&consensus, &stamped(6, 4));
+ commit_head(5);
+ pipeline(&consensus, &stamped(7, 5));
+ assert!(
+ announced().is_empty(),
+ "the newest prepare tells the backups, though an older one does not"
+ );
+ }
+
+ #[test]
+ fn given_a_requested_commit_when_the_heartbeat_fires_on_that_tick_should_send_one_commit() {
+ const HEARTBEAT_TICKS: u64 = 2;
+ let consensus = VsrConsensus::new(1, 0, 3, 0, StageNoopBus, LocalPipeline::new());
+ consensus.set_commit_message_ticks(HEARTBEAT_TICKS);
+ consensus.init();
+ let commits = || tick_commits(&consensus).len();
+
+ assert_eq!(commits(), 0, "the heartbeat is not due yet");
+ pipeline(&consensus, &projected_prepare(1, 0));
+ commit_acked_head(&consensus, 1);
+ assert_eq!(commits(), 1, "the heartbeat answers the request");
+ assert_eq!(commits(), 0, "the heartbeat consumed the request");
+ }
+
+ #[test]
+ fn given_a_backup_that_acks_a_committed_op_late_when_the_next_tick_runs_should_send_it_a_commit_covering_the_op()
+ {
+ let consensus =
+ VsrConsensus::new(1, 0, 3, METADATA_GROUP, StageNoopBus, LocalPipeline::new());
+ consensus.set_commit_message_ticks(u64::MAX);
+ consensus.init();
+ let late_ack = |op: u64| {
+ consensus.handle_late_prepare_ok(&prepare_ok(&consensus, LATE_BACKUP, op));
+ };
+
+ pipeline(&consensus, &projected_prepare(1, 0));
+ pipeline(&consensus, &projected_prepare(2, 0));
+ commit_acked_head(&consensus, 1);
+ commit_acked_head(&consensus, 2);
+ assert_eq!(
+ tick_commits(&consensus),
+ [(Some(ACKING_BACKUP), 2)],
+ "only a backup that acked the op holds it"
+ );
+
+ late_ack(1);
+ assert_eq!(
+ tick_commits(&consensus),
+ [(Some(LATE_BACKUP), 1)],
+ "a late ack gets a Commit at once, naming no op the backup lacks"
+ );
+ late_ack(2);
+ assert_eq!(tick_commits(&consensus), [(Some(LATE_BACKUP), 2)]);
+ late_ack(2);
+ assert!(
+ tick_commits(&consensus).is_empty(),
+ "a Commit already covers a repeated ack"
+ );
+ }
+
+ #[test]
+ fn given_a_backup_that_never_acks_when_the_heartbeat_fires_should_still_send_it_the_commit() {
+ const HEARTBEAT_TICKS: u64 = 4;
+ let consensus =
+ VsrConsensus::new(1, 0, 3, METADATA_GROUP, StageNoopBus, LocalPipeline::new());
+ consensus.set_commit_message_ticks(HEARTBEAT_TICKS);
+ consensus.init();
+
+ pipeline(&consensus, &projected_prepare(1, 0));
+ commit_acked_head(&consensus, 1);
+ assert_eq!(
+ tick_commits(&consensus),
+ [(Some(ACKING_BACKUP), 1)],
+ "the eager Commit skips a backup that never acked"
+ );
+ let heartbeats: Vec<_> = (1..HEARTBEAT_TICKS)
+ .flat_map(|_| tick_commits(&consensus))
+ .collect();
+ assert_eq!(
+ heartbeats,
+ [(None, 1)],
+ "the heartbeat still goes to every backup"
+ );
+
+ consensus.handle_late_prepare_ok(&prepare_ok(&consensus, LATE_BACKUP, 1));
+ assert!(
+ tick_commits(&consensus).is_empty(),
+ "the heartbeat already covered the op"
+ );
+ }
+
+ #[test]
+ fn given_a_requested_commit_when_the_replica_stops_leading_before_the_tick_should_not_announce_it()
+ {
+ for reason in ["demoted", "view change", "newer view observed"] {
+ let consensus =
+ VsrConsensus::new(1, 0, 3, METADATA_GROUP, StageNoopBus, LocalPipeline::new());
+ consensus.set_commit_message_ticks(u64::MAX);
+ consensus.init();
+ pipeline(&consensus, &projected_prepare(1, 0));
+ commit_acked_head(&consensus, 1);
+ match reason {
+ "demoted" => {
+ let start_view = test_bus::make_start_view(1, 1, 1, 1, 0);
+ let _ =
+ consensus.handle_start_view(PlaneKind::Metadata, start_view.header(), &[]);
+ }
+ // Replica 0 leads view 3 too, so only its status holds the Commit back.
+ "view change" => {
+ let _ = consensus.enter_view_change(
+ PlaneKind::Metadata,
+ 3,
+ ViewChangeReason::ReceivedStartViewChange,
+ );
+ }
+ "newer view observed" => consensus.observe_newer_view(1),
+ _ => unreachable!(),
+ }
+ assert!(
+ !consensus
+ .tick(PlaneKind::Metadata)
+ .iter()
+ .any(|action| matches!(action, VsrAction::SendCommit { .. })),
+ "announced a commit after {reason}"
+ );
+ }
+ }
+
#[test]
fn given_an_old_primary_when_the_new_primary_heartbeats_should_remain_in_the_new_view() {
for enters_election in [false, true] {
@@ -6077,6 +7345,50 @@ mod vsr_consensus_tests {
consensus.pipeline_message(PlaneKind::Metadata, message);
}
+ /// The backup whose ack the eager `Commit` tests record for each op they
+ /// commit, in a 3-replica group led by replica 0.
+ const ACKING_BACKUP: u8 = 1;
+ /// The backup those tests withhold acks from, as the chain tail's arrive
+ /// after the quorum.
+ const LATE_BACKUP: u8 = 2;
+
+ /// The `PrepareOk` `replica` sends in the current view for an op pipelined
+ /// from [`projected_prepare`].
+ fn prepare_ok(consensus: &VsrConsensus<StageNoopBus>, replica: u8, op: u64) -> PrepareOkHeader {
+ PrepareOkHeader {
+ command: Command::PrepareOk,
+ replica,
+ view: consensus.view(),
+ op,
+ ..Default::default()
+ }
+ }
+
+ /// Commit the pipeline head `op` the way the metadata plane does, after an
+ /// ack from [`ACKING_BACKUP`].
+ fn commit_acked_head(consensus: &VsrConsensus<StageNoopBus>, op: u64) {
+ let _ = consensus.handle_prepare_ok(
+ PlaneKind::Metadata,
+ &prepare_ok(consensus, ACKING_BACKUP, op),
+ );
+ consensus.advance_commit_max(op);
+ assert_eq!(consensus.pop_committed_prepare().unwrap().header.op, op);
+ consensus.advance_commit_min(op);
+ consensus.request_eager_commit();
+ }
+
+ /// The `Commit`s the next tick sends, as `(target, commit)`.
+ fn tick_commits(consensus: &VsrConsensus<StageNoopBus>) -> Vec<(Option<u8>, u64)> {
+ consensus
+ .tick(PlaneKind::Metadata)
+ .into_iter()
+ .filter_map(|action| match action {
+ VsrAction::SendCommit { target, commit, .. } => Some((target, commit)),
+ _ => None,
+ })
+ .collect()
+ }
+
use iggy_binary_protocol::Operation;
/// Clock frozen at a fixed instant, so a stamp read off it is assertable.
@@ -6765,3 +8077,453 @@ mod probe_answer_tests {
);
}
}
+
+#[cfg(test)]
+mod history_certificate_tests {
+ use super::*;
+ use crate::LocalPipeline;
+ use crate::test_bus::NoopBus;
+
+ #[test]
+ fn given_a_metadata_replica_when_its_history_moves_should_gate_acks_and_persistence() {
+ const ADOPTED_VIEW: u32 = 2;
+ let mut consensus =
+ VsrConsensus::new(1, 0, 3, METADATA_GROUP, NoopBus, LocalPipeline::new());
+ consensus.init();
+ let current = |consensus: &VsrConsensus<NoopBus, LocalPipeline>| {
+ consensus.vsr_state(0, 0).history_certificate()
+ };
+ assert!(consensus.history_certified());
+ assert!(!consensus.needs_superblock_persist());
+
+ // Following the WAL head forces no write.
+ consensus.extend_history_certificate(5, 55);
+ assert_eq!(current(&consensus), Some(HistoryCertificate::new(0, 5, 55)));
+ assert!(!consensus.needs_superblock_persist());
+ consensus.mark_history_certificate_durable(current(&consensus));
+
+ // An adopted view whose log is unproven: no acks, and nothing it journals
+ // moves the binding.
+ consensus.set_view(ADOPTED_VIEW);
+ consensus.set_log_view(ADOPTED_VIEW);
+ consensus.mark_superblock_durable(ADOPTED_VIEW, ADOPTED_VIEW);
+ assert!(!consensus.history_certified());
+ consensus.extend_history_certificate(6, 66);
+ assert_eq!(current(&consensus), Some(HistoryCertificate::new(0, 5, 55)));
+
+ // A rewrite off the bound entry: the lowered binding must be durable before
+ // the entry goes.
+ consensus.lower_history_certificate(5, 4, 44);
+ assert_eq!(current(&consensus), Some(HistoryCertificate::new(0, 4, 44)));
+ assert!(consensus.needs_superblock_persist());
+ consensus.mark_history_certificate_durable(current(&consensus));
+ assert!(!consensus.needs_superblock_persist());
+ consensus.lower_history_certificate(6, 3, 33);
+ assert_eq!(
+ current(&consensus),
+ Some(HistoryCertificate::new(0, 4, 44)),
+ "a rewrite above the binding leaves it"
+ );
+
+ // Proven: the view is certified, and recorded before the first ack.
+ consensus.certify_view_log(7, 77);
+ assert!(consensus.history_certified());
+ assert!(consensus.needs_superblock_persist());
+ consensus.mark_history_certificate_durable(current(&consensus));
+ consensus.extend_history_certificate(8, 88);
+ assert_eq!(
+ current(&consensus),
+ Some(HistoryCertificate::new(ADOPTED_VIEW, 8, 88))
+ );
+ assert!(!consensus.needs_superblock_persist());
+ }
+
+ #[test]
+ fn given_a_restart_with_an_unproven_wal_when_restored_should_fence_it_and_stay_out_of_elections()
+ {
+ let consensus = VsrConsensus::new(1, 0, 3, METADATA_GROUP, NoopBus, LocalPipeline::new());
+ consensus.init_as_backup();
+ let legacy = VsrState {
+ cluster: 1,
+ replica_id: 0,
+ replica_count: 3,
+ view: 4,
+ log_view: 4,
+ commit_max: 0,
+ checkpoint_op: 0,
+ checkpoint_checksum: 0,
+ offset_frontier: 0,
+ offset_reserved: 0,
+ adopted_view: None,
+ };
+ consensus.restore_history(HistoryRecovery::Unproven, (9, 99), Some(&legacy));
+ assert!(consensus.view_log_unproven());
+ assert!(!consensus.view_log_entry_proven(consensus.commit_min() + 1));
+ assert!(!consensus.history_certified());
+ assert!(!consensus.needs_superblock_persist());
+ assert!(
+ !consensus.recovery_election_allowed(),
+ "an unanswered probe must not let an unproven log vote"
+ );
+
+ consensus.certify_view_log(9, 99);
+ assert!(consensus.history_certified());
+ assert!(consensus.recovery_election_allowed());
+ }
+
+ #[test]
+ fn given_a_partition_group_when_recording_should_keep_its_offset_words() {
+ let group = IggyNamespace::new(0, 0, 0).inner();
+ let mut consensus = VsrConsensus::new(1, 0, 3, group, NoopBus, LocalPipeline::new());
+ consensus.init();
+ consensus.set_view(1);
+ consensus.set_log_view(1);
+ assert!(consensus.history_certified());
+ let state = consensus.vsr_state(0, 0);
+ assert_eq!((state.offset_frontier, state.offset_reserved), (0, 0));
+ }
+}
+
+#[cfg(test)]
+mod adopted_view_claim_tests {
+ //! An adopted claim must retain its announced head and exact identity across
+ //! elections and recovery. A body certificate alone does not preserve them;
+ //! advertising the adopted view with the shorter WAL head loses committed ops.
+ //!
+ //! A backup that already applied an op must refuse a `StartView` naming a
+ //! different prepare there: adopting it would hide the loss.
+
+ use super::*;
+ use crate::LocalPipeline;
+ use crate::test_bus::{NoopBus, make_start_view};
+ use crate::view_change_quorum::{DvcSuffix, StoredDvc, dvc_quorum_array_empty, dvc_record};
+
+ /// The view the cluster committed `HEAD` in.
+ const OLD_VIEW: u32 = 10;
+ /// The view whose `StartView` announced `HEAD` to the backup.
+ const ADOPTED_VIEW: u32 = 11;
+ const ADOPTED_VIEW_PRIMARY: u8 = 2;
+ const BACKUP: u8 = 0;
+ /// The view-10 primary, which committed `HEAD` and then restarted.
+ const OLD_PRIMARY: u8 = 1;
+ /// Committed in `OLD_VIEW`; the backup never receives its body.
+ const HEAD: u64 = 26;
+
+ /// Hash-chained prepare headers for ops `1..=HEAD`, indexed by op.
+ fn history() -> Vec<PrepareHeader> {
+ let mut headers = vec![PrepareHeader::default()];
+ for op in 1..=HEAD {
+ let mut header = PrepareHeader {
+ command: Command::Prepare,
+ cluster: 1,
+ group: METADATA_GROUP,
+ timestamp: op,
+ operation: Operation::CreateStream,
+ size: u32::try_from(size_of::<PrepareHeader>()).unwrap(),
+ op,
+ commit: op - 1,
+ parent: headers[usize::try_from(op - 1).unwrap()].checksum,
+ view: OLD_VIEW,
+ ..PrepareHeader::default()
+ };
+ header.checksum = header.identity_checksum();
+ headers.push(header);
+ }
+ headers
+ }
+
+ fn at(history: &[PrepareHeader], op: u64) -> PrepareHeader {
+ history[usize::try_from(op).unwrap()]
+ }
+
+ /// A suffix for `low..=high` holding every body, high-to-low.
+ fn held(history: &[PrepareHeader], low: u64, high: u64) -> DvcSuffix {
+ let headers: Vec<PrepareHeader> = (low..=high).rev().map(|op| at(history, op)).collect();
+ let present = (1u128 << headers.len()) - 1;
+ DvcSuffix::new(headers, 0, present)
+ }
+
+ /// A backup in normal status at `OLD_VIEW` whose journal ends at `head`.
+ fn backup_at(log_view: u32, view: u32, head: u64, commit: u64) -> VsrConsensus<NoopBus> {
+ let mut consensus =
+ VsrConsensus::new(1, BACKUP, 3, METADATA_GROUP, NoopBus, LocalPipeline::new());
+ consensus.restore_view(ViewRestore {
+ durable_view: Some((view, log_view)),
+ view_fallback: None,
+ seed_view: None,
+ });
+ consensus.restore_history(
+ HistoryRecovery::Certified {
+ view: log_view,
+ truncate_from: None,
+ },
+ (head, 0),
+ None,
+ );
+ consensus.init_as_backup();
+ consensus.sequencer().set_sequence(head);
+ consensus.restore_commit_state(commit, commit);
+ consensus
+ }
+
+ /// Adopt the view-11 `StartView` (head `HEAD`, commit `HEAD - 1`) with the
+ /// journal still ending at op 23.
+ fn adopt_unrepaired(history: &[PrepareHeader]) -> VsrConsensus<NoopBus> {
+ let backup = backup_at(OLD_VIEW, OLD_VIEW, 23, 22);
+ let suffix = [at(history, HEAD), at(history, HEAD - 1)];
+ backup.handle_start_view(
+ PlaneKind::Metadata,
+ make_start_view(ADOPTED_VIEW, HEAD, HEAD - 1, ADOPTED_VIEW_PRIMARY, 0).header(),
+ bytemuck::cast_slice(&suffix),
+ );
+ assert_eq!(backup.log_view(), ADOPTED_VIEW);
+ assert!(
+ backup.view_log_unproven(),
+ "the journal does not hold the head yet"
+ );
+ backup
+ }
+
+ fn dvc_of(consensus: &VsrConsensus<NoopBus>) -> StoredDvc {
+ let VsrAction::SendDoViewChange {
+ log_view,
+ op,
+ commit,
+ suffix,
+ ..
+ } = consensus.build_do_view_change(BACKUP)
+ else {
+ unreachable!("build_do_view_change builds a DoViewChange");
+ };
+ StoredDvc {
+ replica: consensus.replica(),
+ log_view,
+ op,
+ commit,
+ suffix,
+ }
+ }
+
+ /// The view-10 primary after its restart: `HEAD` journaled, commit point one
+ /// below it because nothing it journaled stamps the commit of `HEAD`.
+ fn old_primary(history: &[PrepareHeader]) -> StoredDvc {
+ StoredDvc {
+ replica: OLD_PRIMARY,
+ log_view: OLD_VIEW,
+ op: HEAD,
+ commit: HEAD - 1,
+ suffix: held(history, HEAD - 1, HEAD),
+ }
+ }
+
+ fn merged_head(consensus: &VsrConsensus<NoopBus>, senders: [StoredDvc; 2]) -> u64 {
+ let mut quorum = dvc_quorum_array_empty();
+ for dvc in senders {
+ assert!(dvc_record(&mut quorum, dvc));
+ }
+ match merge_dvc_quorum(&quorum, consensus.merge_quorums()) {
+ MergeOutcome::Ready(merged) => merged.op_head,
+ outcome => panic!("the quorum holds every body it needs, got {outcome:?}"),
+ }
+ }
+
+ #[test]
+ fn given_an_unrepaired_start_view_head_when_the_backup_restarts_should_keep_the_committed_head()
+ {
+ let history = history();
+ let adopted = adopt_unrepaired(&history);
+ // Persisted before any view-scoped send, so this is what survives the crash.
+ let durable = adopted.vsr_state(0, 0);
+
+ // Repair reached op 25 before the crash; op 26 never arrived.
+ let claimed_view = durable.history_certificate().unwrap().view;
+ let restarted = backup_at(claimed_view, durable.view, HEAD - 1, HEAD - 2);
+ restarted.restore_adopted_view(durable.adopted_view.as_ref());
+ restarted.resume_adopted_view();
+ restarted.fence_view_log(HEAD, 0);
+ let adopted_headers = restarted.adopted_view_log().unwrap().headers;
+ restarted.set_local_dvc_suffix(DvcSuffix::new(adopted_headers, 0, 0b10));
+ assert_eq!(restarted.sequencer().current_sequence(), HEAD);
+ assert_eq!(restarted.log_view_claim(), ADOPTED_VIEW);
+
+ assert_eq!(
+ merged_head(&restarted, [dvc_of(&restarted), old_primary(&history)]),
+ HEAD,
+ "the restarted backup claimed log_view {} with head {}, so the merge never \
+ scanned op {HEAD}, which view {OLD_VIEW} committed",
+ durable.log_view,
+ HEAD - 1,
+ );
+ }
+
+ #[test]
+ fn given_repaired_adopted_bodies_when_restoring_should_keep_the_older_certificate_binding() {
+ const CERTIFIED_HEAD: u64 = HEAD - 3;
+ let history = history();
+ let adopted = adopt_unrepaired(&history);
+ let certificate = HistoryCertificate::new(
+ OLD_VIEW,
+ CERTIFIED_HEAD,
+ at(&history, CERTIFIED_HEAD).checksum,
+ );
+ let durable = adopted
+ .vsr_state(0, 0)
+ .with_history_certificate(Some(certificate));
+ let recovery =
+ HistoryRecovery::new(Some(&durable), false, |op| Some(at(&history, op).checksum));
+ let restarted = backup_at(OLD_VIEW, durable.view, HEAD - 1, HEAD - 2);
+ restarted.restore_history(
+ recovery,
+ (HEAD - 1, at(&history, HEAD - 1).checksum),
+ Some(&durable),
+ );
+ restarted.restore_adopted_view(durable.adopted_view.as_ref());
+ restarted.resume_adopted_view();
+ assert_eq!(
+ restarted.vsr_state(0, 0).history_certificate(),
+ Some(certificate)
+ );
+ assert!(restarted.view_log_unproven());
+ assert!(!restarted.history_certified());
+ }
+
+ #[test]
+ fn given_an_unrepaired_start_view_head_when_a_view_change_drops_the_parked_headers_should_keep_the_committed_head()
+ {
+ let history = history();
+ let backup = adopt_unrepaired(&history);
+ // Reconcile found op 26 missing; repair brought ops 24 and 25.
+ backup.fence_view_log(HEAD, 0);
+ backup.enter_view_change(
+ PlaneKind::Metadata,
+ ADOPTED_VIEW + 1,
+ ViewChangeReason::NormalHeartbeatTimeout,
+ );
+ assert!(backup.pending_view_log().is_none());
+ // The accepted header distinguishes a missing body from a truncated op.
+ backup.set_local_dvc_suffix(DvcSuffix::new(
+ backup.adopted_view_log().unwrap().headers,
+ 0,
+ 0b10,
+ ));
+
+ assert_eq!(
+ merged_head(&backup, [dvc_of(&backup), old_primary(&history)]),
+ HEAD,
+ "the backup nacked op {HEAD} as a canonical sender of view {ADOPTED_VIEW}, so the \
+ view-{OLD_VIEW} primary's copy counted as a second nack",
+ );
+ }
+
+ #[test]
+ fn given_an_unrepaired_start_view_head_when_the_proof_settles_should_persist_the_adopted_log_view()
+ {
+ let history = history();
+ let backup = adopt_unrepaired(&history);
+ assert_eq!(
+ backup.vsr_state(0, 0).history_certificate().unwrap().view,
+ OLD_VIEW,
+ "the superblock must not record a view whose log the journal does not hold"
+ );
+ let durable = backup.vsr_state(0, 0);
+ backup.mark_superblock_durable(durable.view, durable.log_view);
+ backup.mark_adopted_view_durable(durable.adopted_view.as_ref());
+ assert!(!backup.needs_superblock_persist());
+
+ backup.settle_view_log_proof();
+ backup.certify_view_log(HEAD, at(&history, HEAD).checksum);
+ assert!(
+ backup.needs_superblock_persist(),
+ "the proven view must reach the superblock before the next view-scoped send"
+ );
+ assert_eq!(
+ backup.vsr_state(0, 0).history_certificate().unwrap().view,
+ ADOPTED_VIEW
+ );
+ }
+
+ /// A backup that applied every op through `HEAD` receives the view-11
+ /// `StartView` with `suffix` as its log at `HEAD`.
+ fn offer_applied_backup(
+ history: &[PrepareHeader],
+ suffix: PrepareHeader,
+ ) -> (VsrConsensus<NoopBus>, Option<Vec<VsrAction>>) {
+ let backup = backup_at(OLD_VIEW, OLD_VIEW, HEAD, HEAD);
+ let outcome = backup.handle_start_view_guarded(
+ PlaneKind::Metadata,
+ make_start_view(ADOPTED_VIEW, HEAD, HEAD, ADOPTED_VIEW_PRIMARY, 0).header(),
+ bytemuck::bytes_of(&suffix),
+ |op| (op <= HEAD).then(|| at(history, op)),
+ );
+ (backup, outcome)
+ }
+
+ #[test]
+ fn given_a_start_view_that_contradicts_an_applied_op_when_adopting_should_refuse_it() {
+ let history = history();
+ let mut forged = at(&history, HEAD);
+ forged.timestamp += 1;
+ forged.checksum = forged.identity_checksum();
+
+ let (backup, outcome) = offer_applied_backup(&history, forged);
+
+ assert!(
+ outcome.is_none(),
+ "a log naming another prepare at applied op {HEAD} must be refused, not adopted"
+ );
+ assert_eq!(
+ (
+ backup.view(),
+ backup.log_view(),
+ backup.sequencer().current_sequence()
+ ),
+ (OLD_VIEW, OLD_VIEW, HEAD),
+ "a refused StartView changes nothing"
+ );
+ assert_eq!(backup.applied_log_contradictions(), 1);
+ }
+
+ #[test]
+ fn given_a_start_view_that_matches_the_applied_ops_when_adopting_should_adopt_it() {
+ let history = history();
+
+ let (backup, outcome) = offer_applied_backup(&history, at(&history, HEAD));
+
+ assert!(outcome.is_some());
+ assert_eq!(backup.view(), ADOPTED_VIEW);
+ assert_eq!(backup.applied_log_contradictions(), 0);
+ }
+
+ #[test]
+ fn given_a_contradicting_start_view_when_redelivered_should_report_it_once_per_view() {
+ let history = history();
+ let mut forged = at(&history, HEAD);
+ forged.timestamp += 1;
+ forged.checksum = forged.identity_checksum();
+ let (backup, _) = offer_applied_backup(&history, forged);
+ let offer = |view| {
+ backup.handle_start_view_guarded(
+ PlaneKind::Metadata,
+ make_start_view(view, HEAD, HEAD, backup.primary_index(view), 0).header(),
+ bytemuck::bytes_of(&forged),
+ |op| (op <= HEAD).then(|| at(&history, op)),
+ )
+ };
+
+ assert!(offer(ADOPTED_VIEW).is_none(), "a redelivery is refused too");
+ assert_eq!(
+ backup.applied_log_contradictions(),
+ 1,
+ "a redelivered StartView must not be reported again"
+ );
+ let next_view = (ADOPTED_VIEW + 1..=ADOPTED_VIEW + u32::from(backup.replica_count()))
+ .find(|view| backup.primary_index(*view) != BACKUP)
+ .expect("primaryship rotates through every replica");
+ assert!(offer(next_view).is_none());
+ assert_eq!(
+ backup.applied_log_contradictions(),
+ 2,
+ "a contradiction in a newer view is reported"
+ );
+ }
+}
diff --git a/core/consensus/src/lib.rs b/core/consensus/src/lib.rs
index 0524d337c..9aed5ddad 100644
--- a/core/consensus/src/lib.rs
+++ b/core/consensus/src/lib.rs
@@ -211,6 +211,6 @@ pub use view_change_quorum::*;
mod dvc_merge;
pub use dvc_merge::*;
mod vsr_state;
-pub use vsr_state::{VsrState, VsrStateError};
+pub use vsr_state::{AdoptedView, HistoryCertificate, HistoryRecovery, VsrState, VsrStateError};
mod vsr_timeout;
pub use vsr_timeout::{TICK_INTERVAL, TimeoutManager};
diff --git a/core/consensus/src/metadata_helpers.rs b/core/consensus/src/metadata_helpers.rs
index 9e9a597e2..d8c162af7 100644
--- a/core/consensus/src/metadata_helpers.rs
+++ b/core/consensus/src/metadata_helpers.rs
@@ -427,10 +427,7 @@ where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
- consensus.is_primary()
- && !consensus.has_ceded_primaryship()
- && consensus.is_normal()
- && !consensus.is_transferring()
+ is_unceded_normal_primary(consensus)
// Recovery re-pipelines the WAL's prepared-but-uncommitted suffix;
// those ops were acked to clients before the restart, so admitting
// new requests (a login is a Register write) before the suffix
@@ -439,6 +436,32 @@ where
&& consensus.commit_max() >= consensus.recovery_barrier()
}
+/// [`is_caught_up_primary`] without the recovery barrier.
+///
+/// The barrier holds back only the recovered suffix above `commit_max`. State at
+/// or below `commit_min` is committed whatever becomes of that suffix, so a caller
+/// that only hands out such state, as a state transfer does, needs no barrier.
+/// Waiting for it can deadlock: when the only other live replica sits below this
+/// primary's WAL floor, a transfer is its one route to acking the suffix.
+pub fn is_caught_up_primary_except_barrier<B, P>(consensus: &VsrConsensus<B, P>) -> bool
+where
+ B: MessageBus,
+ P: Pipeline<Entry = PipelineEntry>,
+{
+ is_unceded_normal_primary(consensus) && consensus.commit_min() == consensus.commit_max()
+}
+
+fn is_unceded_normal_primary<B, P>(consensus: &VsrConsensus<B, P>) -> bool
+where
+ B: MessageBus,
+ P: Pipeline<Entry = PipelineEntry>,
+{
+ consensus.is_primary()
+ && !consensus.has_ceded_primaryship()
+ && consensus.is_normal()
+ && !consensus.is_transferring()
+}
+
/// Build + best-effort send `Eviction`. `SendError` dropped: eviction is
/// terminal one-way; gone connection has nothing to recover.
#[allow(clippy::future_not_send)]
@@ -1113,6 +1136,23 @@ mod tests {
assert!(!backup.is_primary());
assert!(!is_caught_up_primary(&backup));
assert!(!is_settled_primary(&backup));
+ assert!(!is_caught_up_primary_except_barrier(&backup));
+
+ // Recovered suffix above commit_max -> neither caught up nor settled, yet
+ // its committed state is servable.
+ let recovering = VsrConsensus::new(1, 0, 3, 0, ClientSpyBus::new(), LocalPipeline::new());
+ recovering.init();
+ recovering.set_recovery_barrier(recovering.commit_max() + 1);
+ assert!(!is_caught_up_primary(&recovering));
+ assert!(!is_settled_primary(&recovering));
+ assert!(is_caught_up_primary_except_barrier(&recovering));
+
+ // The barrier is the only clause it drops.
+ recovering.begin_state_transfer_await();
+ assert!(!is_caught_up_primary_except_barrier(&recovering));
+ recovering.set_state_transfer_stage(crate::StateTransferStage::Idle);
+ recovering.advance_commit_max(5);
+ assert!(!is_caught_up_primary_except_barrier(&recovering));
}
#[test]
diff --git a/core/consensus/src/observability.rs b/core/consensus/src/observability.rs
index d85a4da4e..a2b1f9e50 100644
--- a/core/consensus/src/observability.rs
+++ b/core/consensus/src/observability.rs
@@ -56,6 +56,7 @@ impl ReplicaRole {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewChangeReason {
NormalHeartbeatTimeout,
+ PrimaryTransfer,
/// The current view's primary sent a `RequestStartView` probe: it
/// cannot lead (restarted, or abandoned the view mid-view-change), so
/// peers elect without waiting for its heartbeats to stop arriving
@@ -75,6 +76,7 @@ impl ViewChangeReason {
pub const fn as_str(self) -> &'static str {
match self {
Self::NormalHeartbeatTimeout => "normal_heartbeat_timeout",
+ Self::PrimaryTransfer => "primary_transfer",
Self::PrimaryProbedView => "primary_probed_view",
Self::ViewProbeUnanswered => "view_probe_unanswered",
Self::ViewChangeStatusTimeout => "view_change_status_timeout",
@@ -344,10 +346,12 @@ impl ControlActionLogEvent {
op: Some(op),
commit: Some(commit),
},
- VsrAction::SendStartView { op, commit, .. } => Self {
+ VsrAction::SendStartView {
+ op, commit, target, ..
+ } => Self {
replica,
action: ControlActionKind::SendStartView,
- target_replica: None,
+ target_replica: target,
op: Some(op),
commit: Some(commit),
},
@@ -381,10 +385,10 @@ impl ControlActionLogEvent {
op: None,
commit: None,
},
- VsrAction::SendCommit { commit, .. } => Self {
+ VsrAction::SendCommit { commit, target, .. } => Self {
replica,
action: ControlActionKind::SendCommit,
- target_replica: None,
+ target_replica: target,
op: None,
commit: Some(commit),
},
@@ -963,7 +967,7 @@ impl StructuredSimEvent for ControlActionLogEvent {
status = status_as_str(ctx.status),
role = ctx.role.as_str(),
action = self.action.as_str(),
- target_replica = self.target_replica.unwrap_or_default(),
+ target_replica = self.target_replica,
op = self.op.unwrap_or_default(),
action_commit = self.commit.unwrap_or_default(),
);
diff --git a/core/consensus/src/plane_helpers.rs b/core/consensus/src/plane_helpers.rs
index 78e54da70..89a87cab9 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -16,8 +16,8 @@
// under the License.
use crate::{
- Consensus, DvcHeaderKind, IgnoreReason, Pipeline, PipelineEntry, PlaneKind, PrepareOkOutcome,
- Sequencer, Status, VsrConsensus, dvc_header_kind,
+ Consensus, DvcHeaderKind, IgnoreReason, MergedLog, Pipeline, PipelineEntry, PlaneKind,
+ PrepareOkOutcome, Sequencer, Status, VsrConsensus, dvc_header_kind,
};
use iggy_binary_protocol::consensus::{REJECTION_SECTION_LEN, write_rejection_section};
use iggy_binary_protocol::{
@@ -853,7 +853,10 @@ where
return false;
}
- if consensus.is_transferring() || consensus.view_log_unproven() {
+ if consensus.is_transferring()
+ || consensus.view_log_unproven()
+ || !consensus.history_certified()
+ {
return false;
}
@@ -911,11 +914,23 @@ pub enum ViewLogProof {
op: u64,
unproven_through: u64,
},
- Divergent(u64),
+ /// This replica holds an entry at every op in `first..=last`, and each differs
+ /// from the view; retained entries above `last` match it.
+ Divergent {
+ first: u64,
+ last: u64,
+ },
}
/// Prove held entries against the view's hash chain. Canonical headers can
/// bridge missing bodies; a gap with no header leaves its lower prefix unknown.
+///
+/// A canonical header off the chain is no header, so its op is such a gap. It is
+/// no divergence: nothing is held there to remove, and the next proof would name
+/// the same op again.
+///
+/// A divergence spans every contiguous held entry the chain still proves wrong,
+/// so the caller removes it with one rewrite instead of one per op.
#[must_use]
pub fn view_log_proof(
commit_floor: u64,
@@ -931,6 +946,7 @@ pub fn view_log_proof(
.first()
.filter(|head| head.op == op_head && dvc_header_kind(head) != DvcHeaderKind::Blank)
else {
+ // No canonical checksum to test a resident head against, so fetch one.
return ViewLogProof::Missing {
op: op_head,
unproven_through: op_head,
@@ -941,11 +957,12 @@ pub fn view_log_proof(
for op in (proven + 1..=op_head).rev() {
let header = if let Some(local) = header_at(op) {
local
- } else if let Some(header) = canonical
- .iter()
- .find(|header| header.op == op && dvc_header_kind(header) != DvcHeaderKind::Blank)
+ } else if let Some(header) = canonical_header_at(canonical, op)
+ .filter(|header| header.checksum != CHECKSUM_UNSEALED && header.checksum == expected)
{
- missing.get_or_insert(op);
+ // The lowest missing body, not the highest: the repair window opens at
+ // it and runs up to the head, so one round fetches every bridged body.
+ missing = Some(op);
*header
} else {
return ViewLogProof::Missing {
@@ -954,7 +971,10 @@ pub fn view_log_proof(
};
};
if header.checksum == CHECKSUM_UNSEALED || header.checksum != expected {
- return ViewLogProof::Divergent(op);
+ return ViewLogProof::Divergent {
+ first: divergent_run_first(op, expected, proven, canonical, &header_at),
+ last: op,
+ };
}
expected = header.parent;
}
@@ -964,6 +984,43 @@ pub fn view_log_proof(
})
}
+fn canonical_header_at(canonical: &[PrepareHeader], op: u64) -> Option<&PrepareHeader> {
+ canonical
+ .iter()
+ .find(|header| header.op == op && dvc_header_kind(header) != DvcHeaderKind::Blank)
+}
+
+/// Lowest op of the divergent run ending at `last`, where the view expects `expected`.
+///
+/// A held entry below joins the run when it differs from the parent that the
+/// view's header one op up names, the same test the walk applies. The run ends
+/// where that header is missing or off the chain, so an entry past an unknown op
+/// stays.
+fn divergent_run_first(
+ last: u64,
+ mut expected: u128,
+ proven: u64,
+ canonical: &[PrepareHeader],
+ header_at: &impl Fn(u64) -> Option<PrepareHeader>,
+) -> u64 {
+ let mut first = last;
+ while first - 1 > proven {
+ let Some(named) = canonical_header_at(canonical, first)
+ .filter(|named| named.checksum != CHECKSUM_UNSEALED && named.checksum == expected)
+ else {
+ break;
+ };
+ expected = named.parent;
+ if header_at(first - 1)
+ .is_none_or(|local| local.checksum != CHECKSUM_UNSEALED && local.checksum == expected)
+ {
+ break;
+ }
+ first -= 1;
+ }
+ first
+}
+
fn locally_proven_commit(
commit_floor: u64,
header_at: &impl Fn(u64) -> Option<PrepareHeader>,
@@ -987,6 +1044,94 @@ fn locally_proven_commit(
proven
}
+/// What a replica's own log says about a repaired prepare before it journals it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RepairEvidence {
+ /// The view's log names this prepare.
+ Proven,
+ /// Nothing held names the prepare or rules it out.
+ Unproven,
+ /// The view's log names another prepare at or next to this op, or the frame
+ /// does not chain to a held neighbor.
+ Contradicted,
+}
+
+/// Classify a repaired prepare against the headers this replica already trusts.
+///
+/// A repair body carries no sender, so a stale peer's abandoned prepare at a
+/// committed op passes every integrity check. Only this replica's own log can
+/// vouch for it: `pending` is the adopted view's log, whose suffix and committed
+/// headers it names, and `held_at(op)` returns the journaled header at `op` with
+/// whether the view-log proof covers it.
+///
+/// Three things prove a prepare. A vouched header at its op with its checksum. A
+/// vouched header one op up whose `parent` is its checksum. A `view` stamp at or
+/// above this replica's view: only that view's primary stamps or restamps it, and
+/// that primary's log holds every op committed in an earlier view, so a prepare an
+/// earlier view abandoned never carries it.
+///
+/// A vouched neighbor that does not chain contradicts. A held unvouched neighbor
+/// that does not chain contradicts only an unproven prepare: one of the two comes
+/// from another branch, and journaling it would splice the branches. An unsealed
+/// checksum on either side of a chain link is no evidence.
+#[must_use]
+pub fn repaired_prepare_evidence(
+ header: &PrepareHeader,
+ view: u32,
+ pending: Option<&MergedLog>,
+ held_at: impl Fn(u64) -> Option<(PrepareHeader, bool)>,
+) -> RepairEvidence {
+ let entry_at = |op: u64| {
+ pending
+ .and_then(|pending| {
+ canonical_header_at(&pending.headers, op)
+ .or_else(|| canonical_header_at(&pending.committed_elsewhere, op))
+ })
+ .map(|named| (*named, true))
+ .or_else(|| held_at(op))
+ };
+ if let Some((named, true)) = entry_at(header.op) {
+ return if named.checksum == header.checksum {
+ RepairEvidence::Proven
+ } else {
+ RepairEvidence::Contradicted
+ };
+ }
+ let links = |child: &PrepareHeader, parent: &PrepareHeader| {
+ (child.checksum != CHECKSUM_UNSEALED && parent.checksum != CHECKSUM_UNSEALED)
+ .then_some(child.parent == parent.checksum)
+ };
+ let mut proven = header.view >= view;
+ let mut splices = false;
+ let below = header
+ .op
+ .checked_sub(1)
+ .filter(|op| *op > 0)
+ .and_then(entry_at);
+ let above = header.op.checked_add(1).and_then(entry_at);
+ for (link, vouched) in [
+ below.map(|(below, vouched)| (links(header, &below), vouched)),
+ above.map(|(above, vouched)| (links(&above, header), vouched)),
+ ]
+ .into_iter()
+ .flatten()
+ {
+ match link {
+ Some(false) if vouched => return RepairEvidence::Contradicted,
+ Some(false) => splices = true,
+ _ => {}
+ }
+ }
+ proven |= above.is_some_and(|(above, vouched)| vouched && links(&above, header) == Some(true));
+ if proven {
+ RepairEvidence::Proven
+ } else if splices {
+ RepairEvidence::Contradicted
+ } else {
+ RepairEvidence::Unproven
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1277,6 +1422,28 @@ mod tests {
assert!(restamp_prepare_view(malformed, 1).is_none());
}
+ #[test]
+ fn given_restamped_lower_op_when_rebuilding_pipeline_should_keep_the_chain() {
+ // A backup that missed op 9 journals the copy the view-3 primary
+ // retransmits, restamped to view 3, while op 10 keeps the view-0 stamp it
+ // arrived with. Promoted later, it rebuilds its pipeline from these headers.
+ let retransmitted = restamp_prepare_view(prepare_message(9, 7, 11).into_frozen(), 3)
+ .expect("valid prepare");
+ let lower = *bytemuck::checked::try_from_bytes::<PrepareHeader>(
+ &retransmitted[..size_of::<PrepareHeader>()],
+ )
+ .expect("restamped prepare header");
+ let upper = *prepare_message(10, lower.checksum, 13).header();
+ assert!(upper.view < lower.view);
+
+ let mut pipeline = LocalPipeline::new();
+ pipeline.push(PipelineEntry::new(lower));
+ pipeline.push(PipelineEntry::new(upper));
+
+ assert_eq!(pipeline.prepare_count(), 2);
+ assert!(pipeline.message_by_op_and_checksum(10, 13).is_some());
+ }
+
#[test]
fn given_truncated_buffer_when_reading_frozen_prepare_should_reject() {
let malformed: Frozen<MESSAGE_ALIGN> = Owned::<MESSAGE_ALIGN>::copy_from_slice(&[0]).into();
@@ -1647,6 +1814,7 @@ mod tests {
for op in low.max(1)..=high {
let mut header = PrepareHeader {
command: Command::Prepare,
+ cluster: 1,
operation: iggy_binary_protocol::Operation::CreateStream,
op,
view,
@@ -2020,6 +2188,102 @@ mod tests {
assert!(consensus.applied_view_start());
}
+ #[test]
+ fn given_parked_view_repair_when_starting_should_rebuild_only_unapplied_ops() {
+ const MERGED_COMMIT: u64 = 2;
+ const HEAD: u64 = 5;
+ const VIEW: u32 = 3;
+
+ for (applied_before, repaired_to, expected_start) in [
+ (2, 3, Some(4)),
+ (2, 5, None),
+ (2, 2, Some(3)),
+ (0, 0, Some(3)),
+ ] {
+ let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus, LocalPipeline::new());
+ consensus.init();
+ consensus.restore_commit_state(applied_before, MERGED_COMMIT);
+ consensus.sequencer().set_sequence(HEAD);
+ install_local_suffix(&consensus, HEAD, MERGED_COMMIT, 0);
+ let _ = consensus.handle_start_view_change(PlaneKind::Metadata, &svc_header(1, VIEW));
+ let (dvc, body) = dvc_with_full_suffix(2, VIEW, 0, HEAD, MERGED_COMMIT);
+ let _ = consensus.handle_do_view_change(PlaneKind::Metadata, &dvc, &body);
+ let merged = consensus.pending_view_log().expect("the view is parked");
+ assert_eq!(merged.commit_max, MERGED_COMMIT);
+
+ // Repair may apply a verified prefix after the DVC quorum is frozen.
+ for op in applied_before + 1..=repaired_to {
+ consensus.advance_commit_max(op);
+ consensus.advance_commit_min(op);
+ }
+ assert_eq!(consensus.log_view(), 0);
+ consensus.settle_view_log_proof();
+ let actions = consensus.start_pending_view(PlaneKind::Metadata);
+ let mut rebuilt_from = None;
+ for action in actions {
+ match action {
+ VsrAction::CommitJournal => {
+ for op in consensus.commit_min() + 1..=consensus.commit_max() {
+ consensus.advance_commit_min(op);
+ }
+ }
+ VsrAction::RebuildPipeline { from_op, to_op } => {
+ rebuilt_from = Some(from_op);
+ assert_eq!(to_op, HEAD);
+ consensus.with_pipeline_mut(|pipeline| {
+ for op in from_op..=to_op {
+ let header = *merged
+ .headers
+ .iter()
+ .find(|header| header.op == op)
+ .expect("the decided history is available");
+ let mut entry = PipelineEntry::new(header);
+ entry.add_ack(consensus.replica());
+ pipeline.push(entry);
+ }
+ });
+ }
+ _ => {}
+ }
+ }
+ assert_eq!(
+ rebuilt_from, expected_start,
+ "repair applied through {repaired_to}"
+ );
+
+ for header in merged
+ .headers
+ .iter()
+ .rev()
+ .filter(|header| header.op > repaired_to.max(MERGED_COMMIT))
+ {
+ let ack = PrepareOkHeader {
+ command: Command::PrepareOk,
+ view: VIEW,
+ replica: 1,
+ op: header.op,
+ prepare_checksum: header.checksum,
+ ..Default::default()
+ };
+ assert!(ack_quorum_reached(&consensus, PlaneKind::Metadata, &ack));
+ let ready = peek_committable_head(&consensus)
+ .expect("the next op must commit in this view");
+ assert_eq!(ready.checksum, header.checksum);
+ assert_eq!(consensus.pop_committed_prepare().unwrap().header, ready);
+ consensus.advance_commit_min(ready.op);
+ }
+ assert_eq!(consensus.view(), VIEW);
+ assert_eq!(consensus.status(), Status::Normal);
+ assert_eq!(consensus.commit_min(), HEAD);
+ assert!(consensus.pipeline_is_empty());
+ assert!(consensus.applied_view_start());
+ assert_eq!(
+ consensus.last_prepare_checksum(),
+ merged.headers[0].checksum
+ );
+ }
+ }
+
#[test]
fn loopback_cleared_on_complete_view_change_as_primary() {
// 3 replicas, replica 0 is primary for view 0 (and view 3: 3 % 3 = 0).
@@ -2102,7 +2366,7 @@ mod tests {
let header = StartViewHeader {
checksum: 0,
checksum_body: 0,
- cluster: 0,
+ cluster: 1,
size: u32::try_from(std::mem::size_of::<StartViewHeader>() + body.len())
.expect("synthetic StartView fits u32"),
view,
@@ -2178,7 +2442,7 @@ mod tests {
#[test]
fn given_an_unsealed_suffix_when_decoding_should_be_accepted() {
// The on-disk sentinel: suffixes are read out of the journal, which may hold
- // pre-seal entries, and partition-plane prepares are unsealed by construction.
+ // pre-seal entries. Both planes seal newly prepared headers.
let headers: Vec<PrepareHeader> = suffix_headers(2, 4, 1)
.into_iter()
.map(|mut header| {
@@ -2640,8 +2904,21 @@ mod tests {
},
"a canonical header proves the prefix even while its body is missing"
);
+ let mut off_chain = bridged;
+ off_chain[1].checksum += 10;
+ assert_eq!(
+ proof(2, &gap, &off_chain),
+ ViewLogProof::Missing {
+ op: 5,
+ unproven_through: 5
+ },
+ "a canonical header off the chain is no header, and nothing is held to remove"
+ );
gap[1].checksum += 10;
- assert_eq!(proof(2, &gap, &bridged), ViewLogProof::Divergent(4));
+ assert_eq!(
+ proof(2, &gap, &bridged),
+ ViewLogProof::Divergent { first: 4, last: 4 }
+ );
assert_eq!(
proof(2, &gap, &canonical),
ViewLogProof::Missing {
@@ -2651,7 +2928,10 @@ mod tests {
"a gap cannot prove the held prefix divergent"
);
gap.insert(2, local[2]);
- assert_eq!(proof(2, &gap, &canonical), ViewLogProof::Divergent(4));
+ assert_eq!(
+ proof(2, &gap, &canonical),
+ ViewLogProof::Divergent { first: 4, last: 4 }
+ );
assert_eq!(
proof(2, &local, &[]),
ViewLogProof::Missing {
@@ -2662,6 +2942,109 @@ mod tests {
assert_eq!(proof(6, &local, &[]), ViewLogProof::Proven);
}
+ /// A backup that adopted a view holding only the head body proves the rest
+ /// through canonical headers, and repairs from the reported op up to the head.
+ /// Reporting the highest missing body fetched one body per round trip, each
+ /// open to a stall retry, so a full pipeline took hundreds of ticks to commit.
+ #[test]
+ fn given_bridged_bodies_missing_when_proving_history_should_report_the_lowest() {
+ const FLOOR: u64 = 2;
+ const HEAD: u64 = 6;
+ let canonical: Vec<_> = (FLOOR + 1..=HEAD)
+ .rev()
+ .map(|op| PrepareHeader {
+ op,
+ parent: u128::from(op - 1),
+ checksum: u128::from(op),
+ ..Default::default()
+ })
+ .collect();
+ let head = canonical[0];
+
+ assert_eq!(
+ view_log_proof(FLOOR, &canonical, HEAD, |op| (op == HEAD).then_some(head)),
+ ViewLogProof::Missing {
+ op: FLOOR + 1,
+ unproven_through: 0,
+ },
+ "a repair window opened above the lowest missing body leaves it for another round"
+ );
+ }
+
+ #[test]
+ fn given_a_divergent_run_when_proving_history_should_report_the_whole_proven_run() {
+ const FLOOR: u64 = 2;
+ const FIRST: u64 = FLOOR + 1;
+ const DIVERGENT: u64 = 32;
+ const LAST: u64 = FIRST + DIVERGENT - 1;
+ const HEAD: u64 = LAST + 3;
+ const UNKNOWN: u64 = FIRST + 10;
+ const HOLE: u64 = FIRST + 20;
+ const COMMITTED: u64 = FIRST + 4;
+ const STALE: u128 = 1 << 64;
+ let view = |op: u64| PrepareHeader {
+ op,
+ parent: u128::from(op - 1),
+ checksum: u128::from(op),
+ ..Default::default()
+ };
+ let held: BTreeMap<_, _> = (1..=HEAD)
+ .map(|op| {
+ let mut header = view(op);
+ if (FIRST..=LAST).contains(&op) {
+ header.checksum += STALE;
+ if op > FIRST {
+ header.parent += STALE;
+ }
+ }
+ (op, header)
+ })
+ .collect();
+ let canonical: Vec<_> = (FLOOR + 1..=HEAD).rev().map(view).collect();
+ let proof = |floor, held: &BTreeMap<u64, PrepareHeader>, canonical: &[PrepareHeader]| {
+ view_log_proof(floor, canonical, HEAD, |op| held.get(&op).copied())
+ };
+
+ assert_eq!(
+ proof(FLOOR, &held, &canonical),
+ ViewLogProof::Divergent {
+ first: FIRST,
+ last: LAST
+ }
+ );
+ let unknown: Vec<_> = canonical
+ .iter()
+ .copied()
+ .filter(|header| header.op != UNKNOWN)
+ .collect();
+ assert_eq!(
+ proof(FLOOR, &held, &unknown),
+ ViewLogProof::Divergent {
+ first: UNKNOWN,
+ last: LAST
+ },
+ "the header above an unknown op still proves it, but nothing below it"
+ );
+ let mut holed = held.clone();
+ holed.remove(&HOLE);
+ assert_eq!(
+ proof(FLOOR, &holed, &canonical),
+ ViewLogProof::Divergent {
+ first: HOLE + 1,
+ last: LAST
+ },
+ "a run never spans a held gap"
+ );
+ assert_eq!(
+ proof(COMMITTED, &held, &canonical),
+ ViewLogProof::Divergent {
+ first: COMMITTED + 1,
+ last: LAST
+ },
+ "a run never reaches the commit floor"
+ );
+ }
+
#[test]
fn given_blank_view_slots_when_proving_history_should_wait_for_evidence() {
const HEAD: u64 = 4;
@@ -2738,4 +3121,98 @@ mod tests {
}
);
}
+
+ #[test]
+ fn given_a_repaired_prepare_when_classifying_should_require_evidence_from_the_view_log() {
+ const VIEW: u32 = 3;
+ const OP: u64 = 5;
+ let entry = |op: u64, branch: u128, view: u32| PrepareHeader {
+ op,
+ parent: u128::from(op - 1) + branch,
+ checksum: u128::from(op) + branch,
+ view,
+ ..Default::default()
+ };
+ let committed = entry(OP, 0, VIEW - 1);
+ let abandoned = entry(OP, 100, VIEW - 2);
+ let pending =
+ |headers: &[PrepareHeader], committed_elsewhere: &[PrepareHeader]| MergedLog {
+ op_head: OP + 1,
+ commit_max: OP + 1,
+ headers: headers.to_vec(),
+ committed_elsewhere: committed_elsewhere.to_vec(),
+ };
+ let classify =
+ |frame: &PrepareHeader, canonical: &[PrepareHeader], held: &[(PrepareHeader, bool)]| {
+ repaired_prepare_evidence(frame, VIEW, Some(&pending(canonical, &[])), |op| {
+ held.iter().find(|(header, _)| header.op == op).copied()
+ })
+ };
+ let above = entry(OP + 1, 0, VIEW);
+ let below = entry(OP - 1, 0, VIEW - 1);
+
+ assert_eq!(
+ classify(&committed, &[committed], &[]),
+ RepairEvidence::Proven
+ );
+ assert_eq!(
+ classify(&abandoned, &[committed], &[]),
+ RepairEvidence::Contradicted
+ );
+ let committed_elsewhere = pending(&[], &[committed]);
+ for (frame, evidence) in [
+ (committed, RepairEvidence::Proven),
+ (abandoned, RepairEvidence::Contradicted),
+ ] {
+ assert_eq!(
+ repaired_prepare_evidence(&frame, VIEW, Some(&committed_elsewhere), |_| None),
+ evidence
+ );
+ }
+ assert_eq!(classify(&committed, &[above], &[]), RepairEvidence::Proven);
+ assert_eq!(
+ classify(&committed, &[], &[(above, true)]),
+ RepairEvidence::Proven
+ );
+ assert_eq!(
+ classify(&abandoned, &[], &[(above, true)]),
+ RepairEvidence::Contradicted
+ );
+ assert_eq!(
+ classify(&entry(OP, 0, VIEW), &[], &[]),
+ RepairEvidence::Proven,
+ "only this view's primary stamps a prepare with this view"
+ );
+ assert_eq!(
+ classify(&committed, &[], &[(below, true)]),
+ RepairEvidence::Unproven,
+ "a parent link proves continuity, not identity"
+ );
+ assert_eq!(
+ classify(&committed, &[], &[(above, false)]),
+ RepairEvidence::Unproven,
+ "an unproven neighbor cannot vouch"
+ );
+ let stale_below = entry(OP - 1, 100, VIEW - 2);
+ assert_eq!(
+ classify(&committed, &[], &[(stale_below, true)]),
+ RepairEvidence::Contradicted
+ );
+ assert_eq!(
+ classify(&committed, &[], &[(stale_below, false)]),
+ RepairEvidence::Contradicted,
+ "an unproven frame must not splice onto another branch"
+ );
+ assert_eq!(
+ classify(&committed, &[above], &[(stale_below, false)]),
+ RepairEvidence::Proven,
+ "a proven frame outranks an unproven neighbor"
+ );
+ let mut unsealed_below = stale_below;
+ unsealed_below.checksum = CHECKSUM_UNSEALED;
+ assert_eq!(
+ classify(&committed, &[], &[(unsealed_below, true)]),
+ RepairEvidence::Unproven
+ );
+ }
}
diff --git a/core/consensus/src/view_change_quorum.rs b/core/consensus/src/view_change_quorum.rs
index 89d5c266c..46d4670bb 100644
--- a/core/consensus/src/view_change_quorum.rs
+++ b/core/consensus/src/view_change_quorum.rs
@@ -405,8 +405,7 @@ pub fn dvc_suffix_decode(
// transit becomes a canonical header no replica holds, honest senders
// read as disagreeing, and a corrupted frame turns into a nack quorum
// against a committed op. The on-disk sentinel is skipped: a suffix is read
- // out of the journal, which may hold entries a pre-seal build wrote, and
- // partition-plane prepares are unsealed by construction.
+ // out of the journal, which may hold entries a pre-seal build wrote.
if header.checksum != CHECKSUM_UNSEALED && header.identity_checksum() != header.checksum
{
return Err(DvcSuffixError::ChecksumMismatch { index });
diff --git a/core/consensus/src/vsr_state.rs b/core/consensus/src/vsr_state.rs
index 3023d3df1..ae82c3d47 100644
--- a/core/consensus/src/vsr_state.rs
+++ b/core/consensus/src/vsr_state.rs
@@ -28,17 +28,22 @@
use std::fmt;
-/// Number of bytes [`VsrState::to_bytes`] produces: `cluster`(16) +
-/// `replica_id`(1) + `replica_count`(1) + `view`(4) + `log_view`(4) +
-/// `commit_max`(8) + `checkpoint_op`(8) + `checkpoint_checksum`(16) +
-/// `offset_frontier`(8) + `offset_reserved`(8).
-///
-/// Growing this is one-way: a record of this length is [`VsrStateError::WrongLength`]
-/// to every build that predates the field, so a ROLLBACK needs the data directory
-/// wiped even though the upgrade does not. Stated for operators beside
-/// `partition.offset_reservation_lease` in `config.toml`.
+use iggy_binary_protocol::{CHECKSUM_UNSEALED, PrepareHeader};
+
+use crate::{DVC_HEADERS_MAX, DvcHeaderKind, MergedLog, dvc_header_kind, dvc_suffix_decode};
+
+/// Size of the legacy fixed prefix. New records append a versioned recovery
+/// extension, keeping adopted log identity separate from body certification.
+/// Old 66/74-byte records upgrade without invented adoption evidence. A binary
+/// predating the extension refuses the new record; rollback requires a consistent
+/// pre-upgrade backup or a deliberate clean rejoin, never partial-file conversion.
pub const ENCODED_LEN: usize = 74;
+const RECOVERY_VERSION: u16 = 2;
+const RECOVERY_PREFIX_LEN: usize = ENCODED_LEN + 4;
+const ADOPTED_FIELDS_LEN: usize = 40;
+const ADOPTED_HEADERS_OFFSET: usize = RECOVERY_PREFIX_LEN + ADOPTED_FIELDS_LEN;
+
/// The layout before `offset_reserved` was appended.
///
/// [`VsrState::try_from`] accepts records of this length. Without it every
@@ -58,17 +63,13 @@ pub const ENCODED_LEN: usize = 74;
/// `vsr_state.rs`, and `edge.2` already wrote 66 -- so a record that short is
/// corruption, not history.
///
-/// A version bump instead of this would not help on its own: `classify` compares
-/// the version for exact equality, so a v2 build turns every v1 record into
-/// `Unreadable`, which is the same refusal wearing a different name -- and it
-/// would make this tolerance unreachable, refusing even records that decode.
pub const ENCODED_LEN_WITHOUT_RESERVATION: usize = 66;
/// The durable consensus state of one replica for one consensus group.
///
/// A view a replica acted in must survive a crash, or it can re-participate in
/// an old view and split the log.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VsrState {
/// Cluster this superblock belongs to. Recovery rejects a mismatch: a copied or
/// misplaced data directory. Catches misplacement only, never staleness -- an
@@ -113,7 +114,8 @@ pub struct VsrState {
/// replicated prepare would persist different bytes here than on every
/// peer, silently.
///
- /// Always `0` on the metadata plane, which mints no message offsets.
+ /// The metadata plane mints no message offsets and keeps its
+ /// [`HistoryCertificate`] here instead: see [`Self::history_certificate`].
pub offset_frontier: u64,
/// PARTITION plane: a monotone CEILING on the offsets this replica may
/// already have minted, claimed ahead of the counter in blocks so an append
@@ -127,15 +129,285 @@ pub struct VsrState {
/// backoff forever. Boot seeds the mint counter from both; the rewind guard
/// reads the frontier alone.
///
- /// Always `0` on the metadata plane, which mints no message offsets.
+ /// The metadata plane keeps its [`HistoryCertificate`] here instead: see
+ /// [`Self::history_certificate`].
pub offset_reserved: u64,
+ /// Accepted `StartView` identity, independent of the WAL's body certificate.
+ pub adopted_view: Option<AdoptedView>,
+}
+
+/// The exact log a primary announced, not a claim that its bodies are local.
+/// Persisted with the replica's view before it can vote under this log's view.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AdoptedView {
+ pub view: u32,
+ pub group: u64,
+ pub partition_incarnation: u64,
+ pub op_head: u64,
+ pub commit_max: u64,
+ pub headers: Vec<PrepareHeader>,
+}
+
+impl AdoptedView {
+ #[must_use]
+ pub fn merged_log(&self) -> MergedLog {
+ MergedLog {
+ op_head: self.op_head,
+ commit_max: self.commit_max,
+ headers: self.headers.clone(),
+ committed_elsewhere: Vec::new(),
+ }
+ }
+
+ /// An adopted identity may retain a repaired body without certifying the WAL.
+ #[must_use]
+ pub fn vouches_for(&self, header: &PrepareHeader) -> bool {
+ if header.checksum == CHECKSUM_UNSEALED {
+ return false;
+ }
+ self.headers.iter().any(|adopted| {
+ if adopted.op != header.op || dvc_header_kind(adopted) != DvcHeaderKind::Valid {
+ return false;
+ }
+ // Retransmission re-stamps the view without changing prepare identity.
+ let mut resident = *header;
+ resident.view = adopted.view;
+ resident == *adopted
+ })
+ }
+
+ /// Validate the same header identities and chain accepted on the wire,
+ /// together with their durable owner and advertised frontier.
+ ///
+ /// # Errors
+ /// Refuses evidence that could not describe this replica's accepted view.
+ pub fn validate(&self, cluster: u128, view: u32) -> Result<(), VsrStateError> {
+ if self.view > view || self.commit_max > self.op_head {
+ return Err(VsrStateError::InvalidAdoptedView);
+ }
+ dvc_suffix_decode(bytemuck::cast_slice(&self.headers), self.op_head, 0, 0)
+ .map_err(|_| VsrStateError::InvalidAdoptedView)?;
+ if self.headers.iter().any(|header| {
+ dvc_header_kind(header) == DvcHeaderKind::Valid
+ && (header.cluster != cluster
+ || header.group != self.group
+ || header.partition_incarnation != self.partition_incarnation)
+ }) {
+ return Err(VsrStateError::InvalidAdoptedView);
+ }
+ Ok(())
+ }
+}
+
+/// METADATA plane: the newest view whose log this replica's WAL durably holds.
+///
+/// `log_view` names the view a replica adopted, which can run ahead of its WAL: a
+/// backup adopts a `StartView` and only then removes divergent entries and refills
+/// the view's log. A crash in that window must not let the WAL be advertised under
+/// the adopted view, or a `DoViewChange` presents a stale suffix as canonical.
+///
+/// Advanced only once a view's log is proven against the WAL, and kept through a
+/// rewrite. Entries above `op` are this view's history only while no later view was
+/// made durable after the binding: see [`HistoryRecovery`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct HistoryCertificate {
+ pub view: u32,
+ /// The WAL head the certificate is bound to.
+ pub op: u64,
+ /// The low 32 bits of the checksum of the entry at `op`, zero when no entry is
+ /// left above the snapshot floor. The record has room for no more, and they only
+ /// guard against a binding the WAL lost to a bug: a rewrite moves the binding
+ /// before it removes the entry.
+ pub checksum: u32,
+}
+
+impl HistoryCertificate {
+ #[must_use]
+ #[allow(clippy::cast_possible_truncation)]
+ pub const fn new(view: u32, op: u64, checksum: u128) -> Self {
+ Self {
+ view,
+ op,
+ checksum: checksum as u32,
+ }
+ }
+
+ /// Whether `checksum` is the entry the certificate is bound to.
+ #[must_use]
+ pub const fn binds(&self, checksum: u128) -> bool {
+ Self::new(self.view, self.op, checksum).checksum == self.checksum
+ }
+}
+
+/// What a restarted METADATA replica may claim about the WAL it recovered.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HistoryRecovery {
+ /// The original prefix is `view` history. Entries from `truncate_from` up have
+ /// neither that proof nor a matching adopted identity and must be dropped.
+ Certified {
+ view: u32,
+ truncate_from: Option<u64>,
+ },
+ /// Nothing proves which view the WAL holds: the record predates certificates, or
+ /// names an entry the WAL lost. Claim no view, fence the whole suffix, and vote in
+ /// no election until a `StartView` gives the log a view to be proven against.
+ Unproven,
+}
+
+impl HistoryRecovery {
+ /// Preserve the resident prefix named by a newer durable adoption. A missing
+ /// body is not a conflicting entry; replay still stops before any hole.
+ /// `headers` must visit resident entries in ascending op order.
+ #[must_use]
+ pub fn retain_adopted_suffix(
+ self,
+ adopted: &AdoptedView,
+ headers: impl IntoIterator<Item = PrepareHeader>,
+ ) -> Self {
+ let Self::Certified {
+ view,
+ truncate_from: Some(from),
+ } = self
+ else {
+ return self;
+ };
+ if adopted.view <= view {
+ return self;
+ }
+ let mut truncate_from = Some(from);
+ for header in headers.into_iter().filter(|header| header.op >= from) {
+ if !adopted.vouches_for(&header) {
+ truncate_from = Some(header.op);
+ break;
+ }
+ truncate_from = header.op.checked_add(1);
+ }
+ Self::Certified {
+ view,
+ truncate_from,
+ }
+ }
+
+ /// Decide from the durable record and the recovered WAL, whose entry checksums
+ /// `checksum_at` reads.
+ ///
+ /// Without a record nothing was ever made durable, so no view was adopted and
+ /// the WAL is view-0 history. A solo replica's WAL is its log by definition.
+ #[must_use]
+ pub fn new(
+ state: Option<&VsrState>,
+ solo: bool,
+ checksum_at: impl Fn(u64) -> Option<u128>,
+ ) -> Self {
+ let Some(state) = state else {
+ return Self::Certified {
+ view: 0,
+ truncate_from: None,
+ };
+ };
+ if solo {
+ return Self::Certified {
+ view: state.log_view,
+ truncate_from: None,
+ };
+ }
+ let Some(certificate) = state
+ .history_certificate()
+ .filter(|certificate| certificate.view <= state.log_view)
+ else {
+ return Self::Unproven;
+ };
+ // A rewrite or refill for a later view runs only once that view is durable,
+ // so while it is not, every entry is still this view's history.
+ if state.view == certificate.view {
+ return Self::Certified {
+ view: certificate.view,
+ truncate_from: None,
+ };
+ }
+ let anchored = certificate.op <= state.checkpoint_op;
+ if anchored
+ || checksum_at(certificate.op).is_some_and(|checksum| certificate.binds(checksum))
+ {
+ return Self::Certified {
+ view: certificate.view,
+ truncate_from: Some(certificate.op.max(state.checkpoint_op).saturating_add(1)),
+ };
+ }
+ Self::Unproven
+ }
+
+ /// The `log_view` the replica restores, and advertises in its `DoViewChange`.
+ #[must_use]
+ pub const fn log_view(&self) -> u32 {
+ match *self {
+ Self::Certified { view, .. } => view,
+ Self::Unproven => 0,
+ }
+ }
}
impl VsrState {
- /// Encode to the fixed little-endian on-disk layout.
+ /// METADATA plane: the history certificate this record carries, `None` for a
+ /// record written before certificates existed.
+ ///
+ /// Packed into the two offset words, which the metadata plane otherwise leaves
+ /// zero: `offset_frontier` holds the bound op, `offset_reserved` the view plus one
+ /// in its high half and the bound checksum in its low half. The plus one keeps a
+ /// certificate for view 0 apart from an old record's zero.
+ #[must_use]
+ #[allow(clippy::cast_possible_truncation)]
+ pub const fn history_certificate(&self) -> Option<HistoryCertificate> {
+ let view = (self.offset_reserved >> 32) as u32;
+ if view == 0 {
+ return None;
+ }
+ Some(HistoryCertificate {
+ view: view - 1,
+ op: self.offset_frontier,
+ checksum: self.offset_reserved as u32,
+ })
+ }
+
+ /// METADATA plane: store `certificate` as [`Self::history_certificate`] reads it.
+ /// A view with no room for the plus one is stored as no certificate.
+ #[must_use]
+ pub const fn with_history_certificate(
+ mut self,
+ certificate: Option<HistoryCertificate>,
+ ) -> Self {
+ let (op, packed) = match certificate {
+ Some(certificate) => match certificate.view.checked_add(1) {
+ Some(view) => (
+ certificate.op,
+ ((view as u64) << 32) | certificate.checksum as u64,
+ ),
+ None => (0, 0),
+ },
+ None => (0, 0),
+ };
+ self.offset_frontier = op;
+ self.offset_reserved = packed;
+ self
+ }
+
+ /// Refuse evidence copied from another group or partition incarnation.
+ ///
+ /// # Errors
+ /// Returns an identity error without installing any recovered state.
+ pub fn validate_group(&self, group: u64, incarnation: u64) -> Result<(), VsrStateError> {
+ if self.adopted_view.as_ref().is_some_and(|adopted| {
+ adopted.group != group || adopted.partition_incarnation != incarnation
+ }) {
+ return Err(VsrStateError::AdoptedGroupMismatch);
+ }
+ Ok(())
+ }
+
+ /// Encode the legacy prefix and versioned adopted-view extension atomically.
#[must_use]
- pub fn to_bytes(&self) -> [u8; ENCODED_LEN] {
- let mut out = [0u8; ENCODED_LEN];
+ pub fn to_bytes(&self) -> Vec<u8> {
+ let mut out = vec![0u8; RECOVERY_PREFIX_LEN];
out[0..16].copy_from_slice(&self.cluster.to_le_bytes());
out[16] = self.replica_id;
out[17] = self.replica_count;
@@ -146,6 +418,18 @@ impl VsrState {
out[42..58].copy_from_slice(&self.checkpoint_checksum.to_le_bytes());
out[58..66].copy_from_slice(&self.offset_frontier.to_le_bytes());
out[66..74].copy_from_slice(&self.offset_reserved.to_le_bytes());
+ out[74..76].copy_from_slice(&RECOVERY_VERSION.to_le_bytes());
+ if let Some(adopted) = &self.adopted_view {
+ out[76..78].copy_from_slice(&1u16.to_le_bytes());
+ out.extend_from_slice(&adopted.view.to_le_bytes());
+ out.extend_from_slice(&adopted.group.to_le_bytes());
+ out.extend_from_slice(&adopted.partition_incarnation.to_le_bytes());
+ out.extend_from_slice(&adopted.op_head.to_le_bytes());
+ out.extend_from_slice(&adopted.commit_max.to_le_bytes());
+ let count = u32::try_from(adopted.headers.len()).unwrap_or(u32::MAX);
+ out.extend_from_slice(&count.to_le_bytes());
+ out.extend_from_slice(bytemuck::cast_slice(&adopted.headers));
+ }
out
}
}
@@ -169,7 +453,51 @@ impl TryFrom<&[u8]> for VsrState {
// out of RAM above the frontier are gone with the process either way --
// but refusing the record recovers nothing and costs the node its boot.
let mut padded = [0u8; ENCODED_LEN];
+ let adopted_view = if bytes.len() >= RECOVERY_PREFIX_LEN {
+ let version = u16::from_le_bytes([bytes[74], bytes[75]]);
+ if version != RECOVERY_VERSION {
+ return Err(VsrStateError::UnsupportedVersion { version });
+ }
+ let present = u16::from_le_bytes([bytes[76], bytes[77]]);
+ match present {
+ 0 if bytes.len() == RECOVERY_PREFIX_LEN => None,
+ 1 if bytes.len() >= ADOPTED_HEADERS_OFFSET => {
+ let fields = &bytes[RECOVERY_PREFIX_LEN..ADOPTED_HEADERS_OFFSET];
+ let count = u32::from_le_bytes(field(fields, 36)) as usize;
+ if count > DVC_HEADERS_MAX
+ || bytes.len()
+ != ADOPTED_HEADERS_OFFSET + count * size_of::<PrepareHeader>()
+ {
+ return Err(VsrStateError::InvalidAdoptedView);
+ }
+ let mut headers = Vec::with_capacity(count);
+ for chunk in bytes[ADOPTED_HEADERS_OFFSET..]
+ .as_chunks::<{ size_of::<PrepareHeader>() }>()
+ .0
+ {
+ headers.push(
+ bytemuck::checked::try_pod_read_unaligned(chunk)
+ .map_err(|_| VsrStateError::InvalidAdoptedView)?,
+ );
+ }
+ Some(AdoptedView {
+ view: u32::from_le_bytes(field(fields, 0)),
+ group: u64::from_le_bytes(field(fields, 4)),
+ partition_incarnation: u64::from_le_bytes(field(fields, 12)),
+ op_head: u64::from_le_bytes(field(fields, 20)),
+ commit_max: u64::from_le_bytes(field(fields, 28)),
+ headers,
+ })
+ }
+ _ => return Err(VsrStateError::InvalidAdoptedView),
+ }
+ } else {
+ None
+ };
match bytes.len() {
+ length if length >= RECOVERY_PREFIX_LEN => {
+ padded.copy_from_slice(&bytes[..ENCODED_LEN]);
+ }
ENCODED_LEN => padded.copy_from_slice(bytes),
ENCODED_LEN_WITHOUT_RESERVATION => {
padded[..ENCODED_LEN_WITHOUT_RESERVATION].copy_from_slice(bytes);
@@ -195,6 +523,7 @@ impl TryFrom<&[u8]> for VsrState {
checkpoint_checksum: u128::from_le_bytes(field(bytes, 42)),
offset_frontier: u64::from_le_bytes(field(bytes, 58)),
offset_reserved: u64::from_le_bytes(field(bytes, 66)),
+ adopted_view,
};
// A record violating `log_view <= view` decodes into a replica that looks
// healthy locally while `DoViewChangeHeader::validate` makes every peer drop
@@ -207,13 +536,16 @@ impl TryFrom<&[u8]> for VsrState {
log_view: state.log_view,
});
}
+ if let Some(adopted) = &state.adopted_view {
+ adopted.validate(state.cluster, state.view)?;
+ }
Ok(state)
}
}
/// Copy a fixed-width field out of the length-validated record. Always in bounds
-/// for a `[u8; ENCODED_LEN]`, so the slice conversion is infallible.
-fn field<const N: usize>(bytes: &[u8; ENCODED_LEN], start: usize) -> [u8; N] {
+/// after the fixed prefix or extension length checks above.
+fn field<const N: usize>(bytes: &[u8], start: usize) -> [u8; N] {
let mut out = [0u8; N];
out.copy_from_slice(&bytes[start..start + N]);
out
@@ -222,16 +554,35 @@ fn field<const N: usize>(bytes: &[u8; ENCODED_LEN], start: usize) -> [u8; N] {
/// Failure decoding a [`VsrState`] from bytes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VsrStateError {
- /// The byte slice was not exactly `ENCODED_LEN` long.
- WrongLength { expected: usize, actual: usize },
+ /// The record did not match any supported fixed or extended layout.
+ WrongLength {
+ expected: usize,
+ actual: usize,
+ },
/// The record violates `log_view <= view`, so it cannot be a state any replica
/// reached.
- LogViewAheadOfView { view: u32, log_view: u32 },
+ LogViewAheadOfView {
+ view: u32,
+ log_view: u32,
+ },
+ UnsupportedVersion {
+ version: u16,
+ },
+ InvalidAdoptedView,
+ AdoptedGroupMismatch,
}
impl fmt::Display for VsrStateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
+ Self::UnsupportedVersion { version } => {
+ write!(f, "unsupported VsrState recovery version {version}")
+ }
+ Self::InvalidAdoptedView => write!(f, "invalid durable adopted-view evidence"),
+ Self::AdoptedGroupMismatch => write!(
+ f,
+ "durable adopted view belongs to another group or incarnation"
+ ),
Self::WrongLength { expected, actual } => {
write!(
f,
@@ -271,9 +622,10 @@ mod tests {
checkpoint_checksum: 8,
offset_frontier: 9,
offset_reserved: 10,
+ adopted_view: None,
};
let bytes = state.to_bytes();
- assert_eq!(bytes.len(), ENCODED_LEN);
+ assert_eq!(bytes.len(), RECOVERY_PREFIX_LEN);
assert_eq!(bytes[0], 1, "cluster low byte");
assert_eq!(bytes[16], 2, "replica_id");
assert_eq!(bytes[17], 5, "replica_count");
@@ -307,6 +659,7 @@ mod tests {
checkpoint_checksum: 5,
offset_frontier: 77,
offset_reserved: 88,
+ adopted_view: None,
}
.to_bytes();
@@ -355,6 +708,7 @@ mod tests {
checkpoint_checksum: 0,
offset_frontier: 0,
offset_reserved: 0,
+ adopted_view: None,
}
.to_bytes();
@@ -383,6 +737,7 @@ mod tests {
// trailing field would still satisfy every assertion below.
offset_frontier: 9,
offset_reserved: 11,
+ adopted_view: None,
}
.to_bytes();
assert_eq!(bytes[58], 9, "offset_frontier must occupy bytes 58..66");
@@ -397,4 +752,237 @@ mod tests {
})
);
}
+
+ fn metadata_record(view: u32, log_view: u32, checkpoint_op: u64) -> VsrState {
+ VsrState {
+ cluster: 1,
+ replica_id: 1,
+ replica_count: 3,
+ view,
+ log_view,
+ commit_max: 0,
+ checkpoint_op,
+ checkpoint_checksum: 0,
+ offset_frontier: 0,
+ offset_reserved: 0,
+ adopted_view: None,
+ }
+ }
+
+ #[test]
+ fn given_a_history_certificate_when_recorded_should_round_trip_and_tell_view_zero_from_none() {
+ const CHECKSUM: u128 = u128::MAX - 3;
+ for certificate in [
+ HistoryCertificate::new(0, 0, 0),
+ HistoryCertificate::new(7, 41, CHECKSUM),
+ ] {
+ let state = metadata_record(9, 8, 3).with_history_certificate(Some(certificate));
+ let decoded = VsrState::try_from(&state.to_bytes()[..]).unwrap();
+ assert_eq!(decoded, state);
+ assert_eq!(decoded.history_certificate(), Some(certificate));
+ }
+ let certificate = HistoryCertificate::new(7, 41, CHECKSUM);
+ assert!(certificate.binds(CHECKSUM));
+ assert!(!certificate.binds(CHECKSUM ^ 1));
+
+ // Records written before certificates: zero words, or the shorter layout
+ // whose reservation fills from a zero frontier.
+ let legacy = metadata_record(9, 8, 3);
+ assert_eq!(legacy.history_certificate(), None);
+ let short = &legacy.to_bytes()[..ENCODED_LEN_WITHOUT_RESERVATION];
+ assert_eq!(
+ VsrState::try_from(short).unwrap().history_certificate(),
+ None
+ );
+ assert_eq!(legacy.clone().with_history_certificate(None), legacy);
+ }
+
+ #[test]
+ fn given_recovered_records_when_deciding_history_should_trust_only_a_bound_certificate() {
+ const WAL_HEAD: u64 = 12;
+ const BINDING: u64 = 10;
+ const OLD_VIEW: u32 = 5;
+ const ADOPTED_VIEW: u32 = 6;
+ // Low bits set, so a binding that kept only the op would not match.
+ const CHECKSUM_LOW_BITS: u128 = 0x00C0_FFEE;
+ let checksum_of = |op: u64| u128::from(op) << 64 | CHECKSUM_LOW_BITS;
+ let checksum_at = |op: u64| (op <= WAL_HEAD).then(|| checksum_of(op));
+ let bound = |op: u64| HistoryCertificate::new(OLD_VIEW, op, checksum_of(op));
+ let decide = |state: Option<VsrState>, solo: bool| {
+ HistoryRecovery::new(state.as_ref(), solo, checksum_at)
+ };
+ let certified = |view: u32, truncate_from: Option<u64>| HistoryRecovery::Certified {
+ view,
+ truncate_from,
+ };
+
+ // Nothing was ever recorded, so nothing was adopted.
+ assert_eq!(decide(None, false), certified(0, None));
+ // A solo replica's WAL is its log.
+ let legacy = metadata_record(ADOPTED_VIEW, ADOPTED_VIEW, 0);
+ assert_eq!(
+ decide(Some(legacy.clone()), true),
+ certified(ADOPTED_VIEW, None)
+ );
+ // An earlier build recorded the adopted view with no proof of its log.
+ assert_eq!(decide(Some(legacy), false), HistoryRecovery::Unproven);
+ assert_eq!(HistoryRecovery::Unproven.log_view(), 0);
+
+ let record = |view, log_view, checkpoint_op, certificate| {
+ metadata_record(view, log_view, checkpoint_op)
+ .with_history_certificate(Some(certificate))
+ };
+ // No later view was recorded, so every entry is the certified view's,
+ // above the binding too.
+ let same_view = record(OLD_VIEW, OLD_VIEW, 0, bound(BINDING));
+ assert_eq!(decide(Some(same_view), false), certified(OLD_VIEW, None));
+ // A later view was recorded, during its view change or after adopting
+ // it: entries above the binding were journaled for it and are dropped.
+ for log_view in [OLD_VIEW, ADOPTED_VIEW] {
+ let adopted = record(ADOPTED_VIEW, log_view, 0, bound(BINDING));
+ let decided = decide(Some(adopted), false);
+ assert_eq!(decided, certified(OLD_VIEW, Some(BINDING + 1)));
+ assert_eq!(decided.log_view(), OLD_VIEW);
+ }
+ // A checkpoint published over the binding anchors it, even once its drain
+ // removed the bound entry.
+ let drained = HistoryCertificate::new(OLD_VIEW, BINDING - 2, 1);
+ let checkpointed = record(ADOPTED_VIEW, ADOPTED_VIEW, BINDING, drained);
+ assert_eq!(
+ decide(Some(checkpointed), false),
+ certified(OLD_VIEW, Some(BINDING + 1))
+ );
+ // A binding the WAL no longer holds proves nothing.
+ for lost in [
+ bound(WAL_HEAD + 1),
+ HistoryCertificate::new(OLD_VIEW, BINDING, 1),
+ ] {
+ let unbound = record(ADOPTED_VIEW, ADOPTED_VIEW, 0, lost);
+ assert_eq!(decide(Some(unbound), false), HistoryRecovery::Unproven);
+ }
+ // Certified past the recorded log_view: corruption.
+ let ahead = record(ADOPTED_VIEW, OLD_VIEW - 1, 0, bound(BINDING));
+ assert_eq!(decide(Some(ahead), false), HistoryRecovery::Unproven);
+ }
+ #[test]
+ fn given_adopted_headers_when_persisted_should_round_trip_independently_of_body_proof() {
+ let state = adopted_record();
+ let bytes = state.to_bytes();
+ assert_eq!(VsrState::try_from(bytes.as_slice()).unwrap(), state);
+ let legacy = VsrState::try_from(&bytes[..ENCODED_LEN]).unwrap();
+ assert!(legacy.adopted_view.is_none());
+ assert_eq!(legacy.history_certificate(), state.history_certificate());
+ assert_eq!(state.adopted_view.as_ref().unwrap().view, state.view);
+ assert!(state.history_certificate().unwrap().view < state.view);
+ assert!(state.validate_group(7, 9).is_ok());
+ assert_eq!(
+ state.validate_group(8, 9),
+ Err(VsrStateError::AdoptedGroupMismatch)
+ );
+ assert_eq!(
+ state.validate_group(7, 10),
+ Err(VsrStateError::AdoptedGroupMismatch)
+ );
+ }
+
+ #[test]
+ fn given_malformed_adopted_evidence_when_decoding_should_refuse_before_recovery() {
+ let original = adopted_record();
+ for corrupt in 0..7 {
+ let mut state = original.clone();
+ let adopted = state.adopted_view.as_mut().unwrap();
+ match corrupt {
+ 0 => adopted.view = state.view + 1,
+ 1 => adopted.commit_max = adopted.op_head + 1,
+ 2 => adopted.op_head += 1,
+ 3 => adopted.headers[0].checksum ^= 1,
+ 4 => adopted.headers[0].reserved[0] = 1,
+ 5 => {
+ adopted.headers[0].parent ^= 1;
+ adopted.headers[0].checksum = adopted.headers[0].identity_checksum();
+ }
+ 6 => adopted.group += 1,
+ _ => unreachable!(),
+ }
+ assert_eq!(
+ VsrState::try_from(state.to_bytes().as_slice()),
+ Err(VsrStateError::InvalidAdoptedView),
+ "case {corrupt}"
+ );
+ }
+ let mut bytes = original.to_bytes();
+ bytes[ADOPTED_HEADERS_OFFSET - 4..ADOPTED_HEADERS_OFFSET]
+ .copy_from_slice(&u32::MAX.to_le_bytes());
+ assert_eq!(
+ VsrState::try_from(bytes.as_slice()),
+ Err(VsrStateError::InvalidAdoptedView)
+ );
+ let mut bytes = original.to_bytes();
+ bytes.pop();
+ assert_eq!(
+ VsrState::try_from(bytes.as_slice()),
+ Err(VsrStateError::InvalidAdoptedView)
+ );
+ let mut bytes = original.to_bytes();
+ bytes[ENCODED_LEN..ENCODED_LEN + 2].copy_from_slice(&(RECOVERY_VERSION + 1).to_le_bytes());
+ assert_eq!(
+ VsrState::try_from(bytes.as_slice()),
+ Err(VsrStateError::UnsupportedVersion {
+ version: RECOVERY_VERSION + 1
+ })
+ );
+ }
+
+ #[test]
+ fn given_an_unsealed_adopted_header_when_recovering_should_truncate_its_unproven_body() {
+ let mut state = adopted_record();
+ let adopted = state.adopted_view.as_mut().unwrap();
+ let oldest = adopted.headers.last_mut().unwrap();
+ oldest.checksum = iggy_binary_protocol::CHECKSUM_UNSEALED;
+ let first_unproven = oldest.op;
+ let original = HistoryRecovery::Certified {
+ view: state.history_certificate().unwrap().view,
+ truncate_from: Some(first_unproven),
+ };
+ let adopted = state.adopted_view.as_ref().unwrap();
+ let recovered =
+ original.retain_adopted_suffix(adopted, adopted.headers.iter().rev().copied());
+ assert_eq!(
+ recovered, original,
+ "an unsealed header cannot vouch for a repaired body even when its bytes match"
+ );
+ }
+
+ fn adopted_record() -> VsrState {
+ let mut older = PrepareHeader {
+ command: iggy_binary_protocol::Command::Prepare,
+ operation: iggy_binary_protocol::Operation::CreateStream,
+ cluster: 1,
+ group: 7,
+ partition_incarnation: 9,
+ op: 25,
+ commit: 24,
+ timestamp: 25,
+ ..PrepareHeader::default()
+ };
+ older.checksum = older.identity_checksum();
+ let mut newer = PrepareHeader {
+ op: 26,
+ timestamp: 26,
+ parent: older.checksum,
+ ..older
+ };
+ newer.checksum = newer.identity_checksum();
+ let mut state = metadata_record(14, 10, 0)
+ .with_history_certificate(Some(HistoryCertificate::new(10, 24, 1)));
+ state.adopted_view = Some(AdoptedView {
+ view: 14,
+ group: 7,
+ partition_incarnation: 9,
+ op_head: 26,
+ commit_max: 25,
+ headers: vec![newer, older],
+ });
+ state
+ }
}
diff --git a/core/integration/Cargo.toml b/core/integration/Cargo.toml
index 58e483356..b8a79786a 100644
--- a/core/integration/Cargo.toml
+++ b/core/integration/Cargo.toml
@@ -38,6 +38,9 @@ arrow = { workspace = true }
assert_cmd = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
+# Derives the HTTP relay proof key the way a cluster node does, so a test can
+# pose as a node relaying a partition request.
+blake3 = { workspace = true }
bon = { workspace = true }
bytemuck = { workspace = true }
bytes = { workspace = true }
diff --git a/core/integration/src/harness/handle/server.rs b/core/integration/src/harness/handle/server.rs
index 931be5a37..66ec08680 100644
--- a/core/integration/src/harness/handle/server.rs
+++ b/core/integration/src/harness/handle/server.rs
@@ -26,7 +26,7 @@ use crate::harness::port_reserver::PortReserver;
use crate::harness::traits::{IggyServerDependent, Restartable, TestBinary};
use assert_cmd::prelude::CommandCargoExt;
use iggy::prelude::{DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME};
-use iggy_common::TransportProtocol;
+use iggy_common::{IggyDuration, TransportProtocol};
use rand::RngExt as _;
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
@@ -35,6 +35,7 @@ use std::net::SocketAddr;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
+use std::str::FromStr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle, available_parallelism, sleep};
@@ -50,6 +51,8 @@ const FAILURE_LOG_TAIL_LINES: usize = 8;
/// between parses as valid TOML with sections or keys missing; readiness
/// retries on this message.
const INCOMPLETE_DUMP_MESSAGE: &str = "Failed to parse server config: the dump is incomplete";
+/// The effective config the server dumps at boot, relative to its data path.
+const CONFIG_DUMP_PATH: &str = "runtime/current_config.toml";
/// How the last [`TestBinary::stop`] of a running server ended.
#[derive(Debug, Clone, Copy)]
@@ -155,6 +158,30 @@ impl ServerHandle {
.join(format!("server_{}_local_data", self.server_id))
}
+ /// The `cluster.commit_broadcast_interval` the server runs with, read from
+ /// the config it dumped at boot.
+ ///
+ /// # Errors
+ ///
+ /// [`TestBinaryError::InvalidState`] when the dump cannot be read or holds
+ /// no valid interval.
+ pub fn commit_broadcast_interval(&self) -> Result<Duration, TestBinaryError> {
+ let path = self.data_path().join(CONFIG_DUMP_PATH);
+ let invalid = |reason: String| TestBinaryError::InvalidState {
+ message: format!("{}: {reason}", path.display()),
+ };
+ let dump = fs::read_to_string(&path).map_err(|error| invalid(error.to_string()))?;
+ let config: Value = toml::from_str(&dump).map_err(|error| invalid(error.to_string()))?;
+ let interval = config
+ .get("cluster")
+ .and_then(|cluster| cluster.get("commit_broadcast_interval"))
+ .and_then(Value::as_str)
+ .ok_or_else(|| invalid("no cluster.commit_broadcast_interval".to_string()))?;
+ IggyDuration::from_str(interval)
+ .map(|interval| interval.get_duration())
+ .map_err(|error| invalid(format!("commit_broadcast_interval {interval:?}: {error}")))
+ }
+
fn stdout_log_path(&self) -> PathBuf {
self.context
.base_dir()
@@ -261,6 +288,15 @@ impl ServerHandle {
self.stdout_contains("replica mesh complete")
}
+ /// True once this node has logged that it admits partition requests.
+ /// A replica in a multi-node cluster refuses them as transient until it
+ /// holds fresh quorum evidence, which arrives one probe round after the
+ /// mesh forms. A single-node server never emits the marker.
+ #[must_use]
+ pub fn partition_admission_open(&self) -> bool {
+ self.stdout_contains("partition admission opened")
+ }
+
/// True once this node's stdout log contains `marker`. Spec tests use
/// this to pin server-side behavior that has no client-visible effect on
/// this node (e.g. a follower completing a state transfer).
@@ -572,7 +608,7 @@ impl ServerHandle {
fn wait_for_server_ready(&mut self) -> Result<(), TestBinaryError> {
let data_path = self.data_path();
- let config_path = data_path.join("runtime/current_config.toml");
+ let config_path = data_path.join(CONFIG_DUMP_PATH);
let max_attempts = (MAX_PORT_WAIT_DURATION_S * 1000) / SLEEP_INTERVAL_MS;
let deadline = Instant::now() + Duration::from_secs(MAX_PORT_WAIT_DURATION_S);
@@ -1017,7 +1053,7 @@ impl TestBinary for ServerHandle {
self.build_envs()?;
let data_path = self.data_path();
- let config_path = data_path.join("runtime/current_config.toml");
+ let config_path = data_path.join(CONFIG_DUMP_PATH);
if config_path.exists() {
let _ = fs::remove_file(&config_path);
}
diff --git a/core/integration/src/harness/helpers.rs b/core/integration/src/harness/helpers.rs
index ec2aef4ce..47bb1ba6a 100644
--- a/core/integration/src/harness/helpers.rs
+++ b/core/integration/src/harness/helpers.rs
@@ -15,9 +15,11 @@
// specific language governing permissions and limitations
// under the License.
+use crate::harness::{TestBinary, TestHarness};
use iggy::prelude::{
ConsumerGroupClient, ConsumerGroupDetails, DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME,
- GlobalPermissions, Identifier, IdentityInfo, Permissions, StreamClient, UserClient, UserStatus,
+ GlobalPermissions, Identifier, IdentityInfo, Permissions, StreamClient, TopicClient,
+ TopicDetails, UserClient, UserStatus,
};
use std::collections::HashSet;
use std::time::Duration;
@@ -25,6 +27,10 @@ use tokio::time::{Instant, sleep};
pub const USER_PASSWORD: &str = "secret";
const ASSIGNMENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
+const TOPIC_STATS_POLL_INTERVAL: Duration = Duration::from_millis(50);
+/// On an idle cluster a backup learns a commit only from the commit broadcast.
+/// Several broadcasts cover a late one.
+const BACKUP_COMMIT_BROADCASTS: u32 = 6;
/// Wait for the expected assignment distribution, checking uniqueness during convergence.
pub async fn wait_for_consumer_group_assignment(
@@ -77,6 +83,73 @@ pub async fn wait_for_consumer_group_assignment(
}
}
+/// Wait until `get_topic` counts `expected` messages in the topic.
+pub async fn wait_for_topic_messages_count(
+ client: &(impl TopicClient + ?Sized),
+ stream_id: &Identifier,
+ topic_id: &Identifier,
+ expected: u64,
+ budget: Duration,
+) {
+ let messages_count = wait_for_topic(client, stream_id, topic_id, budget, |details| {
+ details.messages_count == expected
+ })
+ .await
+ .messages_count;
+ assert_eq!(
+ messages_count, expected,
+ "topic messages_count is {messages_count} after {budget:?}, expected {expected}"
+ );
+}
+
+/// Wait until `get_topic` returns details that satisfy `ready` or `budget`
+/// runs out, and return the last details read.
+///
+/// Topic stats are per node. A node that is not the partition primary counts a
+/// message only after it learns the commit, so its stats can trail the send by
+/// up to [`backup_commit_budget`].
+pub async fn wait_for_topic(
+ client: &(impl TopicClient + ?Sized),
+ stream_id: &Identifier,
+ topic_id: &Identifier,
+ budget: Duration,
+ ready: impl Fn(&TopicDetails) -> bool,
+) -> TopicDetails {
+ let deadline = Instant::now() + budget;
+ loop {
+ let details = client
+ .get_topic(stream_id, topic_id)
+ .await
+ .expect("read topic")
+ .expect("topic exists");
+ if ready(&details) || Instant::now() >= deadline {
+ return details;
+ }
+ sleep(TOPIC_STATS_POLL_INTERVAL).await;
+ }
+}
+
+/// How long a backup can take to learn a commit at the longest
+/// `cluster.commit_broadcast_interval` the running servers of `harness` use.
+///
+/// # Panics
+///
+/// If no server runs, or a running server's config dump holds no valid interval.
+pub fn backup_commit_budget(harness: &TestHarness) -> Duration {
+ harness
+ .all_servers()
+ .iter()
+ .filter(|server| server.is_running())
+ .map(|server| {
+ server
+ .commit_broadcast_interval()
+ .expect("the commit broadcast interval of a running server")
+ })
+ .max()
+ .expect("a running server")
+ .saturating_mul(BACKUP_COMMIT_BROADCASTS)
+}
+
/// Login as root user.
pub async fn login_root(client: &impl UserClient) -> Result<IdentityInfo, iggy_common::IggyError> {
client
diff --git a/core/integration/src/harness/mod.rs b/core/integration/src/harness/mod.rs
index 0d3f5ae18..53e72f1e1 100644
--- a/core/integration/src/harness/mod.rs
+++ b/core/integration/src/harness/mod.rs
@@ -68,8 +68,8 @@ pub use orchestrator::{TestHarness, TestHarnessBuilder, TestLogs};
pub use traits::{IggyServerDependent, Restartable, TestBinary};
pub use helpers::{
- USER_PASSWORD, assert_clean_system, create_user, delete_user, login_root, login_user,
- wait_for_consumer_group_assignment,
+ USER_PASSWORD, assert_clean_system, backup_commit_budget, create_user, delete_user, login_root,
+ login_user, wait_for_consumer_group_assignment, wait_for_topic, wait_for_topic_messages_count,
};
pub use fixture::TestFixture;
diff --git a/core/integration/src/harness/orchestrator/harness.rs b/core/integration/src/harness/orchestrator/harness.rs
index c37b51a89..2e9345b9c 100644
--- a/core/integration/src/harness/orchestrator/harness.rs
+++ b/core/integration/src/harness/orchestrator/harness.rs
@@ -220,7 +220,6 @@ impl TestHarness {
}
const CLUSTER_READY_TIMEOUT: Duration = Duration::from_secs(15);
- const CLUSTER_READY_RETRY_INTERVAL: Duration = Duration::from_millis(200);
let deadline = Instant::now() + CLUSTER_READY_TIMEOUT;
@@ -231,30 +230,54 @@ impl TestHarness {
// every later op as a gap. Gating on all nodes meshed means the
// test runs against a fully constructed cluster with no late
// joiners.
- while Instant::now() < deadline
- && !self.servers.iter().all(ServerHandle::replica_mesh_complete)
- {
- sleep(CLUSTER_READY_RETRY_INTERVAL).await;
- }
- if !self.servers.iter().all(ServerHandle::replica_mesh_complete) {
- let per_node: Vec<String> = self
- .servers
- .iter()
- .enumerate()
- .map(|(i, s)| format!("node{i}={}", s.replica_mesh_complete()))
- .collect();
- return Err(TestBinaryError::InvalidState {
- message: format!(
- "Timed out waiting for VSR replica mesh to form on all nodes [{}]",
- per_node.join(", ")
- ),
- });
- }
+ self.wait_for_every_node(
+ deadline,
+ ServerHandle::replica_mesh_complete,
+ "VSR replica mesh to form",
+ )
+ .await?;
+ // Admission needs a probe round, not an op. A backup credits a
+ // probe only after applying the commit it carries, so the login
+ // waits until then instead of racing it.
+ self.wait_for_every_node(
+ deadline,
+ ServerHandle::partition_admission_open,
+ "partition admission to open",
+ )
+ .await?;
self.wait_for_login_ready(deadline).await
}
}
+ async fn wait_for_every_node(
+ &self,
+ deadline: Instant,
+ ready: fn(&ServerHandle) -> bool,
+ what: &str,
+ ) -> Result<(), TestBinaryError> {
+ const RETRY_INTERVAL: Duration = Duration::from_millis(200);
+
+ while Instant::now() < deadline && !self.servers.iter().all(ready) {
+ sleep(RETRY_INTERVAL).await;
+ }
+ if self.servers.iter().all(ready) {
+ return Ok(());
+ }
+ let per_node: Vec<String> = self
+ .servers
+ .iter()
+ .enumerate()
+ .map(|(i, s)| format!("node{i}={}", ready(s)))
+ .collect();
+ Err(TestBinaryError::InvalidState {
+ message: format!(
+ "Timed out waiting for {what} on all nodes [{}]",
+ per_node.join(", ")
+ ),
+ })
+ }
+
/// Poll a root login until one succeeds or `deadline` passes.
///
/// A root login is a replicated `Register`, so it cannot commit without a
diff --git a/core/integration/tests/cli/common/mod.rs b/core/integration/tests/cli/common/mod.rs
index 790d348f7..8e1e77c55 100644
--- a/core/integration/tests/cli/common/mod.rs
+++ b/core/integration/tests/cli/common/mod.rs
@@ -29,10 +29,12 @@ use async_trait::async_trait;
use iggy::prelude::Client;
use integration::harness::{
ServerHandle, TestBinary, TestHarness, TestHarnessBuilder, TestServerConfig,
+ backup_commit_budget,
};
use std::fmt::{Display, Formatter, Result};
use std::io::Write;
use std::process::{Command, Stdio};
+use std::time::Duration;
pub(crate) enum TestIdentifier {
Numeric,
@@ -157,6 +159,11 @@ impl IggyCmdTest {
self.clear_session_keyring();
}
+ /// See [`backup_commit_budget`]. Needs the servers that [`Self::setup`] starts.
+ pub(crate) fn backup_commit_budget(&self) -> Duration {
+ backup_commit_budget(&self.harness)
+ }
+
/// Drop any CLI session token the OS keyring holds for this server's
/// addresses.
///
diff --git a/core/integration/tests/cli/message/test_message_send_command.rs b/core/integration/tests/cli/message/test_message_send_command.rs
index abde36aef..718d08ca6 100644
--- a/core/integration/tests/cli/message/test_message_send_command.rs
+++ b/core/integration/tests/cli/message/test_message_send_command.rs
@@ -22,6 +22,7 @@ use assert_cmd::assert::Assert;
use async_trait::async_trait;
use iggy::prelude::defaults::{DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME};
use iggy::prelude::*;
+use integration::harness::wait_for_topic_messages_count;
use predicates::str::{contains, diff};
use serial_test::parallel;
use std::collections::BTreeMap;
@@ -60,12 +61,14 @@ struct TestMessageSendCmd {
message_input: ProvideMessages,
partitioning: PartitionSelection,
header: Option<BTreeMap<HeaderKey, HeaderValue>>,
+ commit_budget: Duration,
// These will be populated after creating the resources
actual_stream_id: Option<u32>,
actual_topic_id: Option<u32>,
}
impl TestMessageSendCmd {
+ #[allow(clippy::too_many_arguments)]
fn new(
stream_name: String,
topic_name: String,
@@ -74,6 +77,7 @@ impl TestMessageSendCmd {
message_input: ProvideMessages,
partitioning: PartitionSelection,
header: Option<BTreeMap<HeaderKey, HeaderValue>>,
+ commit_budget: Duration,
) -> Self {
Self {
stream_name,
@@ -83,6 +87,7 @@ impl TestMessageSendCmd {
message_input,
partitioning,
header,
+ commit_budget,
actual_stream_id: None,
actual_topic_id: None,
}
@@ -211,15 +216,14 @@ impl IggyCmdTestCase for TestMessageSendCmd {
}
async fn verify_server_state(&self, client: &dyn Client) {
- let topic = client
- .get_topic(
- &self.actual_stream_id.unwrap().try_into().unwrap(),
- &self.actual_topic_id.unwrap().try_into().unwrap(),
- )
- .await;
- assert!(topic.is_ok());
- let topic_details = topic.unwrap().expect("Failed to get topic");
- assert_eq!(topic_details.messages_count, self.messages.len() as u64);
+ wait_for_topic_messages_count(
+ client,
+ &self.actual_stream_id.unwrap().try_into().unwrap(),
+ &self.actual_topic_id.unwrap().try_into().unwrap(),
+ self.messages.len() as u64,
+ self.commit_budget,
+ )
+ .await;
// For Balanced partitioning, messages are distributed across all partitions
// so we need to poll from all partitions and collect messages
@@ -333,6 +337,7 @@ pub async fn should_be_successful() {
];
iggy_cmd_test.setup().await;
+ let commit_budget = iggy_cmd_test.backup_commit_budget();
for (message_input, using_partitioning) in test_parameters {
iggy_cmd_test
.execute_test(TestMessageSendCmd::new(
@@ -353,6 +358,7 @@ pub async fn should_be_successful() {
),
(HeaderKey::try_from("key2").unwrap(), 42i32.into()),
])),
+ commit_budget,
))
.await;
}
diff --git a/core/integration/tests/cli/topic/test_topic_get_command.rs b/core/integration/tests/cli/topic/test_topic_get_command.rs
index 8cf572a2e..ddaecb8c3 100644
--- a/core/integration/tests/cli/topic/test_topic_get_command.rs
+++ b/core/integration/tests/cli/topic/test_topic_get_command.rs
@@ -27,10 +27,11 @@ use iggy::prelude::Durability;
use iggy::prelude::IggyExpiry;
use iggy::prelude::defaults::{DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME};
use iggy::prelude::{IggyMessage, Partitioning, StreamClient, TopicClient, TopicCreateOptions};
-use integration::harness::{TestHarnessBuilder, TestServerConfig};
+use integration::harness::{TestHarnessBuilder, TestServerConfig, wait_for_topic_messages_count};
use predicates::prelude::PredicateBooleanExt;
use predicates::str::{contains, starts_with};
use serial_test::parallel;
+use std::time::Duration;
struct TestTopicGetCmd {
stream_id: u32,
@@ -44,6 +45,7 @@ struct TestTopicGetCmd {
consumer_offset_durability: Durability,
partition_ids: Vec<u32>,
populate_partitions: bool,
+ commit_budget: Duration,
}
impl TestTopicGetCmd {
@@ -65,10 +67,16 @@ impl TestTopicGetCmd {
consumer_offset_durability: Durability::Replicated,
partition_ids: Vec::new(),
populate_partitions: false,
+ commit_budget: Duration::ZERO,
}
}
- fn with_topic_options(mut self, partitions_count: u32, durability: Durability) -> Self {
+ fn with_topic_options(
+ mut self,
+ partitions_count: u32,
+ durability: Durability,
+ commit_budget: Duration,
+ ) -> Self {
self.partitions_count = partitions_count;
self.durability = durability;
self.consumer_offset_durability = match durability {
@@ -76,6 +84,7 @@ impl TestTopicGetCmd {
Durability::Replicated => Durability::Persisted,
};
self.populate_partitions = true;
+ self.commit_budget = commit_budget;
self
}
@@ -92,6 +101,14 @@ impl TestTopicGetCmd {
command
}
+
+ fn expected_messages_count(&self) -> u64 {
+ if self.populate_partitions {
+ (1..=u64::from(self.partitions_count)).sum()
+ } else {
+ 0
+ }
+ }
}
#[async_trait]
@@ -135,6 +152,14 @@ impl IggyCmdTestCase for TestTopicGetCmd {
.await
.expect("messages should be sent");
}
+ wait_for_topic_messages_count(
+ client,
+ &self.stream_id.try_into().unwrap(),
+ &self.topic_id.try_into().unwrap(),
+ self.expected_messages_count(),
+ self.commit_budget,
+ )
+ .await;
}
self.partition_ids = topic
@@ -167,11 +192,6 @@ impl IggyCmdTestCase for TestTopicGetCmd {
let start_message =
format!("Executing get topic with ID: {topic_id} from stream with ID: {stream_id}");
- let expected_messages_count = if self.populate_partitions {
- (1..=u64::from(self.partitions_count)).sum::<u64>()
- } else {
- 0
- };
let assertion = command_state
.success()
.stdout(starts_with(start_message))
@@ -185,7 +205,7 @@ impl IggyCmdTestCase for TestTopicGetCmd {
.stdout(contains("Max topic size | unlimited"))
.stdout(contains(format!(
"Topic message count | {}",
- expected_messages_count
+ self.expected_messages_count()
)))
.stdout(contains(format!(
"Partitions count | {}",
@@ -308,6 +328,7 @@ pub async fn should_be_successful() {
pub async fn should_show_multiple_partitions_and_persisted_durability() {
let mut iggy_cmd_test = IggyCmdTest::default();
iggy_cmd_test.setup().await;
+ let commit_budget = iggy_cmd_test.backup_commit_budget();
iggy_cmd_test
.execute_test(
TestTopicGetCmd::new(
@@ -316,7 +337,7 @@ pub async fn should_show_multiple_partitions_and_persisted_durability() {
TestStreamId::Named,
TestTopicId::Named,
)
- .with_topic_options(3, Durability::Persisted),
+ .with_topic_options(3, Durability::Persisted, commit_budget),
)
.await;
}
@@ -326,6 +347,7 @@ pub async fn should_show_multiple_partitions_and_persisted_durability() {
pub async fn should_show_multiple_partitions_using_numeric_identifiers() {
let mut iggy_cmd_test = IggyCmdTest::default();
iggy_cmd_test.setup().await;
+ let commit_budget = iggy_cmd_test.backup_commit_budget();
iggy_cmd_test
.execute_test(
TestTopicGetCmd::new(
@@ -334,7 +356,7 @@ pub async fn should_show_multiple_partitions_using_numeric_identifiers() {
TestStreamId::Numeric,
TestTopicId::Numeric,
)
- .with_topic_options(3, Durability::Replicated),
+ .with_topic_options(3, Durability::Replicated, commit_budget),
)
.await;
}
diff --git a/core/integration/tests/cluster/client_table_restart.rs b/core/integration/tests/cluster/client_table_restart.rs
index ec263f477..b243ac661 100644
--- a/core/integration/tests/cluster/client_table_restart.rs
+++ b/core/integration/tests/cluster/client_table_restart.rs
@@ -50,7 +50,7 @@
//! to speak it -- but it must stay credential-bearing.
use crate::server::raw_tcp::TEST_BIND_SECRET;
-use bytes::Bytes;
+use bytes::{Bytes, BytesMut};
use iggy::prelude::*;
use iggy_binary_protocol::codec::{WireDecode, WireEncode};
use iggy_binary_protocol::codes::{
@@ -63,21 +63,25 @@ use iggy_binary_protocol::consensus::{
use iggy_binary_protocol::primitives::consumer::WireConsumer;
use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
use iggy_binary_protocol::requests::consumer_groups::JoinConsumerGroupRequest;
+use iggy_binary_protocol::requests::messages::send_messages::{RawMessage, SendMessagesEncoder};
use iggy_binary_protocol::requests::messages::{GetPartitionRouteRequest, PollMessagesRequest};
use iggy_binary_protocol::requests::streams::CreateStreamRequest;
use iggy_binary_protocol::requests::system::{BindSessionRequest, SessionIdentity};
+use iggy_binary_protocol::requests::topics::CreateTopicRequest;
use iggy_binary_protocol::requests::users::LoginRegisterRequest;
use iggy_binary_protocol::requests::users::login_register::BindSecret;
use iggy_binary_protocol::responses::messages::{PartitionRouteResponse, PollMessagesResponse};
use iggy_binary_protocol::responses::users::LoginRegisterResponse;
use iggy_binary_protocol::{
ClientVersionInfo, HEADER_SIZE, IGGY_PROTOCOL_VERSION, WireIdentifier, WireName, WireOptions,
+ WirePartitioning,
};
use integration::harness::TestHarness;
use integration::iggy_harness;
use secrecy::SecretString;
use std::mem::offset_of;
use std::net::SocketAddr;
+use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -209,6 +213,136 @@ async fn given_committed_request_when_node_restarts_should_dedup_same_id_retry(
resume_request(&addrs, session, 1, &create_stream, Some(&committed)).await;
}
+#[iggy_harness(cluster_nodes = 3, server(metadata.partitions_max = "4"))]
+async fn given_inherited_create_when_cluster_bootstraps_from_wal_should_count_it_once(
+ harness: &mut TestHarness,
+) {
+ const STREAM: &str = "bootstrap-admission";
+ let (mut connection, session) = register(tcp_addr(harness)).await;
+ commit_request(&mut connection, session, 1, &create_stream_payload(STREAM)).await;
+ let topic_body = |name: &str, partitions_count| {
+ CreateTopicRequest {
+ stream_id: WireIdentifier::named(STREAM).unwrap(),
+ partitions_count,
+ name: WireName::new(name).unwrap(),
+ options: WireOptions::empty(),
+ }
+ .to_bytes()
+ };
+ let inherited = topic_body("inherited", 1);
+ let inherited_header = RequestHeader::for_request(
+ iggy_binary_protocol::codes::CREATE_TOPIC_CODE,
+ CLIENT_ID,
+ 2,
+ session,
+ &inherited,
+ )
+ .unwrap();
+ let committed = commit_request_header(&mut connection, &inherited_header, &inherited).await;
+ let receipt =
+ bytemuck::checked::try_pod_read_unaligned::<ReplyHeader>(committed.header.as_ref())
+ .unwrap();
+ harness.kill_cluster().unwrap();
+ drop(connection);
+ let paths: Vec<_> = (0..harness.cluster_size())
+ .map(|node| harness.node(node).data_path())
+ .collect();
+ let mut copies = 0;
+ for path in &paths {
+ let headers = metadata_wal_headers(path.clone());
+ if let Some(header) = headers
+ .iter()
+ .find(|header| header.client == CLIENT_ID && header.request == 2)
+ {
+ copies += 1;
+ assert_eq!(header.op, receipt.op);
+ assert_eq!(header.operation, Operation::CreateTopicWithAssignments);
+ assert!(
+ headers.iter().map(|header| header.commit).max().unwrap() < header.op,
+ "bootstrap must inherit the create above the WAL's provable commit floor"
+ );
+ }
+ }
+ assert!(
+ copies >= 2,
+ "the acknowledged create must be retained by a quorum"
+ );
+
+ harness.restart_cluster().await.unwrap();
+ let mut connection = resume_request_header(
+ &tcp_addrs(harness),
+ &inherited_header,
+ &inherited,
+ Some(&committed),
+ )
+ .await;
+ let remaining = topic_body("remaining", 3);
+ let remaining_header = RequestHeader::for_request(
+ iggy_binary_protocol::codes::CREATE_TOPIC_CODE,
+ CLIENT_ID,
+ 3,
+ session,
+ &remaining,
+ )
+ .unwrap();
+ commit_request_header(&mut connection, &remaining_header, &remaining).await;
+ let extra = topic_body("over-cap", 1);
+ let extra_header = RequestHeader::for_request(
+ iggy_binary_protocol::codes::CREATE_TOPIC_CODE,
+ CLIENT_ID,
+ 4,
+ session,
+ &extra,
+ )
+ .unwrap();
+ let deadline = Instant::now() + COMMIT_BUDGET;
+ loop {
+ match exchange(&mut connection, &extra_header, &extra)
+ .await
+ .verdict()
+ {
+ Verdict::Rejected(code) if code == IggyError::PartitionsLimitReached.as_code() => break,
+ Verdict::Rejected(code) if is_transient(code) && Instant::now() < deadline => {
+ sleep(RETRY_PAUSE).await
+ }
+ other => panic!("the inherited partition must consume exactly one cap slot: {other:?}"),
+ }
+ }
+ harness.kill_cluster().unwrap();
+ for path in paths {
+ let headers = metadata_wal_headers(path);
+ assert_eq!(
+ headers
+ .iter()
+ .filter(|header| header.client == CLIENT_ID && header.request == 2)
+ .count(),
+ 1,
+ "bootstrap and receipt replay must retain one prepare for the inherited create"
+ );
+ }
+}
+
+fn metadata_wal_headers(data_path: PathBuf) -> Vec<iggy_binary_protocol::PrepareHeader> {
+ let bytes = std::fs::read(data_path.join("metadata/journal.wal")).unwrap();
+ let mut position = 0;
+ let mut headers = Vec::new();
+ while let Some(raw) = bytes.get(position..position + HEADER_SIZE) {
+ let header =
+ bytemuck::checked::try_pod_read_unaligned::<iggy_binary_protocol::PrepareHeader>(raw)
+ .unwrap();
+ assert_eq!(header.command, Command::Prepare);
+ let size = usize::try_from(header.size).unwrap();
+ assert!(size >= HEADER_SIZE);
+ if bytes.get(position..position + size).is_none() {
+ break;
+ }
+ assert_eq!(header.checksum, header.identity_checksum());
+ headers.push(header);
+ position += size;
+ }
+ headers
+}
+
#[iggy_harness(cluster_nodes = [1, 3])]
async fn given_bound_session_when_node_restarts_should_accept_next_request_id(
harness: &mut TestHarness,
@@ -436,7 +570,7 @@ async fn commit_request_header(
assert_eq!(receipt.client, header.client);
assert_eq!(receipt.request, header.request);
assert_eq!(receipt.request_checksum, header.request_checksum);
- assert_eq!(receipt.operation, header.operation);
+ assert_eq!(receipt.operation.request_operation(), header.operation);
assert_eq!(receipt.partition_incarnation, header.partition_incarnation);
assert_ne!(receipt.op, 0);
assert_eq!(receipt.op, receipt.commit);
@@ -471,6 +605,19 @@ pub(super) async fn resume_request(
body: &Bytes,
expect_replay_of: Option<&CommittedReply>,
) {
+ let header =
+ RequestHeader::for_request(CREATE_STREAM_CODE, CLIENT_ID, request, session, body).unwrap();
+ drop(resume_request_header(addrs, &header, body, expect_replay_of).await);
+}
+
+async fn resume_request_header(
+ addrs: &[SocketAddr],
+ header: &RequestHeader,
+ body: &Bytes,
+ expect_replay_of: Option<&CommittedReply>,
+) -> TcpStream {
+ let session = header.session;
+ let request = header.request;
let deadline = Instant::now() + RESUME_BUDGET;
let mut last_failure = "the listener never came back".to_string();
let mut attempt = 0usize;
@@ -483,14 +630,13 @@ pub(super) async fn resume_request(
continue;
};
// Keep the epoch so continuation retries use the recovered receipt identity.
- let resumed = match login_on(&mut stream, CLIENT_ID).await {
+ match login_on(&mut stream, CLIENT_ID).await {
Some(resumed) => {
assert!(
resumed == session,
"rebind must preserve the recovered session \
(old {session}, got {resumed})"
);
- resumed
}
None => {
last_failure = "re-login did not commit".to_string();
@@ -498,15 +644,12 @@ pub(super) async fn resume_request(
continue;
}
};
- let header =
- RequestHeader::for_request(CREATE_STREAM_CODE, CLIENT_ID, request, resumed, body)
- .unwrap();
- match exchange(&mut stream, &header, body).await.verdict() {
+ match exchange(&mut stream, header, body).await.verdict() {
Verdict::Success(reply) => {
if let Some(original) = expect_replay_of {
assert_replayed_from_cache(original, &reply, request);
}
- return;
+ return stream;
}
Verdict::Rejected(code) if is_transient(code) => {
last_failure = format!("still transient (code {code})");
@@ -709,6 +852,7 @@ const LIVENESS_STREAM: &str = "session-liveness";
const LIVENESS_TOPIC: &str = "events";
const LIVENESS_GROUP: &str = "readers";
const LIVENESS_OBSERVATION: Duration = Duration::from_secs(12);
+const LIVENESS_CONSUMER: u32 = 1;
#[iggy_harness(server(
metadata.clients_table_max = 4,
@@ -1156,6 +1300,85 @@ async fn given_primary_member_when_host_crashes_should_expire_membership(
assert_eq!(after.members[0].id, expired.members[0].id);
}
+/// A session needs no socket to the metadata primary. The partition primary
+/// it still talks to reports its activity, which renews the lease.
+#[iggy_harness(cluster_nodes = 3, server(
+ heartbeat.enabled = true,
+ heartbeat.interval = "7s",
+ consumer_group.heartbeat_interval = "500ms",
+ sharding.cpu_allocation = "0..1",
+))]
+async fn given_a_cut_metadata_link_when_partition_links_stay_up_should_keep_sending_and_polling(
+ harness: &mut TestHarness,
+) {
+ let observer = harness.root_client_for_node(0).await.unwrap();
+ let partitions_count = u32::try_from(harness.cluster_size()).unwrap();
+ observer.create_stream(LIVENESS_STREAM).await.unwrap();
+ observer
+ .create_topic(
+ &Identifier::named(LIVENESS_STREAM).unwrap(),
+ LIVENESS_TOPIC,
+ &TopicCreateOptions {
+ partitions_count: Some(partitions_count),
+ durability: Durability::Persisted,
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+ let metadata_primary = primary_index(harness, &observer).await;
+ let metadata_address = harness.node(metadata_primary).tcp_addr().unwrap();
+ let (mut metadata_link, session) = register(metadata_address).await;
+ let mut backup_route = None;
+ for partition_id in 0..partitions_count {
+ let route = partition_route(&mut metadata_link, session, partition_id).await;
+ if usize::from(route.primary_replica) != metadata_primary {
+ backup_route = Some((partition_id, route));
+ break;
+ }
+ }
+ let (partition_id, route) =
+ backup_route.expect("spread placement must give a metadata backup a partition primary");
+ let partition_address = harness
+ .node(usize::from(route.primary_replica))
+ .tcp_addr()
+ .unwrap();
+ let (mut partition_link, rebound) = register(partition_address).await;
+ assert_eq!(
+ rebound, session,
+ "the partition link must join the same logical session"
+ );
+ drop(metadata_link);
+
+ let context = route.context();
+ let deadline = Instant::now() + LIVENESS_OBSERVATION;
+ let mut offset = 0;
+ while Instant::now() < deadline {
+ let payload = format!("activity-{offset}");
+ send_payload(
+ &mut partition_link,
+ session,
+ context,
+ partition_id,
+ offset + 1,
+ payload.as_bytes(),
+ )
+ .await;
+ assert_eq!(
+ poll_payload(&mut partition_link, session, context, partition_id, offset).await,
+ payload.as_bytes(),
+ "the poll at offset {offset} must read the message just sent"
+ );
+ offset += 1;
+ sleep(RETRY_PAUSE).await;
+ }
+ let (_, resumed) = register(metadata_address).await;
+ assert_eq!(
+ resumed, session,
+ "the metadata primary must keep the session its partition link kept active"
+ );
+}
+
async fn liveness_group(observer: &IggyClient) -> ConsumerGroupDetails {
observer
.get_consumer_group(
@@ -1261,3 +1484,122 @@ async fn create_liveness_group(observer: &IggyClient) {
.await
.unwrap();
}
+
+/// The route of a liveness partition, replayed while the partition is not yet
+/// routable.
+async fn partition_route(
+ connection: &mut TcpStream,
+ session: u64,
+ partition_id: u32,
+) -> PartitionRouteResponse {
+ let route = GetPartitionRouteRequest {
+ stream_id: WireIdentifier::named(LIVENESS_STREAM).unwrap(),
+ topic_id: WireIdentifier::named(LIVENESS_TOPIC).unwrap(),
+ partition_id,
+ }
+ .to_bytes();
+ let mut header = request_header(Operation::NonReplicated, session, 0, route.len());
+ header.reserved[..size_of::<u32>()].copy_from_slice(&GET_PARTITION_ROUTE_CODE.to_le_bytes());
+ let deadline = Instant::now() + COMMIT_BUDGET;
+ loop {
+ match exchange(connection, &header, &route).await {
+ Exchange::Reply {
+ status: 0, body, ..
+ } => return PartitionRouteResponse::decode_from(&body).unwrap(),
+ Exchange::Reply { status, .. } if is_transient(status) && Instant::now() < deadline => {
+ sleep(RETRY_PAUSE).await;
+ }
+ other => panic!("partition {partition_id} never became routable: {other:?}"),
+ }
+ }
+}
+
+/// Send one message to a liveness partition, replayed while its primary
+/// refuses it transiently.
+async fn send_payload(
+ connection: &mut TcpStream,
+ session: u64,
+ context: PartitionContext,
+ partition_id: u32,
+ request: u64,
+ payload: &[u8],
+) {
+ let body = send_messages_body(partition_id, payload);
+ let mut header = request_header(Operation::SendMessages, session, request, body.len());
+ context.stamp(&mut header);
+ let deadline = Instant::now() + COMMIT_BUDGET;
+ loop {
+ match exchange(connection, &header, &body).await {
+ Exchange::Reply { status: 0, .. } => return,
+ Exchange::Reply { status, .. } if is_transient(status) && Instant::now() < deadline => {
+ sleep(RETRY_PAUSE).await;
+ }
+ other => panic!("send request {request} did not commit: {other:?}"),
+ }
+ }
+}
+
+fn send_messages_body(partition_id: u32, payload: &[u8]) -> Bytes {
+ let stream_id = WireIdentifier::named(LIVENESS_STREAM).unwrap();
+ let topic_id = WireIdentifier::named(LIVENESS_TOPIC).unwrap();
+ let partitioning = WirePartitioning::PartitionId(partition_id);
+ let messages = [RawMessage {
+ id: 0,
+ origin_timestamp: 0,
+ headers: None,
+ payload,
+ }];
+ let mut body = BytesMut::with_capacity(SendMessagesEncoder::encoded_size(
+ &stream_id,
+ &topic_id,
+ &partitioning,
+ &messages,
+ ));
+ SendMessagesEncoder::encode(&mut body, &stream_id, &topic_id, &partitioning, &messages)
+ .unwrap();
+ body.freeze()
+}
+
+/// The payload at `offset` of a liveness partition, polled until the committed
+/// message is readable.
+async fn poll_payload(
+ connection: &mut TcpStream,
+ session: u64,
+ context: PartitionContext,
+ partition_id: u32,
+ offset: u64,
+) -> Vec<u8> {
+ let poll = PollMessagesRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(LIVENESS_CONSUMER)),
+ stream_id: WireIdentifier::named(LIVENESS_STREAM).unwrap(),
+ topic_id: WireIdentifier::named(LIVENESS_TOPIC).unwrap(),
+ partition_id: Some(partition_id),
+ strategy: WirePollingStrategy::offset(offset),
+ count: 1,
+ auto_commit: false,
+ }
+ .to_bytes();
+ let mut header = request_header(Operation::NonReplicated, session, 0, poll.len());
+ header.reserved[..size_of::<u32>()].copy_from_slice(&POLL_MESSAGES_CODE.to_le_bytes());
+ context.stamp(&mut header);
+ let deadline = Instant::now() + COMMIT_BUDGET;
+ loop {
+ let polled = match exchange(connection, &header, &poll).await {
+ Exchange::Reply {
+ status: 0, body, ..
+ } => body,
+ other => panic!("the poll at offset {offset} was refused: {other:?}"),
+ };
+ let mut response = PollMessagesResponse::decode(&polled).unwrap();
+ if let Some(message) = response.messages.next() {
+ let message = message.unwrap();
+ assert_eq!(message.offset, offset);
+ return message.payload.to_vec();
+ }
+ assert!(
+ Instant::now() < deadline,
+ "the message at offset {offset} never became readable"
+ );
+ sleep(RETRY_PAUSE).await;
+ }
+}
diff --git a/core/integration/tests/cluster/parked_frame_redispatch.rs b/core/integration/tests/cluster/parked_frame_redispatch.rs
index 9a47fa431..572ced23f 100644
--- a/core/integration/tests/cluster/parked_frame_redispatch.rs
+++ b/core/integration/tests/cluster/parked_frame_redispatch.rs
@@ -27,15 +27,15 @@
//! its op has quorum, so the backup gap-stops and waits out `tick_partitions`'
//! repair debounce before anything refetches it.
//!
-//! What makes the window wide on a backup is the commit broadcast. A backup
-//! learns a metadata commit from the `commit` field of the next prepare on that
-//! plane or from the primary's `CommitMessage` heartbeat, whose interval is
-//! `cluster.commit_broadcast_interval` (500ms by default). Nothing in
-//! `create_topic`'s reply path waits for that, so a produce issued the instant
-//! `create_topic` returns reaches the backups as a partition prepare for a
-//! namespace they have not yet heard of, let alone built. Four producers on
-//! their own connections keep a burst in flight across that gap, so several ops
-//! of one partition park together and the order they leave in is observable.
+//! The window on a backup is the metadata hand-off. A backup learns a metadata
+//! commit from the `commit` field of the next prepare on that plane or from the
+//! `Commit` the primary sends on its next consensus tick, and then still has to
+//! build the namespace. Nothing in `create_topic`'s reply path waits for that,
+//! so a produce issued the instant `create_topic` returns reaches the backups as
+//! a partition prepare for a namespace they have not yet heard of, let alone
+//! built. Four producers on their own connections keep a burst in flight across
+//! that gap, so several ops of one partition park together and the order they
+//! leave in is observable.
//!
//! Three things are asserted, and they fail separately:
//!
@@ -69,8 +69,7 @@ use integration::iggy_harness;
use tokio::time::{Instant, sleep};
const STREAM: &str = "parked-redispatch-stream";
-/// Each topic is one shot at the race, and each costs about one commit
-/// broadcast interval.
+/// Each topic is one shot at the race.
const TOPICS: u32 = 6;
const PARTITIONS: u32 = 4;
/// The reconciler builds a topic's namespaces in id order, so the last one has
diff --git a/core/integration/tests/cluster/partition_dedup.rs b/core/integration/tests/cluster/partition_dedup.rs
index 2b44bc306..f1fb57c48 100644
--- a/core/integration/tests/cluster/partition_dedup.rs
+++ b/core/integration/tests/cluster/partition_dedup.rs
@@ -27,6 +27,8 @@
//! which is precisely the input under test. The SDK is still used for setup and
//! for reading the log back, where it is the more honest observer.
+use super::partition_primary_routing::{partition_route, pause_node};
+use super::register_forwarding::connect_without_login;
use crate::server::raw_tcp::{self, TEST_BIND_SECRET};
use bytes::{Bytes, BytesMut};
use consensus::client_table::COMMITTED_WINDOW_BITS;
@@ -47,6 +49,7 @@ use iggy_binary_protocol::{
AckLevel, ClientVersionInfo, HEADER_SIZE, IGGY_PROTOCOL_VERSION, WireConsumer, WireIdentifier,
WireName, WirePartitioning,
};
+use iggy_common::with_binary_deadline;
use integration::harness::TestHarness;
use integration::iggy_harness;
use journal::partition_journal::{FRONTIER_FILE_NAME, PARTITION_WAL_BLOCK_SIZE};
@@ -290,6 +293,142 @@ async fn verify_lost_reply_restart(harness: &mut TestHarness) {
);
}
+#[iggy_harness(cluster_nodes = 1, server(sharding.cpu_allocation = "0..1"))]
+async fn given_lost_send_reply_on_singleton_when_send_permission_is_revoked_should_deny_retries_until_regrant(
+ harness: &mut TestHarness,
+) {
+ verify_lost_reply_after_send_revocation(harness).await;
+}
+
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
+async fn given_lost_send_reply_on_cluster_when_send_permission_is_revoked_should_deny_retries_until_regrant(
+ harness: &mut TestHarness,
+) {
+ verify_lost_reply_after_send_revocation(harness).await;
+}
+
+/// The client retains a send whose reply was lost. While its user lacks the
+/// send permission, the partition primary denies each retry before its dedup
+/// reads the receipt, so the send stays uncertain. After a re-grant, the retry
+/// returns the original receipt. Node 0 is the metadata primary and the
+/// primary of the first topic's partition, so it applies a permission change
+/// before the root client sees it commit.
+async fn verify_lost_reply_after_send_revocation(harness: &mut TestHarness) {
+ const USERNAME: &str = "partition-dedup-producer";
+ const PASSWORD: &str = "partition-dedup-password";
+ // Long enough for the frame to reach the stopped node, far below the
+ // cluster heartbeat timeout.
+ const LOST_REPLY_DEADLINE: Duration = Duration::from_secs(1);
+ const DENIAL_MARKER: &str = "partition request denied by authorization";
+ let permissions = |send_messages| Permissions {
+ global: GlobalPermissions {
+ send_messages,
+ // The route lookup of a retry passes on this one alone.
+ poll_messages: true,
+ ..GlobalPermissions::default()
+ },
+ streams: None,
+ };
+ let observer = harness.root_client_for_node(0).await.unwrap();
+ seed_topic(&observer).await;
+ let route_client = connect_without_login(harness.node(0).tcp_addr().unwrap()).await;
+ route_client
+ .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+ .await
+ .unwrap();
+ let route = partition_route(
+ &route_client,
+ &Identifier::named(STREAM_NAME).unwrap(),
+ &Identifier::named(TOPIC_NAME).unwrap(),
+ PARTITION_ID,
+ )
+ .await;
+ assert_eq!(
+ route.primary_replica, 0,
+ "the stopped node must own the partition whose reply is lost"
+ );
+ drop(route_client);
+ observer
+ .create_user(
+ USERNAME,
+ PASSWORD,
+ UserStatus::Active,
+ Some(permissions(true)),
+ )
+ .await
+ .expect("create the producer user");
+ let producer = harness.new_client().await.unwrap();
+ producer
+ .login_user(USERNAME, PASSWORD)
+ .await
+ .expect("sign in the producer");
+ // A committed send caches the route, so the next send needs no lookup.
+ send_one(&producer, b"before-lost-reply")
+ .await
+ .expect("send while every node runs");
+
+ pause_node(harness, 0, true);
+ let lost = with_binary_deadline(
+ Instant::now() + LOST_REPLY_DEADLINE,
+ send_one(&producer, b"lost-reply"),
+ )
+ .await;
+ pause_node(harness, 0, false);
+ let Err(IggyError::OperationUncertain(op)) = lost else {
+ panic!("a send without a reply must end uncertain, got {lost:?}");
+ };
+ let deadline = Instant::now() + COMMIT_BUDGET;
+ while poll_all(&observer).await != 2 {
+ assert!(
+ Instant::now() < deadline,
+ "the unanswered send must commit after the node resumes"
+ );
+ sleep(RETRY_PAUSE).await;
+ }
+
+ let user = Identifier::named(USERNAME).unwrap();
+ observer
+ .update_permissions(&user, Some(permissions(false)))
+ .await
+ .expect("revoke the send permission");
+ assert_eq!(
+ producer
+ .resolve_operation(op, std::time::Instant::now() + REPLY_WAIT)
+ .await,
+ Err(IggyError::OperationUncertain(op)),
+ "a retry without the send permission must not return the receipt"
+ );
+ await_marker(harness, 0, DENIAL_MARKER).await;
+ let unauthorized = format!("status={}", IggyError::Unauthorized.as_code());
+ assert!(
+ harness
+ .node(0)
+ .stdout_plain()
+ .lines()
+ .any(|line| line.contains(DENIAL_MARKER) && line.contains(&unauthorized)),
+ "the partition primary must deny the retry with {unauthorized}"
+ );
+ assert_eq!(
+ send_one(&producer, b"behind-uncertain-send").await,
+ Err(IggyError::LaneBlocked(op)),
+ "the client must keep the uncertain send"
+ );
+
+ observer
+ .update_permissions(&user, Some(permissions(true)))
+ .await
+ .expect("grant the send permission again");
+ producer
+ .resolve_operation(op, std::time::Instant::now() + REPLY_WAIT)
+ .await
+ .expect("after a re-grant the retry must return the original receipt");
+ assert_eq!(
+ poll_all(&observer).await,
+ 2,
+ "the resolved retry must not append again"
+ );
+}
+
#[iggy_harness(cluster_nodes = 3, server(partition.wal_bytes_max = "134225920 B", sharding.cpu_allocation = "0..1"))]
async fn given_reclaimed_wal_when_recovered_quorum_replays_should_return_the_original_receipt(
harness: &mut TestHarness,
@@ -948,6 +1087,24 @@ async fn seed_topic(client: &IggyClient) -> PartitionContext {
.await
}
+async fn send_one(client: &IggyClient, payload: &'static [u8]) -> Result<(), IggyError> {
+ let mut messages = vec![
+ IggyMessage::builder()
+ .payload(Bytes::from_static(payload))
+ .build()
+ .expect("build one message"),
+ ];
+ client
+ .send_messages(
+ &Identifier::named(STREAM_NAME).expect("stream identifier"),
+ &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+ &Partitioning::partition_id(PARTITION_ID),
+ &mut messages,
+ )
+ .await
+ .map(|_| ())
+}
+
async fn poll_all(client: &IggyClient) -> u32 {
poll_up_to(client, 100).await
}
diff --git a/core/integration/tests/cluster/partition_primary_routing.rs b/core/integration/tests/cluster/partition_primary_routing.rs
index 5bdb574b0..1d2fbe56f 100644
--- a/core/integration/tests/cluster/partition_primary_routing.rs
+++ b/core/integration/tests/cluster/partition_primary_routing.rs
@@ -16,11 +16,12 @@
// under the License.
//! Metadata and partition consensus groups choose primaries independently.
-//! New partitions must inherit the metadata view, while existing partitions
+//! New partitions use their committed placement views, while existing partitions
//! can retain a different primary after a metadata-only election. SDK clients
//! settle on the metadata leader, so both writes to new partitions and group
//! polls of existing partitions must reach an owner that can commit progress.
+use std::net::SocketAddr;
use std::str::FromStr;
use std::time::Duration;
@@ -60,10 +61,13 @@ use integration::iggy_harness;
use journal::superblock::{PingPongSuperblock, SuperblockStore};
use reqwest::StatusCode;
use reqwest::header::RETRY_AFTER;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{Instant, sleep, timeout};
-use crate::server::http_client::{HttpClient, applied_op, node_url, until_primary_resolved};
+use crate::server::http_client::{
+ HttpClient, access_token, applied_op, node_url, until_primary_resolved,
+};
use crate::server::raw_tcp;
const STREAM_NAME: &str = "partition-routing-stream";
@@ -77,9 +81,53 @@ const RELAYED_PAYLOADS: [&str; 2] = ["relayed-acked", "relayed-unacked"];
/// The marker and the route of an HTTP request that one node relays to another.
const FORWARDED_HEADER: &str = "iggy-forwarded";
const PARTITION_ROUTE_HEADER: &str = "iggy-partition-route";
+/// The `<view>,<replica>` a hop target's `NotPrimary` names as its primary.
+const PARTITION_PRIMARY_HEADER: &str = "iggy-partition-primary";
/// Partition 0 at incarnation 1, owner generation 0 and metadata op 0, with
/// 5 s left. Every replica applied op 0, so the metadata hold ends at once.
const ROUTE_AT_FIRST_OP: &str = "0,1,0,0,5000";
+/// The hop target's budget for the relays it serves, and each credential's
+/// share of it, as the server sizes them.
+const RELAY_TARGET_BUDGET: usize = 128;
+const RELAY_TARGET_SHARE: usize = 32;
+const SPREAD_PARTITIONS: u32 = 3;
+const RELAY_TARGET_NODE: usize = 1;
+const RELAY_TARGET_PARTITION: u32 = 1;
+/// The longest budget a hop target grants a relay: its own request deadline.
+const RELAY_BUDGET: Duration = Duration::from_secs(10);
+/// Declared by a stalled upload, which never sends it in full.
+const STALLED_BODY_LENGTH: usize = 1024;
+/// How late a stalled upload may end on a loaded machine.
+const DEADLINE_SLACK: Duration = Duration::from_secs(10);
+/// A node's outbound relay slots, shared by its metadata and partition relays,
+/// and the longest it relays a metadata request, as the server sizes them.
+const FORWARD_SLOTS: u64 = 128;
+const FORWARD_DEADLINE: Duration = Duration::from_secs(30);
+const REQUESTS_METRIC: &str = "http_requests_total";
+const RELAY_PROBE_INTERVAL: Duration = Duration::from_millis(100);
+/// A node proves its relay fields under a key derived from the cluster key
+/// material, here the `http.jwt` secret, with the context the server freezes.
+const RELAY_PROOF_HEADER: &str = "iggy-relay-proof";
+const RELAY_KEY_MATERIAL: &str = "0123456789abcdef0123456789abcdef";
+const RELAY_KEY_CONTEXT: &str = "apache-iggy server http-relay v1 key->hmac-sha256-key";
+const RELAY_PROOF_LIFETIME_MICROS: u64 = 10_000_000;
+const RELAY_FORGER: &str = "relay-forger";
+const RELAY_FORGER_PASSWORD: &str = "relay-forger-password";
+/// The node that relays the caller's request to the hop target.
+const RELAY_ENTRY_NODE: usize = 2;
+/// Lets forged relays reach the hop target before the genuine ones.
+const FORGED_HEAD_START: Duration = Duration::from_secs(1);
+/// How long a relay for an op the cluster has not committed must stay held.
+const HOLD_PROBE: Duration = Duration::from_secs(1);
+const HELD_PAYLOAD: &str = "held-for-a-fresh-op";
+const PROBES_METRIC: &str = "http_partition_primary_probes_total";
+const RELAYS_METRIC: &str = "http_partition_relays_total";
+const FLOOR_READS_METRIC: &str = "http_metadata_floor_reads_total";
+const SESSION_REGISTERS_METRIC: &str = "http_session_registers_total";
+const METADATA_FLOOR_PATH: &str = "/cluster/metadata-floor";
+const FLOOR_STREAM: &str = "moves-the-commit-floor";
+/// Created to commit the op a held relay waits for.
+const FRONTIER_STREAM: &str = "moves-the-metadata-frontier";
const GROUP_POLL_BUDGET: Duration = Duration::from_secs(20);
// QUIC may use the full 30-second response budget before a dead data connection
// reports an uncertain outcome; recovery then needs a fresh routing attempt.
@@ -102,6 +150,7 @@ const SEND_BUDGET: Duration = Duration::from_secs(20);
/// the state this test needs before it can observe anything.
const PRECONDITION_BUDGET: Duration = Duration::from_secs(20);
const PRECONDITION_POLL: Duration = Duration::from_millis(500);
+const REBALANCE_BUDGET: Duration = Duration::from_secs(120);
fn message(payload: &str) -> IggyMessage {
IggyMessage::from_str(payload).expect("build message")
@@ -119,8 +168,406 @@ fn send_body(payload: &str) -> SendMessages {
}
}
+pub(super) async fn partition_route(
+ client: &TcpClient,
+ stream: &Identifier,
+ topic: &Identifier,
+ partition_id: u32,
+) -> PartitionRouteResponse {
+ let request = GetPartitionRouteRequest {
+ stream_id: identifier_to_wire(stream).unwrap(),
+ topic_id: identifier_to_wire(topic).unwrap(),
+ partition_id,
+ }
+ .to_bytes();
+ timeout(PRECONDITION_BUDGET, async {
+ loop {
+ match client
+ .send_raw_with_response(GET_PARTITION_ROUTE_CODE, request.clone())
+ .await
+ {
+ Ok(reply) => break PartitionRouteResponse::decode_from(&reply).unwrap(),
+ Err(IggyError::TransientNotAccepted) => sleep(PRECONDITION_POLL).await,
+ Err(error) => panic!("route {stream}/{topic}/{partition_id}: {error}"),
+ }
+ }
+ })
+ .await
+ .unwrap_or_else(|_| panic!("partition {stream}/{topic}/{partition_id} never became routable"))
+}
+
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "2"))]
+async fn given_concurrent_creates_when_growing_partitions_should_spread_and_serve_every_partition(
+ harness: &mut TestHarness,
+) {
+ const FIRST_TOPIC: &str = "spread-first";
+ const SECOND_TOPIC: &str = "spread-second";
+ const THIRD_TOPIC: &str = "spread-third";
+ const INITIAL_PARTITIONS: u32 = 3;
+ const ADDED_PARTITIONS: u32 = 3;
+ const SECOND_PARTITIONS: u32 = 2;
+ const THIRD_PARTITIONS: u32 = 4;
+
+ let first = connect_without_login(harness.node(0).tcp_addr().unwrap()).await;
+ first
+ .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+ .await
+ .unwrap();
+ let second = connect_without_login(harness.node(0).tcp_addr().unwrap()).await;
+ second
+ .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+ .await
+ .unwrap();
+ first.create_stream(STREAM_NAME).await.unwrap();
+ let stream = Identifier::named(STREAM_NAME).unwrap();
+ let options = TopicCreateOptions {
+ partitions_count: Some(INITIAL_PARTITIONS),
+ durability: Durability::Persisted,
+ message_expiry: Some(IggyExpiry::NeverExpire),
+ ..TopicCreateOptions::default()
+ };
+ first
+ .create_topic(&stream, FIRST_TOPIC, &options)
+ .await
+ .unwrap();
+ let first_topic = Identifier::named(FIRST_TOPIC).unwrap();
+ for partition_id in 0..INITIAL_PARTITIONS {
+ let route = partition_route(&first, &stream, &first_topic, partition_id).await;
+ assert_eq!(u32::from(route.primary_replica), partition_id);
+ assert_eq!(route.view, partition_id);
+ }
+
+ let second_options = TopicCreateOptions {
+ partitions_count: Some(SECOND_PARTITIONS),
+ ..options.clone()
+ };
+ let third_options = TopicCreateOptions {
+ partitions_count: Some(THIRD_PARTITIONS),
+ ..options
+ };
+ let (second_result, third_result) = futures::join!(
+ first.create_topic(&stream, SECOND_TOPIC, &second_options),
+ second.create_topic(&stream, THIRD_TOPIC, &third_options),
+ );
+ second_result.unwrap();
+ third_result.unwrap();
+ let mut topics = [
+ (FIRST_TOPIC, INITIAL_PARTITIONS),
+ (SECOND_TOPIC, SECOND_PARTITIONS),
+ (THIRD_TOPIC, THIRD_PARTITIONS),
+ ];
+ assert_initial_spread(&first, &stream, &topics, harness.cluster_size()).await;
+ first
+ .create_partitions(&stream, &first_topic, ADDED_PARTITIONS)
+ .await
+ .unwrap();
+ topics[0].1 += ADDED_PARTITIONS;
+ assert_initial_spread(&first, &stream, &topics, harness.cluster_size()).await;
+
+ let mut original_routes = Vec::new();
+ for (name, count) in topics {
+ let topic = Identifier::named(name).unwrap();
+ for partition_id in 0..count {
+ let route = partition_route(&first, &stream, &topic, partition_id).await;
+ original_routes.push((name, partition_id, route));
+ let payload = format!("{name}-{partition_id}");
+ let mut messages = vec![message(&payload)];
+ first
+ .send_messages(
+ &stream,
+ &topic,
+ &Partitioning::partition_id(partition_id),
+ &mut messages,
+ )
+ .await
+ .unwrap();
+ assert_partition_payload(&second, &stream, &topic, partition_id, &payload).await;
+ }
+ }
+ drop(first);
+ drop(second);
+ for node in 0..harness.cluster_size() {
+ harness.stop_node(node).unwrap();
+ }
+ for node in 0..harness.cluster_size() {
+ harness.restart_node(node).unwrap();
+ }
+ let recovered = connect_without_login(harness.node(0).tcp_addr().unwrap()).await;
+ recovered
+ .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+ .await
+ .unwrap();
+ for (name, partition_id, original) in original_routes {
+ let topic = Identifier::named(name).unwrap();
+ let route = partition_route(&recovered, &stream, &topic, partition_id).await;
+ assert_eq!(route.incarnation, original.incarnation);
+ assert_eq!(route.partitions_count, original.partitions_count);
+ assert!(route.view >= original.view, "{name}/{partition_id}");
+ let payload = format!("{name}-{partition_id}");
+ assert_partition_payload(&recovered, &stream, &topic, partition_id, &payload).await;
+ }
+}
+
+async fn assert_initial_spread(
+ client: &TcpClient,
+ stream: &Identifier,
+ topics: &[(&str, u32)],
+ replicas: usize,
+) {
+ let mut primary_counts = vec![0_u32; replicas];
+ for &(name, count) in topics {
+ let topic = Identifier::named(name).unwrap();
+ for partition_id in 0..count {
+ let route = partition_route(client, stream, &topic, partition_id).await;
+ assert_eq!(route.view, u32::from(route.primary_replica));
+ primary_counts[usize::from(route.primary_replica)] += 1;
+ }
+ }
+ let total: u32 = topics.iter().map(|(_, count)| count).sum();
+ assert_eq!(
+ primary_counts,
+ vec![total / u32::try_from(replicas).unwrap(); replicas],
+ "create batches and growth must count already admitted assignments"
+ );
+}
+
+async fn assert_partition_payload(
+ client: &TcpClient,
+ stream: &Identifier,
+ topic: &Identifier,
+ partition_id: u32,
+ expected: &str,
+) {
+ let polled = client
+ .poll_messages(
+ stream,
+ topic,
+ Some(partition_id),
+ &Consumer::default(),
+ &PollingStrategy::offset(0),
+ 1,
+ false,
+ )
+ .await
+ .unwrap();
+ assert_eq!(polled.messages.len(), 1, "{topic}/{partition_id}");
+ assert_eq!(polled.messages[0].payload.as_ref(), expected.as_bytes());
+}
+
+#[iggy_harness(cluster_nodes = 5, server(sharding.cpu_allocation = "2"))]
+async fn given_balanced_primaries_when_a_replica_fails_and_returns_should_rebalance_without_data_loss(
+ harness: &mut TestHarness,
+) {
+ const TOPIC_COUNT: usize = 100;
+ const PHASES: [&str; 3] = ["before-failure", "while-absent", "after-return"];
+
+ let client = connect_without_login(harness.node(1).tcp_addr().unwrap()).await;
+ client
+ .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+ .await
+ .unwrap();
+ let identity = client.session_identity().await.unwrap();
+ client.create_stream(STREAM_NAME).await.unwrap();
+ let stream = Identifier::named(STREAM_NAME).unwrap();
+ let options = TopicCreateOptions {
+ partitions_count: Some(1),
+ durability: Durability::Persisted,
+ message_expiry: Some(IggyExpiry::NeverExpire),
+ ..TopicCreateOptions::default()
+ };
+ let mut topics = Vec::with_capacity(TOPIC_COUNT);
+ for index in 0..TOPIC_COUNT {
+ let name = format!("rebalance-{index}");
+ client.create_topic(&stream, &name, &options).await.unwrap();
+ topics.push(Identifier::named(&name).unwrap());
+ }
+ let mut request = RequestHeader {
+ client: identity.client_id,
+ session: identity.session,
+ request: RAW_REQUEST_START,
+ ..Default::default()
+ };
+ let initial =
+ wait_for_balanced_routes(harness, &client, &stream, &topics, &[20; 5], &mut request).await;
+ append_and_assert_partition_sentinels(&client, &stream, &topics, &PHASES[..1]).await;
+
+ harness.kill_node(0).unwrap();
+ let surviving = wait_for_balanced_routes(
+ harness,
+ &client,
+ &stream,
+ &topics,
+ &[0, 25, 25, 25, 25],
+ &mut request,
+ )
+ .await;
+ append_and_assert_partition_sentinels(&client, &stream, &topics, &PHASES[..2]).await;
+
+ harness.restart_node(0).unwrap();
+ let returned =
+ wait_for_balanced_routes(harness, &client, &stream, &topics, &[20; 5], &mut request).await;
+ for (index, original) in initial.iter().enumerate() {
+ assert_ne!(original.incarnation, 0);
+ for current in [&surviving[index], &returned[index]] {
+ assert_eq!(
+ current.incarnation, original.incarnation,
+ "{}",
+ topics[index]
+ );
+ assert_eq!(current.partitions_count, 1, "{}", topics[index]);
+ assert!(current.view >= original.view, "{}", topics[index]);
+ }
+ assert!(
+ returned[index].view >= surviving[index].view,
+ "{}",
+ topics[index]
+ );
+ }
+ append_and_assert_partition_sentinels(&client, &stream, &topics, &PHASES).await;
+ let current_identity = client.session_identity().await.unwrap();
+ assert_eq!(current_identity.client_id, identity.client_id);
+ assert_eq!(current_identity.session, identity.session);
+}
+
+async fn wait_for_balanced_routes(
+ harness: &TestHarness,
+ client: &TcpClient,
+ stream: &Identifier,
+ topics: &[Identifier],
+ expected_counts: &[usize],
+ request: &mut RequestHeader,
+) -> Vec<PartitionRouteResponse> {
+ let mut last_counts = vec![vec![0; expected_counts.len()]; expected_counts.len()];
+ timeout(REBALANCE_BUDGET, async {
+ let binding = BindSessionRequest {
+ version_info: ClientVersionInfo {
+ protocol_version: IGGY_PROTOCOL_VERSION,
+ sdk_name: WireName::new("routing-test").unwrap(),
+ sdk_version: WireName::new("1.0.0").unwrap(),
+ },
+ identity: client.session_identity().await.unwrap(),
+ bind_secret: client.session_bind_secret().await.unwrap(),
+ }
+ .to_bytes();
+ let route_requests: Vec<_> = topics
+ .iter()
+ .map(|topic| {
+ GetPartitionRouteRequest {
+ stream_id: identifier_to_wire(stream).unwrap(),
+ topic_id: identifier_to_wire(topic).unwrap(),
+ partition_id: PARTITION_ID,
+ }
+ .to_bytes()
+ })
+ .collect();
+ let mut observers = Vec::with_capacity(expected_counts.len());
+ for (node, count) in expected_counts.iter().enumerate() {
+ if *count == 0 {
+ continue;
+ }
+ let address = harness.node(node).tcp_addr().unwrap();
+ let mut socket = raw_tcp::connect_to(address).await;
+ loop {
+ match data_request(&mut socket, request, BIND_SESSION_CODE, &binding, None).await {
+ Ok(_) => break,
+ Err(IggyError::TransientNotAccepted) => sleep(PRECONDITION_POLL).await,
+ Err(error) => panic!("node {node} refused the existing session: {error:?}"),
+ }
+ }
+ assert_eq!(socket.peer_addr().unwrap(), address);
+ observers.push((node, socket));
+ }
+ loop {
+ let mut snapshots = Vec::with_capacity(observers.len());
+ for (node, socket) in &mut observers {
+ let mut routes = Vec::with_capacity(topics.len());
+ for body in &route_requests {
+ match data_request(socket, request, GET_PARTITION_ROUTE_CODE, body, None).await {
+ Ok(body) => routes.push(PartitionRouteResponse::decode_from(&body).unwrap()),
+ Err(IggyError::TransientNotAccepted) => break,
+ Err(error) => panic!("node {node} refused a partition route: {error:?}"),
+ }
+ }
+ if routes.len() != topics.len() {
+ break;
+ }
+ let mut counts = vec![0; expected_counts.len()];
+ for route in &routes {
+ counts[usize::from(route.primary_replica)] += 1;
+ }
+ last_counts[*node].clone_from(&counts);
+ if counts != expected_counts {
+ break;
+ }
+ snapshots.push(routes);
+ }
+ if snapshots.len() == observers.len()
+ && snapshots.windows(2).all(|pair| {
+ pair[0].iter().zip(&pair[1]).all(|(left, right)| {
+ (left.incarnation, left.partitions_count, left.view, left.primary_replica)
+ == (right.incarnation, right.partitions_count, right.view, right.primary_replica)
+ })
+ })
+ {
+ return snapshots.remove(0);
+ }
+ sleep(PRECONDITION_POLL).await;
+ }
+ })
+ .await
+ .unwrap_or_else(|_| {
+ panic!(
+ "partition primaries did not converge to {expected_counts:?} within {REBALANCE_BUDGET:?}; \
+ physical-node observations: {last_counts:?}"
+ )
+ })
+}
+
+async fn append_and_assert_partition_sentinels(
+ client: &TcpClient,
+ stream: &Identifier,
+ topics: &[Identifier],
+ phases: &[&str],
+) {
+ timeout(REBALANCE_BUDGET, async {
+ for topic in topics {
+ let payload = format!("{topic}:{}", phases.last().unwrap());
+ client
+ .send_messages(
+ stream,
+ topic,
+ &Partitioning::partition_id(PARTITION_ID),
+ &mut [message(&payload)],
+ )
+ .await
+ .unwrap();
+ let polled = client
+ .poll_messages(
+ stream,
+ topic,
+ Some(PARTITION_ID),
+ &Consumer::default(),
+ &PollingStrategy::offset(0),
+ u32::try_from(phases.len() + 1).unwrap(),
+ false,
+ )
+ .await
+ .unwrap();
+ assert_eq!(polled.messages.len(), phases.len(), "{topic}");
+ for (message, phase) in polled.messages.iter().zip(phases) {
+ assert_eq!(
+ message.payload.as_ref(),
+ format!("{topic}:{phase}").as_bytes()
+ );
+ }
+ }
+ })
+ .await
+ .expect("every partition must preserve and serve its history across rebalancing");
+}
+
#[iggy_harness(cluster_nodes = 3)]
-async fn given_metadata_view_moved_when_producing_to_a_fresh_topic_should_reach_the_advertised_leader(
+async fn given_metadata_view_moved_when_creating_topic_should_route_to_its_assigned_primary(
harness: &mut TestHarness,
) {
// Kill node 0: it is the view-0 primary of BOTH planes, so the metadata
@@ -159,9 +606,8 @@ async fn given_metadata_view_moved_when_producing_to_a_fresh_topic_should_reach_
}
};
- // A brand-new topic. Its partition group is seeded from the metadata view,
- // so its primary is the advertised leader; left at view 0 it would be
- // replica 0, the node that was just killed and restarted.
+ // With no existing partitions, the placement tie goes to replica 0,
+ // independently of the metadata primary elected above.
let setup = harness
.root_client_for_node(leader)
.await
@@ -186,6 +632,14 @@ async fn given_metadata_view_moved_when_producing_to_a_fresh_topic_should_reach_
.expect("create topic");
let topic_id = Identifier::named(TOPIC_NAME).expect("topic identifier");
let partitioning = Partitioning::partition_id(PARTITION_ID);
+ let route_client = connect_without_login(harness.node(leader).tcp_addr().unwrap()).await;
+ route_client
+ .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+ .await
+ .unwrap();
+ let route = partition_route(&route_client, &stream_id, &topic_id, PARTITION_ID).await;
+ assert_eq!(route.primary_replica, 0);
+ assert_eq!(route.view, INITIAL_VIEW);
// Where a client asking for node 0 actually ends up.
let leader_address = harness
... 80818 lines suppressed ...