Skip to content

Chunk compaction: fold the holes card deletes leave - #89

Merged
jvsena42 merged 6 commits into
mainfrom
feat/chunk-compaction
Aug 20, 2026
Merged

Chunk compaction: fold the holes card deletes leave#89
jvsena42 merged 6 commits into
mainfrom
feat/chunk-compaction

Conversation

@jvsena42

@jvsena42 jvsena42 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Closes #51.

The problem

Cards are assigned to chunks by sequential append, and deleteCard shrinks that chunk's count rather than resequencing every later card — closing the hole in place would rewrite every following chunk, which is the write amplification the layout exists to remove.

Holes never cost correctness. They cost density: a deck that imported 20k and deleted 15k opens with the request count of a 20k-card deck.

What this does

DeckRepository.compactDeck folds one pair of neighbouring chunks at a time until no two fit in a record — which is the record count the cards actually need. Ordering survives because the pair is renumbered inside the landing chunk's own slice of the ord line, so no chunk outside the pair moves.

The issue floated three options; this is #1 and #3 together, and #1 turned out to need a fix of its own (see below).

Three things it deliberately does not do:

  • It never runs inline. deleteCard and listOwned only ask for a pass, through a new BackgroundTasks.scheduleDeckCompaction (WorkManager / BGTaskScheduler, same shape as the media re-host sweep). Compacting on the delete path would cost a merge per card in a bulk delete and rewrite records followers have cached, mid-edit.
  • It does not bump updated_at or emit changes — nothing user-visible moved, so lighting up every follower's "the author published" badge would be a lie. The per-chunk stamps do move, which is what makes a follower re-fetch the folded pair.
  • It does not go through writeChunksAndManifestLocked. A merge removes a record, so it writes the landing record, then the manifest, then empties the source; emptying first would leave a window where the manifest points at a chunk that 404s.

It converges — every merge strictly shrinks the table — and it is budgeted (DEFAULT_COMPACTION_MERGE_BUDGET) and resumable with no cursor, since each pass re-derives the next merge from the manifest.

The consequence worth knowing about

A merge drops the pair's higher n, so the chunk table is no longer contiguous. Nothing downstream assumed it was — except publish, which cleared stale records over batches.size until previous.chunks.size, a count. On a compacted deck that clears a record that no longer exists and orphans the one that does. It now deletes by chunk number.

Also here

  • fix(import): give the paste field a fixed height — found while testing. The field was heightIn(min = 160.dp), so it grew with whatever was pasted; a 102-line list pushed Next off the bottom of the screen and under the keyboard, making the paste flow unusable for exactly the deck sizes it exists for.
  • docs(journeys): the scripted sign-in failure is pacing, not repetition — driving sign-in here produced a clean discriminator for something RESULTS.md had recorded as unexplained. ~15s from beginSignIn to Authorize (a layout dump between each tap) fails with "the relay isn't responding"; the same three taps chained at ~3s pass first try. That accounts for the "manual sign-ins succeed where scripted ones don't" note — a human taps through in a couple of seconds, a journey runner that verifies each step does not. Journey 01 now spells out the chain and moves every verification after it.
  • The two iOS BGTask handlers were the same twenty lines twice and now share one, which also fixes a swallowed cancellation: the expiration handler cancels the job, and a plain runCatching went on to reschedule and report success — both of which the handler had already done.

Verification

./gradlew :shared:allTests detektAll :composeApp:assembleDebug green; iOS compileKotlinIosSimulatorArm64 green.

23 new tests — the chunk-table maths, and the repository pass over a FakePubkyClient: keeps every card and its study order, stops at the record count the cards need, writes the landing record before dropping the source, leaves the table untouched when a chunk is unreadable, repairs an entry counting cards its record no longer has, and the gapped-numbering case a republish used to orphan.

Driven on the Pixel_9 emulator against a real homeserver, which is the part that matters:

step result
publish 102 cards 2 chunks, [100, 2] — no compaction asked for
delete 1 card [99, 2] = 101, still won't fit — nothing scheduled
delete 1 more [98, 2] = 100 → BgTasks: scheduleDeckCompaction: enqueued (unique, KEEP)
worker runs DeckCompact: compact: ggtiq9gttbk8 merges=1 cards=2 2→1 chunks
reopen the deck 100 cards, in order from word3 (the two deleted were word1/word2)
list decks again no further compaction signal — converged

