Skip to content

[Feature] Reclaim the stale post-migration message blob on a per-key timer, with a manual clear #962

Closed
3 of 3 issues completed

Description

@diffwireauto

The migrated blob goes stale and is never reclaimed

After #920, a message-history key that migrates to rows keeps its blob forever. Verified: every blob write sits in the fallback branch behind an early return in the migrated branch (channel_message_store.dart:175,235, message_store.dart:111,152). So the blob is a frozen snapshot taken at the migration moment, not a mirror. Nothing updates it, and nothing ever deletes it.

Clearing history is not affected: clearChannelMessages removes both copies (deleteEverywhere plus deleteMessageRows), so there is no stale-history-after-delete hole. Checked specifically, because that would have been worse than wasted space.

Measured

On the owner's live 70.9 MiB database:

reclaimable now (40 migrated keys) 16.2 MB
still authoritative (98 unmigrated keys) 23.8 MB
contacts / discovered / path history (blob IS the storage) 2.0 MB
DELETE of the 40 blobs, no VACUUM +0 bytes
DELETE + VACUUM 54.9 MiB, -18.5 MiB, 0.6 s, integrity_check ok, all 28,386 rows intact

Two things that shape the design:

  • DELETE frees nothing on disk. SQLite marks pages free for reuse. VACUUM is what shrinks the file.
  • Migration is lazy and per key. 40 of his keys had migrated and 98 had not, because those radios had not been reconnected. An install-wide clock would be wrong.

Who it affects

Schema 2 has not shipped (v1.6.0, 2026-10-05, predates 4d4d44a6), so no user has this today. Once it ships, all ~175 active Android installs (173 on 2026-10-17) plus desktop hold their history twice. Severity scales with history size, and the users with the largest histories are by definition the ones who hit the original ANRs, since the blob re-encode cost was proportional to history.

The design, owner decision 2026-10-22

  • A per-key time marker written at migration.
  • X time after a key migrates, its stale blob is cleared automatically.
  • Plus a manual "Clear Stale History Now" action, so a user need not wait.

Owner's rationale, recorded because it sets the acceptable-loss bar: "Considering how many messages have gotten merged, lost, or messed up because of the lack of radio separation, loss of everything isn't a big loss. MC stock doesn't have this kind of message retention anyway, people are used to not having a consistent, persistent store of messages going back indefinitely in time."

What the snapshot does and does not protect, so this is not mistaken for removing a backup:

  • It protects the pre-migration history against a migration or read-path bug found later.
  • It protects nothing that arrived since. That traffic is single-copy from the instant it lands, blob or no blob.
  • Its coverage decays as new messages accumulate.

One safety property that makes deletion tolerable: if the migrated marker were ever lost after the blobs are gone, the empty-blob path at blob_store.dart:340 re-marks the key and reads rows, so history survives marker loss.

Related

Plan of record to follow; this is not approved for build yet.

Agent Mail: TealGlen

Activity

  1. added 2 commits that reference this issue on Oct 22, 2026
  2. diffwireauto commented on Oct 23, 2026

    @diffwireauto
    MemberAuthor

    Owner decisions, 2026-10-22

    "X = 30, sweep once per launch, timestamps in separate blob, button reports refusals, agreed."

    All four as recommended, now recorded in the plan's section 9 with the reason for each:

    Decision
    Stale window 30 days after a key migrates
    Automatic sweep Once per launch, after startup settles, never mid-session
    Timestamps A separate __msgrows_migrated_at blob, leaving the hot marker path untouched
    Refusals The manual action reports them in its result; the automatic sweep stays log-only

    Every X placeholder in the plan is now the literal 30 days, and the acceptance bar tests the boundary at 29 days rather than only the middle of each range.

    Also added as diagnosis row 11, from the owner's question about whether VACUUM would wipe out non-migrated blobs: it cannot. Measured on a copy, 40 migrated blobs deleted and 291 keys kept including all 98 unmigrated history blobs, each hashed before and after: 0 missing, 0 byte-changed, 0 unexpected, rows 28,389 unchanged, integrity_check ok. The consequence is the useful part and is now stated in the plan: all the risk is in the DELETE selection and none of it is in VACUUM, which is why the safety budget sits on the per-key re-verify and the key-pattern restriction.

    Plan PR: #964. epic_chain is filled in after it merges and before the grant is minted.

    Agent Mail: TealGlen

  3. added 3 commits that reference this issue on Oct 23, 2026
  4. added 2 commits that reference this issue on Oct 23, 2026
  5. added 3 commits that reference this issue on Oct 24, 2026
  6. diffwireauto commented on Oct 24, 2026

    @diffwireauto
    MemberAuthor

    Feature complete

    All three Epics merged to dev via #973, head 5b061924. Owner-validated on Windows 2026-10-23.

    What shipped

    • A per-key migration timestamp in a separate __msgrows_migrated_at blob, leaving the hot marker path untouched.
    • A sweep, once per launch, clearing a stale copy 30 days after its key migrated. Every delete is gated on asking the rows again under the key's own lock, never on trusting the marker.
    • "Clear Stale History Now" in App Settings, with a confirmation that states how many copies, roughly how much space, how many messages are not affected, and that it cannot be undone.
    • A durable audit in the database naming every key deleted, because the app log is gated on a setting that is off by default and an irreversible delete would otherwise leave no trail.
    • One VACUUM, only when something was deleted, since DELETE alone frees nothing.

    Measured on the owner's install

    file bytes      74,350,592 -> 57,057,280     -16.5 MiB
    timestamps               0 -> 47
    audit entries            0 -> 1  (40 keys named, 0 refused)
    cleared keys still holding a blob   : 0
    unmigrated history blobs lost       : 0 of 98
    cleared keys left with zero rows    : 0
    

    The 98 untouched blobs are the line that matters: those radios have not been reconnected since the storage upgrade, so their history is still single-copy and the sweep correctly left it alone.

    Two things found while building, not before

    • The eligibility bug. Writing the preview exposed that the "skip anything stamped this pass" guard, which exists so an app update never deletes on sight, was also being applied to the button. Every install that migrated before this has no timestamps, so the first press would have stamped everything and cleared nothing. The owner's install was exactly that: 47 migrated keys, none stamped. Now one shared eligibleByAge rule, with the automatic sweep still refusing and an explicit press not.
    • A data-loss hole in the verify path, from the Gemini gate: missingFromRows trusted the converter's own output as the measure of the converter. A callback that silently dropped a record would have had everything it did produce found present and the blob holding the only copy of the dropped one deleted. Now cross-checked against an independent count, as the migration gate always was.

    Process change

    The plan gained B0: mock-ups first, owner-approved before any of it is built, after this Feature's UI was built and installed on both his machines without him ever seeing the design. epic-b-storage-mockups.html is committed beside the plan in the format the firmware initiative uses.

    Carried forward

    #963, the retention limit. Its fourth open question is the one to design first: trimming rows makes the stale blob a superset of the rows, which is this sweep's refusal condition, so the two must agree on authority or a trim permanently blocks reclamation the way #952 did.

    Agent Mail: TealGlen

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions