Repository navigation
Add shard split and merge as a derived lineage - #18
Merged
Merged
Conversation
`TOPO-213` fixes the order of an ownership delta in two groups: first the entries for shards the second snapshot enumerates, then the entries for shards only the first snapshot enumerates. The Java port computed the first group alone. `OwnershipDelta.between` iterated `after.placement().shards()`, so a shard that vanished between the two snapshots produced no entry, and the Javadoc stated that omission as though it were intended. `PLACE-075` bounds the cost of a delta over "the shards the two snapshots enumerate between them", and the Python reference has always computed that union. The two implementations therefore disagreed wherever the two snapshots enumerated different shard sets, and nothing detected it: every pair in `vectors/topology/ownership-delta.json` was `slot` at one `slotCount`, where the two shard sets are always equal and the second group is always empty. The one pair that differs, at `slotCount` 6 against 12, is the `TOPO-231` incomparability refusal and computes no delta at all. The second clause of `TOPO-213` had no executable test. A `ring` pair now carries one. Adding the token `0000000000002000` divides the extent the token `0000000000003000` bounded, so the later snapshot enumerates a shard the earlier one does not; removing it folds that extent back, so the earlier snapshot enumerates one the later one does not. The removal case fails against the unrepaired port and passes against the repaired one, and the addition case passes against both, which is exactly where the two implementations diverged. This is a repair to a port rather than a change to the design. No requirement is restated, no identifier is withdrawn, and no document changes. The suite revision changes because the suite gained two cases, so `conformance/declarations/java.json` re-declares against it and `ports/java/conformance/report.txt` is regenerated from the run the declaration names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An ownership delta joins two snapshots on the shard identifier. Where an epoch changes which shards exist, that join has no answer for the shards that appeared or vanished, and their contents still have to come from somewhere. Nothing in the design answered where. The cost was reachable and silent. Two ring snapshots at factor 2 gain a node carrying one token, which divides the extent its neighbour bounded. The new shard has no entry in the earlier snapshot, so the plan named a source equal to the destination and told two nodes to copy a shard from themselves, naming neither node that held the data. Removing the token was quieter still: the shard that absorbs the folded extent keeps its replica set, so it appeared in no delta entry and received no handoff at all. This is what a node joining or leaving a ring topology did, which is the central operation of the storage walkthrough. A lineage is the second join, over the keys a shard holds rather than over its name. It is derived from the topology document, so `formatVersion` stays at 1.0, the schema is untouched, every document valid before is valid now and digests the same, and no new strategy kind is added. `range` stays withdrawn and the `SPLIT` prefix admits no further identifier. The new requirements take the `LIN` prefix on the `migration` surface, tested at the `migration` level. No conformance surface and no level is added, and no `TOPO-*`, `FENCE-*`, or `PLACE-*` requirement moves. Lineage is deliberately kept out of the ownership delta: `TOPO-211` is tested at `core`, which a port cannot decline, and enriching it would oblige every future port to implement ring interval containment before declaring anything at all. Under `ring` an extent is the token interval of `RING-020` and containment is unsigned interval arithmetic. That code sits in `core.internal.placement` rather than beside the coordinator because `verifyUnsignedComparisons` covers that package and nothing else, and a ring extent wraps past zero, which is where a signed comparison hides. The gate caught one while this was being written. Under `slot` `TOPO-231` holds `slotCount` equal, so every extent equals its counterpart and the lineage is the identity. Under `directory` a pair whose entry sets differ is refused with a named cause until directory extents are defined, which is a correct answer in place of the silent mis-plan it replaces. Under `rendezvous` there is no extent, and `MOVE-251` already refused. The property the change rests on is that an equal shard set is an equal extent set, because the shard identifier renders the geometry under every kind. Every pair the suite carried before this is such a pair, so the regenerated tree is additions only. Records: 0086 derives the lineage from extents, 0087 settles that a strategy's lineage is derived rather than declared while the integrator's storage is declared, 0088 places it in the `migration` surface, and 0091 states that the library refuses an unaligned change and reports the rest without enforcing a preference. 0022 and 0054 are amended rather than superseded, in the form 0053 gives: 0054 was right to withdraw `range`, and one sentence of its consequences was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A division that moves no data still has work to do. A node that holds the parent under the earlier snapshot and the child under the later one has to divide its own copy so that what it holds matches the extent it now owns, and the plan the lineage produced named no such step. The keys were in the right place and the node's idea of which shard they belonged to was not. `MOVE-001` gains `dividing` and `MOVE-021` gains four transitions, which is the largest single edit in this work because that requirement says "exactly these transitions and no others". It lands after the lineage itself is settled and vector-backed rather than beside it. A local step names one node as both its source and its destination, which is the one place `LIN-042` does not apply, and runs `planned` to `dividing` to `complete`. There is no second party to prepare, quiesce, or cut over to, so the long sequence would be meaningless rather than merely wasteful. `LIN-057` orders it: a division before every handoff that draws from the divided parent, a fold after every handoff that draws into the folded shard, because a destination copying from a parent being divided underneath it would copy an extent that is changing. The step is reversible, and that is the point of 0089. `0022` had a local split that could not be undone, which is where `SPLIT-171` came from, and it was the single carve-out in `MOVE-233` that `0051` argued against and `0054` was glad to remove. Requiring the inverse hook keeps `MOVE-233` unconditional: a division is undone by `combine` and a fold by `divide`, so `dividing` reaches `aborting` like every other state that does and `MOVE-421` names it beside the other three. The cost lands on the integrator where it can be seen. Whether a storage can divide a copy in place is a property of that storage, so `HookDeclaration` gains `supportsLineage` and `LIN-053` refuses a plan that needs a local step without it, in preference to calling a hook that was never implemented. That is the shape 0085 established for `supportsRollback`. `HandoffContext` gains `sourceShardId`. It is the member doing the real work in an ordinary handoff too: a transfer hook for a divided child is told to move a shard the source does not hold, and without the parent in the context it cannot find the bytes. `MOVE-011` gains the failure kind `undivided`, for a copy that matches neither the parent's extent nor the child's. The four kinds it already carried each name a condition of a copy that moved between nodes, and this one never left the node it is on. 0090 records what is deliberately not done: a handoff whose shard a newer snapshot no longer enumerates is aborted rather than rewritten onto the children. That is what `MOVE-096` already says and what the port already does, and a transitive rebase would have to decide which child inherits a partial copy and whether a quiesce lease taken against the parent still binds. Relaxing a refusal later is cheap; withdrawing a clever rebase that proved wrong is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LIN-013` refused a `directory` pair whose entry sets differ, which was a correct answer standing in for the right one while the ring geometry settled. It is restated here to define the extent, and the refusal goes with it. A directory extent is a matcher narrowed by the precedence of `PLACE-065`: the keys its entry wins once every entry of the same table that outranks it has taken what it claims. An exact matcher outranks every prefix and a longer prefix outranks a shorter one, so every entry that outranks another and overlaps it is contained in it, and an extent is one matcher minus a finite set of matchers inside it. `LIN-016` states how that is decided without evaluating the table over generated keys, which `LIN-001` forbids for the same reason `TOPO-221` forbids it of the ownership delta. Every decoded matcher value of either table is a node of one trie, and each node contributes two regions: the key equal to it, and the keys strictly extending it that no deeper node is a prefix of. Two keys of one region match exactly the same entries of either table, so a region is the finest distinction either table can draw and the regions together admit every key. An extent is the set of regions its entry wins, and equality, containment, and disjointness are set operations over those. An exact matcher wins only the region that is its own key. A region of keys strictly extending a node contains no node of the trie, and every matcher value is one, so no exact matcher can lie inside it. Refining `prefix:ab` into `ab0` and `ab1` while keeping `ab` divides one extent into three, because `ab` still wins the keys neither longer prefix claims. An entry that wins keys the earlier table matched to no shard under `DIR-010` names a shard with no parent, which is the `fresh` class, and `LIN-043` emits no handoff for it. That case exists only under `directory`, because it is the only kind whose `shardOf` answers with no shard, and it is why the requirement had no executable test until now. Every port declaring `migration` and exposing `directory` implements this, which is the scoping the strategy surface axis already gives and 0087 argues for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user guide was reviewed against the code and three claims did not survive it. Two were mine and one predates this work. `LIN-031` requires the lineage to be an operation the integrator calls, and the port had none. `ShardExtents` sits in `core.internal.placement`, which `module-info.java` does not export, and `HandoffCoordinator` exposed `plan` alone. The conformance harness reached the classification by calling the internal class from inside the module, so the `migration` level passed over a requirement nothing implemented. `HandoffCoordinator` now carries `lineage(from, to)`, `ShardLineage` and `LineageClass` are exported beside it, and the harness goes through that surface, so a port that computes a lineage it cannot expose now fails the level rather than passing it. `30-conformance.md` no longer lists `LIN-031` among the requirements without an executable test, because it has one. The public `topology.OwnershipDelta` Javadoc still described the behaviour `TOPO-213` repaired. The internal copy was corrected with the repair and the exported one, which is the copy an integrator reads, was not. `lineage` refused a differing `topologyId` with the cause `topologyMismatch` while `plan` answers `incomparableShards` for the same input. Two operations disagreeing about one mistake, and the cause was the wrong one: its own documentation describes a plan named against a router that does not hold that topology, which is a different situation. Both now answer `incomparableShards`, and the interface documents all the refusals rather than three of four. The guide gains an orientation section before the first router, covering what a control plane holds and what the library does not do, how a published document reaches a running router and what installation replaces, and the everyday call from a key to the nodes that hold it. The last of those states where routing cost is paid rather than what it is: preparation is per snapshot and not per call, a call consumes the bounded prefix of `CORE-046` rather than the whole ordering, `rendezvous` is the exception because it scores the whole eligible set, and the whole-ordering calls belong off the routing path. It also gains the lineage operation beside the ownership delta, because the two are siblings that answer different questions: the delta joins on the shard identifier and answers who changed owner, the lineage joins on the keys a shard holds and answers where a new shard's contents are. The pair is the thing a reader needs and the guide did not carry it. One correction the review made to this author's own summary is worth recording: a shard that only the later snapshot enumerates is not absent from the ownership delta. It appears with an empty before set and every node in `gained`. What the delta cannot answer for it is where its contents are. The acceptance rule the guide stated was wrong and predates this work. It said the library accepts no document whose epoch is at or below the one in force. `TOPO-061` accepts an equal epoch with an equal digest as a no-op that refreshes freshness and refuses an equal epoch with a differing digest as a conflict rather than as stale, and `TopologyLoader` implements exactly that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`TOPO-071` requires an implementation to reject any document whose epoch is below `minEpoch`, including the first after a restart, and the first row of `TOPO-061` refuses a document whose `topologyId` differs from the one in force or configured. The Java port accepted both settings through `RouterConfig.Builder`, validated them, reported them in `router.configuration()` and then consulted neither. `TopologyLoader` had no field for either and was constructed with the strategy map alone, so a process configured with a floor installed a document below it and a process configured with an identifier adopted the first document it was handed whatever cluster it named. Both rows precede the row that installs a first document, which is the whole point of them: they are what a process has instead of a snapshot in force when it comes back up. An operator who sets `minEpoch` to stop a restart serving an older topology got no protection and no error, which is the worst shape a missing check can take. The Python reference has implemented both since it was written, in the row order the table gives, so this is a port defect rather than an ambiguity. Nothing detected it because nothing exercised it: `coverage.json` recorded `TOPO-071` as covered by `properties/properties.json` alone, through a property that names the identifier, and that property's witness is `topology-rollback`, which runs with neither setting configured and has no step below a floor. The identifier was claimed as covered while no artefact could reach it. `topology-acceptance-floor` reaches it now. It configures both, then refuses a document below the floor with nothing in force, refuses a foreign document that is also below the floor as a conflict rather than as stale, which is what fixes the row order rather than merely the rows, and installs the first document at the floor. It fails against the unenforced port and passes against this one. Two smaller repairs came with it. A scenario whose first document is refused has nothing in force, so both drivers now compare `epochInForce` only where the expectation states one; they previously assumed every rejection had a snapshot behind it. The user guide documents both settings, which it could not honestly do while they did nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`OBS-020` tables ten events under `migration.` and requires an implementation to emit the events of the surfaces it exposes. The Java port declared all ten in the inventory the `migration` vector reads, and emitted none of them. The cause was structural rather than ten missed call sites. A router is built from a `RouterConfig`, which carries the metrics registry and the event sink, so `DefaultRouter` holds a `MetricsHolder` and reports through it. A coordinator is built by `Sharder.coordinator()`, which takes nothing, so there was no sink to report through and nowhere for one to come from. 0092 settles where it comes from: `Sharder.coordinator(config)`, following `Sharder.loader(config)`, which already takes the whole configuration to answer a narrower question. The sinkless factory stays and means a coordinator that reports nothing. All ten now emit at the trigger `OBS-020` names, with the payload members it names. `state_changed` reports from the one chokepoint every transition passes through, and reports nothing for a trigger the machine dropped because the state moved under a hook, since no transition happened. `failed` follows it where the transition reached `failed`, carrying the kind of `MOVE-011`. `quiesce_expired` is raised only where the machine says the lease is spent, because an idle outcome in that position is otherwise a concurrent abort. Nothing in the suite checks any of this, and that is the part worth recording. No driver asserts that a running library emitted a named event, for any surface. `30-conformance.md` claimed otherwise: it said emission is asserted where the suite drives one, naming `sharder.health.ejection_refused` in the health scenarios. Those scenarios do carry an `ejectionsRefused` member, and neither driver reads it, so it is data rather than coverage. A port that declares an inventory it never emits from passes every level, which is exactly the state this port was in for the whole `migration` surface. The section now says that plainly, and a port's own tests are named as where emission is checked until a driver asserts it. `MigrationTest` carries that test for this port: it drives a handoff to completion through a coordinator with a sink and asserts the events, their severities, the common members of `OBS-021`, and the payload members of each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Split and merge return, as a lineage derived from each strategy's own keyspace geometry rather than
as a reinstated
rangestrategy.rangestays withdrawn and theSPLITprefix admits no furtheridentifier.
Why this is a repair as much as a feature
Asking for split and merge surfaced a live defect. Two
ringsnapshots atfactor2 gain a nodecarrying one token, which divides the extent its neighbour bounded. The plan the port built named a
source equal to the destination and told two nodes to copy a shard from themselves, naming neither
node that held the data. Removing the token was quieter: the shard that absorbs the folded extent
keeps its replica set, so it appeared in no delta entry and received no handoff at all. That is what
a node joining or leaving a
ringtopology did, which is the central operation of the storagewalkthrough.
TOPO-211answers which shards changed owner. Nothing answered which shards changed contents, andthe two questions differ exactly when an epoch changes which shards exist.
What the change is
A lineage relates extents rather than identifiers. It is derived from the topology document, so
formatVersionstays at1.0, the schema is untouched, every document valid before is valid nowand digests the same, and no new strategy kind is added. The new requirements take the
LINprefixon the
migrationsurface at themigrationlevel: no conformance surface and no level is added,and no
TOPO-*,FENCE-*, orPLACE-*requirement moves.Lineage is deliberately kept out of the ownership delta.
TOPO-211is tested atcore, which aport cannot decline, and enriching it would oblige every future port to implement ring interval
containment and directory precedence before declaring anything at all.
Where the two snapshots enumerate the same shards the lineage is the identity, so the regenerated
tree is additions only.
Commits
9248ff6TOPO-213in the port, which reported no entry for a shard only the earlier snapshot enumeratesdc531bf7747423000b4c2directoryextent over the prefix triee58320ef416575minEpochand the configuredtopologyId, which were accepted and ignoredbea4494migrationsurface, which were declared and never emittedDecision records
0086 derives the lineage from extents. 0087 settles that a strategy's lineage is derived while the
integrator's storage is declared. 0088 places it in the
migrationsurface. 0089 requires adivision to be undone by a combination, which keeps
MOVE-233unconditional and avoidsreinstating the carve-out
SPLIT-171carried. 0090 records that a handoff is aborted rather thanrebased across a lineage boundary. 0091 states that the library refuses an unaligned change and
reports the rest without enforcing a preference. 0092 settles how an observability sink reaches the
coordinator.
0022and0054are amended rather than superseded, in the form0053gives.0054was right towithdraw
range; one sentence of its consequences was not.Four defects found on the way
Each is the same shape: an artefact that looks like coverage, and nothing executing it.
TOPO-213: the port reported one group of delta entries where the specification and the Pythonreference give two. Every
ownership-deltapair wasslotat oneslotCount, where the twoshard sets are always equal, so the clause was unreachable.
LIN-031: the lineage had no exported surface. The harness reached it through an internal classinside the module, so the level passed over a requirement nothing implemented.
TOPO-071and the first row ofTOPO-061:minEpochandexpectedTopologyIdwere accepted,validated, reported by
router.configuration(), and never consulted.OBS-020: no driver asserts that a running library emitted any event, for any surface.30-conformance.mdclaimed otherwise. It now says plainly that emission is unasserted.Known gap
The cross-language emission-assertion mechanism is not built. Emission is checked by the port's own
tests, and the inventory is the contract the suite holds a port to. Closing it properly means
modelling event emission in the reference, a general expectation in both drivers, and a decision
record.
Verification
./build.shreports every port built. The generator'srun.shcompletes with no failures over 609vector cases, 26 scenarios, and 28 properties; the withdrawal register is clean at 62 identifiers;
conformance/declarations/java.jsonwas re-declared against each new suite revision only after theport passed it.
verifyDocLinksandverifyDocStyleare both clean.Two repairs were verified by reverting them and watching the new artefact fail: the
TOPO-213vector, and the acceptance floor scenario.
🤖 Generated with Claude Code