Chunk compaction: fold the holes card deletes leave - #89
Merged
Conversation
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>
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.
Closes #51.
The problem
Cards are assigned to chunks by sequential append, and
deleteCardshrinks that chunk'scountrather 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.compactDeckfolds 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:
deleteCardandlistOwnedonly ask for a pass, through a newBackgroundTasks.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.updated_ator emitchanges— 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.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 — exceptpublish, which cleared stale records overbatches.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 washeightIn(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 frombeginSignInto 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.BGTaskhandlers were the same twenty lines twice and now share one, which also fixes a swallowed cancellation: the expiration handler cancels the job, and a plainrunCatchingwent on to reschedule and report success — both of which the handler had already done.Verification
./gradlew :shared:allTests detektAll :composeApp:assembleDebuggreen; iOScompileKotlinIosSimulatorArm64green.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:
[100, 2]— no compaction asked for[99, 2]= 101, still won't fit — nothing scheduled[98, 2]= 100 →BgTasks: scheduleDeckCompaction: enqueued (unique, KEEP)DeckCompact: compact: ggtiq9gttbk8 merges=1 cards=2 2→1 chunksword3(the two deleted wereword1/word2)🤖 Generated with Claude Code