🤖 Generated with Claude Code

jvsena42 and others added 6 commits August 20, 2026 08:21
Card deletes shrink a chunk's `count` and leave the gap — closing it in
place would resequence every later card and rewrite every following
record, which is the write amplification chunking exists to remove. The
cost is density: a deck that imported 20k and deleted 15k opens with the
request count of a 20k-card deck.

Adds the pure maths a compaction pass needs. `mergeTarget` picks the
first two neighbours *in the sorted table* whose cards fit in one
record, so a merge that has already left a gap in the numbering does not
stop the next one. Merging up to a full CHUNK_SIZE is deliberate: a
chunk left just short can only pair with an almost-empty neighbour, so a
delete/add cycle does not thrash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`deleteCard` shrinks its chunk's `count` and stops there, so a deck that
churned heavily ends up spread over far more records than its card count
warrants — a 5k-card deck can open like a 20k-card one. `compactDeck`
folds one pair of neighbouring chunks at a time until no two fit in a
record, which is the count the cards actually need.

Three things it deliberately does not do:

- It never runs inline. `deleteCard` and `listOwned` only ask for a pass
  through `BackgroundTasks.scheduleDeckCompaction`; compacting on the
  delete path would cost a merge per card in a bulk delete and rewrite
  records followers have cached, mid-edit.
- It does not bump `updated_at` or emit `changes` — nothing
  user-visible moved. The per-chunk stamps do move, which is what makes
  a follower re-fetch the folded pair.
- It does not go through `writeChunksAndManifestLocked`. A merge removes
  a record, so it writes the landing record, then the manifest, then
  empties the source; emptying first would leave a window where the
  manifest points at a chunk that 404s.

A merge drops the pair's higher `n`, so the chunk table is no longer
contiguous. Nothing downstream assumed it was — but `publish` cleared
stale records over `batches.size until previous.chunks.size`, a count,
which on a compacted deck would clear a record that no longer exists and
orphan the one that does. It now deletes by chunk number.

Refs #51.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The interesting cases are the ones where compaction could quietly lose
something: a pass must keep every card and its study order, must stop at
the record count the cards actually need rather than looping, must write
the landing record before it drops the source, and must leave the table
untouched when a chunk cannot be read.

The gapped-numbering case gets its own fixture — counts [10, 90, 10],
where the first pair fills a record exactly and the tail cannot follow,
so chunk 1 is dropped and chunk 2 survives. That is the shape a
republish used to orphan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t not assume

§8.4 listed compaction as a known gap. It now describes the pass itself
— the merge criterion, why it converges, the write ordering, and why it
never runs on the delete path — plus the consequence that outlives it:
the chunk table is no longer contiguous, so anything walking it must
read `chunks[].n` rather than `0 until chunks.size`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was `heightIn(min = 160.dp)`, so it grew with whatever was pasted. A
102-line list pushed the Next button off the bottom of the screen, and
once the keyboard came up there was no way to reach it at all — the
paste flow was unusable for exactly the deck sizes it exists for.

Fixed height, and the field scrolls inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Journey 01's happy path cannot be driven by a runner that verifies
between actions. Two runs on the same network today: ~15s from
`beginSignIn` to Authorize (a layout dump between each tap) failed with
"the authorisation relay isn't responding"; the same three taps chained
at ~3s passed first try.

That accounts for the manual/scripted split the 2026-08-17 pass recorded
as unexplained and blamed on scripted repetition — a human taps through
in a couple of seconds. 01 now spells out the chain and the coordinates
and moves every verification after it; RESULTS.md marks the old
hypothesis superseded and keeps what is still only a discriminator
separate from a mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42
jvsena42 merged commit 8e0fef7 into main Aug 20, 2026
2 checks passed
@jvsena42
jvsena42 deleted the feat/chunk-compaction branch August 20, 2026 12:07
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.

Chunk compaction: card deletes leave permanent holes

1 participant