Spike 4: exactly-once claims through one durable voter¶
Task 6 of the bee v1 plan. The question was whether
kuilt-cluster fits as-is for claims: one host voter that never claims, and learners (browsers and
bridges) that claim through it. It does, from kuilt 0.7.0-dev.1692. Two clients racing for
one target get exactly one winner, and both see it. The voter is a sequencer, not consensus: it is
the whole voter set, so a claim commits once it appends it.
The first run, against 0.7.0-dev.1690, stopped at the fit check: a second learner cut the first
off the log (bee#11). Iain ruled to fix kuilt first. kuilt#3121 (the learner reconciler) and
kuilt#3122 (learner removal after a grace) fixed it, published as 0.7.0-dev.1692 (kuilt
7654ac4c2). The grace itself first fires on 0.7.0-dev.1693 (kuilt e6044eb42, kuilt#3130).
The in-memory results before 2d19414 ran partly in real time (bee#25). ClaimsRig passed
kuilt's identified(…) a bare TestCoroutineScheduler, which isn't a dispatcher, so the seams'
read and write loops ran on Dispatchers.Default inside virtual-time tests. That covers every
in-memory number recorded before 2d19414, including the 47-of-50 first run and the 46-of-50
control rerun on 1690, the 100 ms virtual-time latency, the 102- and 103-entry log counts, and the
red controls on the old client below. 2d19414 passes StandardTestDispatcher(testScheduler).
Those numbers weren't rerun, except the race: rerun on 2d19414's rig against
0.7.0-dev.1693, it still comes to 0 of 50, with each client winning 25 and the log committed
through 102.
Fit-check answers¶
Measured against kuilt 0.7.0-dev.1692.
| Question | Answer | Evidence |
|---|---|---|
| (a) A real WebSocket from wasm | Yes. clusterClient over KtorClientLoom on the browser's WebSocket claims Won through the fixture voter. |
WasmClaimTest.browserClaimsThroughTheVoter, with -Pbee.realsocket.tests=true |
(b) The server proposes its own Depart |
Yes. voterNodes[HOST_NODE].propose(…) commits inline on the single voter. |
ClaimsClientTest.aLearnerFoldsTheLogFromIndexOneIncludingTheVotersDepart, VoterRestartTest |
(c) Learners receive committed entries from index 1 or an Install |
From index 1, as entries. The voter publishes no snapshot, so raft has no Install to send. |
aLearnerFoldsTheLogFromIndexOneIncludingTheVotersDepart |
| A second learner stays on the log | Yes, from 0.7.0-dev.1692. Both learners stay in the config and fold the voter's later Depart. |
ClaimsRaceTest.aSecondLearnerLeavesTheFirstOnTheLog, ClaimsSmokeTest.aSecondLearnerLeavesTheFirstOnTheLogOverRealSockets |
The race¶
ClaimsRaceTest.twoClientsRaceAndExactlyOneWins runs 50 rounds. In each, two learners claim the
same target at once, each after a seeded delay of up to 200 ms, so either may reach the voter
first. Every round must come to exactly Won(t) for one client and Lost(winner) for the other,
with the mirror holding the winner under t. At the end, both learners' tables must equal the
voter's.
| kuilt | Rounds without exactly one Won |
|---|---|
0.7.0-dev.1690, first run |
47 of 50 |
0.7.0-dev.1690, control rerun on this branch |
46 of 50 |
0.7.0-dev.1692 |
0 of 50, with each client winning 25 |
The 1690 rows predate the seeded delay, when the first client won every round. On
0.7.0-dev.1690, every claim committed at the voter, but the first learner had been dropped from
the config. So it never saw its own wins and reported Unavailable. ServerCluster.admitLearner
built each config from the one the cluster was constructed with, so each admission kept only the
newest learner. kuilt#3121 replaced per-connection admission with one reconciler that builds from
the leader's current membership.
A claim is judged by its own id¶
Review of this PR found that ClaimsClient told its own claims apart by (target, by). A person's
tabs all claim as one human: identity (spec §5.1), so that pair doesn't name one proposal, and
three paths broke exactly-once:
- Another tab's win. Tab B timed out, tab A won, and B took A's committed claim for its own late
win and released A's token. A held a
Wonfor a free target. - An earlier win. A claim that timed out released any hold by its claimant, a win from an earlier claim included.
- A retry. It forgot the claim it retried. When that first claim committed and won, the target
stayed held under a token the caller never learned, and the retry reported
Lost.
Now each Claim carries a ProposalId: the proposer's Raft NodeId, a random incarnation drawn
once per client, and a per-client serial. kuilt keeps a NodeId unique across the cluster and bee
mints a fresh one per dial, so ids are unique by construction. The incarnation covers a reused
NodeId, since a client folds the log from index 1 and meets every claim an earlier client under
that id made. That holds only if no two clients under one NodeId draw the same incarnation, so
their Randoms must not be seeded from the NodeId. The client judges a claim only at the first
committed entry with its id, so a duplicate entry can't change the client's verdict. An entry whose
target or identity doesn't match the claim that id names is ignored too. A claim
reported Unavailable, or cancelled by its caller, is released only if its own entry won, with
that entry's token. The release retries, up to five times, until the table shows the target no
longer held under that token. ClaimsClient.unreleased names any the retries couldn't free.
kuilt's LogEntry.dedupKey couldn't serve as the id. ClusterClient.propose draws the serial
inside the call and returns it only on success, so a claim that timed out has no key its proposer
knows.
Test (ClaimsClientTest) |
On the old client | Now |
|---|---|---|
aTabThatTimedOutNeverReleasesAnotherTabsWin |
red: the target ends Free |
green |
aTimeoutNeverReleasesAnEarlierWin |
red: the target ends Free |
green |
aClaimThatWinsAfterItsRetryStartedIsReleased |
red: the retry got Lost(iain), the target is held under token (1, 4) |
green: Lost(iain), then Free |
aCancelledClaimThatWinsLateIsReleased |
red, with cancellation not abandoning: held under (1, 4) |
green |
The release's retry has no test that tells it from a single attempt. The in-memory rig can hold a send but not drop it, and a held release still lands once let go. Nor does any test pin the target-and-identity check in the client's judgement: with ids unique by construction, no rig makes two claims share one.
A known wart: a caller that retries a timed-out claim can be told Lost(iain), that it lost to
itself, when its first claim committed first. The client then releases that first win, so the
target ends Free. The caller has to claim again.
Duplicate entries aren't handled yet (bee#23). ClaimTable folds every entry, so a second
entry carrying the same Claim, committed after that claim's Release, wins again under a token
nobody holds. No duplicate arrives on 0.7.0-dev.1692 within one incarnation, but kuilt's
forwarding contract allows one: ClientIdentity.Auto leaves it to the consumer's
ClientSessionTable. Production needs that table folded on entry.dedupKey, and kept bounded by
dropping a session when it closes. No kuilt API exposes a session-close event yet: kuilt#3130,
which closed kuilt#3127, ends the learner's admission but adds no such hook. That gap is
tractat-us/kuilt#3129.
Who may depart¶
Only agents depart (spec §6.4). The table rejects a Depart of a human, and
ClaimsVoter.depart refuses one with an IllegalArgumentException.
v1 trusts the log on who proposed a Depart. The spec says the host commits it, but any learner
can propose one, and the table can't tell. LogEntry.dedupKey.clientId would be the only marker,
and it isn't reliable: the proposer stamps it, and a learner can pick it. clusterClient takes a
ClientIdentity, so a learner can pass ClientIdentity.Durable with any id string. It also picks
the NodeId that an Auto id is built from. So v1 assumes every learner is honest about
Depart, as it already does about whose name a Claim carries. Enforcing it needs an
authenticated proposer, which the host doesn't have yet.
Learner removal after a grace¶
ClaimsVoter builds its cluster with learnerGrace = 30.seconds. Every page load, bridge and
/clear dials under a fresh NodeId, so without removal the learner set grows with every
connection the host has seen. 30 s is the spec's heartbeat reconnect window (§6.4). It is well over
a reconnect: clusterClient re-joins at once on a tear. Removal costs a claim nothing: the table
holds claims by PeerIdentity, and a learner that comes back after the grace is admitted again and
catches up.
It works from kuilt 0.7.0-dev.1693. On 0.7.0-dev.1692 the grace never started
(tractat-us/kuilt#3127):
ServerCluster.admitLearner held each admitted room in awaitCancellation(), and only its
finally withdrew the learner's spoke from RaftRelayHub.learnersFlow, where the grace starts.
Nothing cancelled a room's onRoom coroutine when its connection closed, in KtorRoomHost or in
bee's in-memory host. kuilt#3130 ends the admission when the learner leaves the room's roster.
ClaimsRaceTest.aLearnerGoneForTheGraceLeavesTheConfig cancels one learner's client scope. That
runs the client's shutdown, which sends a Goodbye and then closes its in-memory link: a clean
leave, not a silent death. kuilt's own tests cover the silent case. No bee code changed for the
test to pass.
| kuilt | In memory (virtual time) | Real sockets (Netty, KtorRoomHost, CIO) |
|---|---|---|
0.7.0-dev.1692 |
Still in the config at 60 s; the test was @Ignored |
Still in the config 45 s later |
0.7.0-dev.1693 |
Removed at 30 s; the other learner claims Won after |
Removed after 30.08 s; the other learner claims Won after |
The 1692 in-memory row was rerun on 2d19414's rig, as a control on this pin bump. Both
real-socket rows came from a throwaway diagnostic not kept in the tree: one smoke learner's client
scope cancelled, the voter's membership watched for 60 s.
kuilt#3124 still keeps a removed peer's replication state on the leader.
The learner's id must equal the client's NodeId¶
The server takes a learner's NodeId from the roster, which KtorClientLoom fills from its
selfPeerId. The client names itself in clusterClient's clientNodeId and clusterConfig. If
the two differ, the voter replicates to an id the client doesn't answer to. bee has no production
clusterClient yet: Task 7 builds it. Today the three test sites (ClaimsRig.client,
ClaimsSmokeTest.client and WasmClaimTest) each build both from one NodeId. The membership
assertion in aSecondLearnerLeavesTheFirstOnTheLog pins that the voter admits the ids the clients
chose. Task 7 should build the loom and the client in one function that takes a single NodeId,
so the two can't differ.
Other kuilt findings¶
These were found on 0.7.0-dev.1690. They weren't rechecked on 0.7.0-dev.1692.
ServerCluster.close()leaves the relay running, and the relay then throws.start()runs in the caller's scope, not the cluster's. When that coroutine ends afterclose(),admitLearner'sfinallycallsoverlay.evict, and the overlay'sQuilteris already closed:IllegalStateException: Quilter(…#overlay-dir) is closed.ClaimsVoter.close()cancels and joins the relay before it closes the cluster.- A refused dial ends a
clusterClient's reconnect loop with an uncaught exception. With the voter stopped, the loop's nextSeamRoomFactory.jointhrowsConnectException: Connection refused, and nothing retries. This bears on Review Focus 4, where the host is down when someone acts. The smoke closes its clients before it stops the voter. - No in-memory multi-connection
RoomHost.LoomRoomHosthosts one room, andKtorRoomHost's constructor over aLoomis internal. The deterministic tests carryInMemoryClaimsHost(inClaimsRaceTest.kt), which gives every dial its own room, as/ws/claimsdoes, and can hold either direction of a dial throughHoldableConnection.
A bee build finding¶
A local run with -Pbee.realsocket.tests=true leaves bee-web/build/karma-config.d/fixture.js
behind, and :bee-web:karmaConfig stays UP-TO-DATE on the next run without the flag. So the next
check loads a dead voter's port, and WasmClaimTest fails with Unavailable instead of skipping.
CI starts clean, so it doesn't see this. Deleting the file works around it. The fix belongs in
bee-web/build.gradle.kts, which Task 6 doesn't own.
Measurements¶
These are first-claim latencies, so each includes the join and the learner's admission. They are single runs, not steady-state numbers.
| Where | 0.7.0-dev.1690 |
0.7.0-dev.1692 |
|---|---|---|
| Virtual time, in memory, one learner | 100 ms | 100 ms |
| Real loopback sockets, JVM (CIO to Netty) | 265 ms | 189 ms |
| Real loopback socket, wasm in headless Chrome | 174 ms | 217 ms |
The log after the race: 102 committed entries, with nothing compacted. That's 100 claims, the
election no-op and one membership change. The reconciler admitted both learners in that one change,
where 0.7.0-dev.1690 committed one per admission (103 entries).
What was built¶
bee-core:ClaimCommand(JSON on the wire, identities in theirtoString()form),ClaimTable(immutable, foldsEntry,InternalandInstall),ClaimsClient.bee-host:ClaimsVoteroverDurableStoreRaftStorage, and a realFixtureVoterMain.- Green:
ClaimTableTest(13),ClaimsClientTest(7, includinglateCommitOfTimedOutClaimIsReleased, which reds when the late release is disabled),ClaimsRaceTest(3, the grace test from0.7.0-dev.1693),VoterRestartTest(2),ClaimsSmokeTest(2) andWasmClaimTest.
Where the code departs from the plan's interfaces¶
ClaimsClient(cluster, node, clock, scope, random)takes a scope, because it collects the committed stream. It takes the cluster'sclientNodeIdand aRandomfor the incarnation, both for each claim'sProposalId.ClaimCommand.Claimcarries aProposalId, which the table ignores.ClaimOutcomeaddsRetired: the claimant was retired when its claim committed.releaseandcompletereturn whether they applied, andreassignreturns the new token or null.ClaimsVoter.starthas an overload that takes aDurableStore, which the tests use withInMemoryDurableStore. TheFileoverload wraps aFileChannelDurableStore.ClaimsClient.unreleasednames late wins it couldn't release.- A transport failure from
propose(LeadershipLostException,ForwardedProposalFailedException,NotLeaderException) reportsUnavailable. Anything else propagates.