Skip to content

keyvalue: prefix deletion watermark - #759

Open
cheggaaa wants to merge 4 commits into
mainfrom
cheggaaa/syn-145-kv-prefix-delete
Open

keyvalue: prefix deletion watermark#759
cheggaaa wants to merge 4 commits into
mainfrom
cheggaaa/syn-145-kv-prefix-delete

Conversation

@cheggaaa

@cheggaaa cheggaaa commented Aug 11, 2026

Copy link
Copy Markdown
Member

Adds physical deletion to the keyvalue store. Needed by SYN-104 (prune read/ frontiers of removed spaces): today rows are (key, peerId) LWW synced by ldiff, so nothing can ever be removed — a local delete resurrects on the next diff round, and dead-space rows accumulate on every device and node forever.

Design

A deletion is a normal synced row whose signed StoreKeyInner carries a typed StoreDeletePrefix operation (inner.delete) — the watermark. It must be an ordinary row: the stream frame type is frozen (changing it breaks normal sync with old peers), the marker has to participate in ldiff anti-entropy to reach every replica including restored devices, and the operation discriminator must live under the signatures so it cannot be forged. Applying it:

  • physically drops every stored row whose key starts with the prefix and is strictly older than the watermark — document and ldiff element;
  • keeps the watermark row as the only permanent state (one row per deleted prefix), which also guards against resurrection: rows pushed later (e.g. by a device restoring an old snapshot) are rejected while older than the watermark, and the restorer drops its own copies once the watermark syncs to it;
  • wins/loses per ordinary LWW, so a genuinely newer row from a live writer still applies.

Surface: Storage.DeletePrefix(ctx, prefix). Owner-only on both the local API and the receive path (a watermark removes other peers' rows). GetAll/Iterate hide watermark rows. Inner Set returns a SetResult (applied values + dropped ids): SetRaw broadcasts and indexes only applied rows, and the optional DeletionAwareIndexer.RemoveIndex mirrors drops into the app index. Local writes under a newer covering watermark fail with ErrCoveredByWatermark instead of silently succeeding while every replica discards the row.

Validation change bundled: KeyPeerId is now derived from the signed payload (key + "-" + inner.peer). Previously it was unchecked (the TODO in element.go), letting any writer occupy or overwrite another peer's row with a forged id.

Rollout

Old peers store a watermark as an inert empty-valued row and keep their data. Plan: ship the support to the whole fleet first; enable actual DeletePrefix calls only after adoption, so no watermark exists while old versions run.

Tests

  • innerstorage: drop semantics (older dropped; newer, out-of-prefix, and key-boundary rows survive), late-arrival rejection, watermark index rebuild on reopen, batch order independence, failed-tx diff restore including same-batch write+drop, SetResult contract
  • service: two-peer sync propagation + physical drop on both replicas, replay of captured pre-delete rows is a no-op, non-owner watermark rejected, covered-write errors, indexer flow (index applied, un-index dropped, never index rejected)

SYN-145

🤖 Generated with Claude Code

A synced signed row (StoreKeyInner.deletePrefix) whose apply physically
drops every row under the prefix older than it — document and ldiff
element — and rejects such rows arriving later, so restores from old
snapshots cannot resurrect them. The watermark row is the only retained
state. Owner-only, on both the local API (Storage.DeletePrefix) and the
receive path. Validation now also derives KeyPeerId from the signed
payload, closing an impersonation gap: an unchecked id let any writer
occupy another peer's row.

SYN-145
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

New Coverage 60.1% of statements
Patch Coverage 45.7% of changed statements (84/184)

Coverage provided by https://github.com/seriousben/go-patch-cover-action

- applyWatermark filters on the row's key, not its id: an id can match a
  prefix past its key's end (key+"-"+peerId), which deleted rows of a
  shorter key and diverged replicas from the covered() rejection rule
- failed-tx diff undo restores a per-id pre-call snapshot instead of
  replaying prior/added/removed lists, which advertised phantom heads
  when a row was written and watermark-dropped in the same batch
- inner Set returns SetResult (applied values + dropped ids): SetRaw now
  broadcasts and indexes only applied rows — indexing store-rejected rows
  resurrected deleted data downstream
- optional DeletionAwareIndexer.RemoveIndex mirrors watermark drops into
  the app index
- local Set/DeletePrefix under a newer covering watermark fail with
  ErrCoveredByWatermark instead of silently succeeding while every
  replica discards the row
- DeletePrefix("") rejected
- syncWithPeer tolerates a row deleted between CompareDiff and its load
- IteratePrefix and applyWatermark share one id-prefix iterator; the
  break condition is HasPrefix (Contains could over-include mid-id
  matches past the range)

SYN-145
The deletion operation is its own message inside the signed StoreKeyInner
(inner.delete != nil) instead of a bare string field: the schema states
what the field does, the operation type cannot be forged (an unsigned
outer discriminator could be), and future deletion options have a typed
home. Wire mechanics unchanged — the watermark stays an ordinary synced
row, which anti-entropy and p2p sync require.

SYN-145
Stale unsynced rows drop on both writer and hub once the watermark
arrives; rows written after the deletion win by LWW and propagate until
a re-issued delete removes them everywhere; unsynced rows outside the
prefix sync normally.

SYN-145
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant