Skip to content

Verifiable asset deletion: listRoomAssets, deleteReferencesNow, plugin deleteNow - #57

Open
aldrinjenson wants to merge 1 commit into
yjs:masterfrom
aldrinjenson:yhub-delete-primitives
Open

Verifiable asset deletion: listRoomAssets, deleteReferencesNow, plugin deleteNow#57
aldrinjenson wants to merge 1 commit into
yjs:masterfrom
aldrinjenson:yhub-delete-primitives

Conversation

@aldrinjenson

Copy link
Copy Markdown

Adds three additive pieces that make it possible to delete a document's persisted data and prove it is gone. No existing behaviour changes — 234 insertions, 0 deletions.

Why

deleteReferences is tuned for its caller, compaction (store(new row)deleteReferences(old rows)), and its current semantics are right there: scheduling the plugin deletes without awaiting them keeps a slow object store off the compaction critical path, the 10s delay protects readers still holding the previous t, and always removing the rows avoids leaking them on the hot path.

But that combination means a failed object delete removes the only rows naming the object. Since store() offloads unconditionally for branch === 'main', effectively every byte lives in object storage and the row holds a stub — so deleting rows deletes pointers, not data. For a data-retention requirement, "deleted" has to mean the objects are provably gone.

There's a related gap in enumeration: retrieveDoc(room, { references: true }) only reports a reference once the plugin retrieve for it succeeded (retrieved && references?.push(...)), so a temporarily unreadable object hides its row — and with it the assetId needed to ever delete that object. A transient storage error strands a row permanently. That's a sensible degradation for reads, but it silently under-reports for deletion or inventory.

What's here

  • persistence.listRoomAssets(room) — every asset for a room, decoded from the row columns, no plugin calls. Complete by construction, so deletion can be verified and retried.
  • persistence.deleteReferencesNow(references) — objects first and awaited, rows only once all are confirmed gone; rejects otherwise. deleteReferences keeps its exact current behaviour.
  • PersistencePlugin.deleteNow(assetId, assetInfo) — optional immediate, awaited delete. Implemented for the S3 plugin; delete untouched. Optional on the interface, so existing plugins keep working.

Composed:

const assets = await yhub.persistence.listRoomAssets(room)
await yhub.persistence.deleteReferencesNow(assets)

Notes

  • deleteReferencesNow fails closed: it rejects if no plugin claims an offloaded asset, rather than skipping it. Plugins other than S3 need a deleteNow before they can be used with it — happy to add Azure/GCS if you'd like them in scope.
  • Docs added to API.md, including a callout that deleting rows doesn't stop a document coming back (store() is an insert, so a later compaction of stream residue re-creates it) and that writers should be disconnected first — recheckAuth(room, { forceDisconnect: true }) covers the client half nicely.
  • Two tests added to tests/storage.tests.js, following the existing conventions there.

Testing

npm run lint (standard + tsc) passes. I could not run the integration suite locally — it needs Valkey/Postgres/MinIO via compose.yaml and I don't have Docker on this machine — so the two new tests are unverified against real infra; please run them. I did exercise both new methods against stubbed sql and plugins to check the decode path, the ordering (objects before rows), that rows survive a rejecting deleteNow, and that an unclaimed asset rejects rather than being skipped.

Happy to adjust naming, split this up, or fold it into a higher-level deleteDoc(room) if you'd prefer that shape — see the linked issue for the fuller picture.

…lugin deleteNow

deleteReferences is tuned for compaction: plugin deletes are scheduled without
being awaited and the rows are removed regardless. That is the right trade-off
there, but it means a failed object delete leaves data behind with nothing
pointing at it, which makes permanent deletion unverifiable.

retrieveDoc({references: true}) has a related gap: a reference is only reported
once the plugin retrieve for it succeeded, so a temporarily unreadable object
hides its row -- and with it the assetId needed to ever delete that object.

Adds three additive pieces, leaving all existing behaviour untouched:

- persistence.listRoomAssets(room) decodes every asset from the row columns
  without calling the plugins, so the list cannot be short.
- persistence.deleteReferencesNow(references) deletes objects first, awaited,
  and removes rows only once all are confirmed gone, rejecting otherwise.
- PersistencePlugin.deleteNow is an optional immediate, awaited delete,
  implemented for the S3 plugin. delete is unchanged.
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