Skip to content

Make stream deletion asynchronous, resumable across restarts - #1770

Draft
prabhaks wants to merge 6 commits into
parseablehq:mainfrom
prabhaks:fix/1763-async-stream-deletion
Draft

Make stream deletion asynchronous, resumable across restarts#1770
prabhaks wants to merge 6 commits into
parseablehq:mainfrom
prabhaks:fix/1763-async-stream-deletion

Conversation

@prabhaks

Copy link
Copy Markdown
Contributor

Summary

Stacked on #1768.

Closes the second half of #1763: deleting a large stream currently blocks the DELETE request on a full recursive object-store delete, which can take a long time for TB-scale streams even though the underlying delete itself is already reasonably efficient (batched, concurrent). This PR moves the actual deletion to the background, building on the tombstone/deleting-flag safety net added in #1768.

  • DELETE /logstream/{stream} now writes a durable tombstone, best-effort deletes the small stream.json so the stream disappears from listings almost immediately, flags the stream deleting in memory, fans the delete out to ingestors, and responds 202 Accepted instead of 200 OK once all of that is durably in place -- before the slow part even starts.
  • The actual recursive object-store delete runs in a background task (deduplicated per stream), clearing the tombstone and removing the stream from memory once it finishes.
  • If the node crashes or restarts mid-deletion, the tombstone is discovered on startup and the deletion resumes automatically -- no manual cleanup needed.
  • Only the node that received the original client request ever runs the physical delete. Ingestors just flag the stream as deleting and wait for the tombstone to clear, so a single deletion isn't redundantly re-run by every node in the cluster.
  • A periodic self-heal check catches a node that missed the live notification (e.g. it was down or partitioned at the time) and brings it back in sync within one sync interval.
  • Fixed a bug (found during review of this change) where list_streams() on the local filesystem backend would fail the entire listing if it encountered a stream mid-deletion, since that backend treats a stream directory without stream.json as corrupt rather than "not a stream."

API contract change

DELETE /logstream/{stream} now returns 202 Accepted (body: "log stream {name} deletion started") instead of 200 OK once the deletion has finished. Any client code checking for exactly 200 will need updating.

Test plan

  • cargo build --lib
  • cargo test --lib (449 passed)
  • cargo fmt --check
  • cargo clippy --lib --all-targets
  • New unit tests: ACTIVE_STREAM_DELETIONS dedup semantics, and list_streams() correctly skipping (not erroring on) a stream mid-deletion on the local filesystem backend, including a control case confirming a genuinely corrupt directory still errors
  • Live cluster validation (202 timing, crash-mid-deletion resume, ingestor self-heal) -- to be run separately against a real multi-node cluster
Lays groundwork for background stream deletion: a durable tombstone
marker outside the deleted prefix, an in-memory `deleting` flag on
resident streams, and guards in the reload/query/info-endpoint code
paths that reject a stream once either is set. Purely additive, no
behavior change to the current delete handlers, since nothing yet
sets a tombstone or the flag. Prepares for the actual async-delete
rewrite in a follow-up PR.
…erable

list_dirs_relative only surfaces child directories on every backend
(S3/GCS/Azure via list-with-delimiter's common_prefixes, LocalFS via
read_dir + is_dir), never leaf objects. A tombstone stored as a bare
key named after the stream was therefore invisible to any future scan
that needs to discover tombstoned streams rather than check one known
name at a time. Move the marker one level deeper, under a directory
named after the stream, and add list_tombstoned_streams for that scan.
…lehq#1763)

DELETE /logstream/{stream} now writes a tombstone, notifies ingestors,
and returns 202 Accepted immediately instead of blocking on the full
recursive object-store delete. The actual deletion runs in a
deduplicated background task, resumes automatically if the node
crashes or restarts mid-delete (via the tombstone left by PR parseablehq#1768's
safety net), and self-heals nodes that missed the live notification.

Only the node that receives the original DELETE request ever runs the
physical delete; ingestors flag the stream as deleting and wait for
the tombstone to clear, so a large deletion doesn't get redundantly
re-run by every node in the cluster. list_streams() on the local
filesystem backend is also fixed to treat a stream mid-deletion as
absent rather than erroring out the whole listing.
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@prabhaks

Copy link
Copy Markdown
Contributor Author

CI status update: the two Quest integration test failures (Distributed and Standalone) are an expected consequence of this PR's intentional API contract change, not a bug in the implementation.

Root causes, confirmed from the CI logs:

  • DELETE /logstream/{stream} now returns 202 Accepted instead of 200 OK (by design, since the deletion is now asynchronous). Every Quest test whose setup/teardown does "delete stream, assert 200" fails at that assertion and then runs in a partially-cleaned-up state, which cascades into several unrelated-looking failures later in the same sequential test run.
  • Recreating a stream immediately after deleting it can now correctly return 409 Conflict ("being deleted, please retry shortly") instead of silently succeeding, since the old stream may still be mid-deletion. A few tests that delete-then-immediately-recreate a stream with the same name hit this.

Both are already called out under "API contract changes" in the PR description. Quest (quay.io/parseablehq/quest:main) is a separate repo/image and will need its assertions updated to expect 202 for stream deletion and to tolerate/retry on a transient 409 when recreating a stream right after deleting it, before this PR's CI can go green.

prabhaks added a commit to prabhaks/quest that referenced this pull request Aug 26, 2026
parseablehq/parseable#1770 makes DELETE /logstream/{stream} return 202
Accepted instead of 200 OK, since deletion now runs in the background
rather than blocking the response. It also makes recreating a stream
immediately after deleting it return 409 while the old stream's
deletion is still in flight, instead of succeeding right away.

Updates DeleteStream to expect 202, and adds a bounded retry-on-409 to
the stream creation helpers so tests that delete and immediately
recreate the same stream name (a common setup/teardown pattern here)
keep working without needing changes at every call site.
@prabhaks

Copy link
Copy Markdown
Contributor Author

Opened a companion fix for the Quest test suite: parseablehq/quest#126 (updates the hardcoded 200 assertions to 202, and adds retry-on-409 for tests that recreate a stream right after deleting it).

…discovery

set_metadata replaced the whole LogStreamMetadata wholesale, so a reload
racing a delete (e.g. a schema update landing after mark_deleting()) could
silently clear the deleting flag back to false despite it being documented
as monotonic. Now ORs it in instead of overwriting.

list_tombstoned_streams trusted list_dirs_relative's raw directory listing
as proof of a marker's existence, but a directory can exist under the
tombstone root without the marker itself (e.g. an interrupted write).
Each candidate is now re-verified with is_tombstoned before being reported.

list_old_streams (unused elsewhere in this codebase, but kept consistent
with list_streams) didn't exclude TOMBSTONE_ROOT_DIRECTORY, so dir_with_old_stream
would treat it as a corrupt stream directory the same way list_streams did
before the earlier fix.
check_or_load_stream's resident-stream fast path doesn't itself check
is_tombstoned (flagged in CodeRabbit's review of parseablehq#1768), so a concurrent
request on the same node could slip through in the window between the
tombstone becoming durable and mark_deleting() actually running. Moving
mark_deleting() before the tombstone write, with no await point in
between, closes that window entirely for the initiating node.

Cross-node propagation is still bounded by the existing fan-out push and
self-heal, not synchronous -- that's an accepted, already-documented
limitation of this design, not something this reorder attempts to fix.
@prabhaks

prabhaks commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

How the tombstone-based deletion works

The core problem: today, DELETE /logstream/{stream} blocks the HTTP response on a full recursive object-store delete. For a TB-scale stream that's potentially millions of keys, so the client waits minutes for something that should be instant.

The fix, in three parts:

  1. Durable marker, placed outside the stream's own prefix. When a delete request comes in, we write a tiny marker object to .tombstones/{tenant}/{stream_name}/marker — deliberately not under the stream's own {tenant}/{stream_name}/... prefix. That matters because the actual bulk delete is a single recursive LIST-then-DELETE over that exact prefix. If the tombstone lived inside it, the bulk delete could sweep it up mid-job, and then a crash right after would leave no record that a deletion was ever in progress — breaking the "resume on restart" guarantee. Placing it outside makes it structurally immune to that, regardless of backend or listing order.

  2. In-memory deleting flag, set before the tombstone write. The moment a delete request lands, we flip a deleting bool on the resident Stream object in memory (not persisted — it's re-derived from the tombstone on reload). This flag is checked at every place a stream could be touched: query execution, ingestion, schema/stats lookups, and stream reload from storage. It's set before the tombstone put_object call completes, with no await in between, specifically to close a race where a concurrent request on the same node could otherwise slip through in the gap between "tombstone durable" and "flag set."

  3. Respond immediately, delete in the background. Once the tombstone is written and the flag is set, we respond 202 Accepted right away. The actual bulk delete runs in a spawned background task. When it finishes, it clears the tombstone and removes the stream from memory. If the node crashes mid-delete, the tombstone survives (per point 1), and on restart we scan .tombstones/ and resume the deletion for anything still marked — so there's no orphaned half-deleted stream state after a crash.

Cross-node correctness (this is a distributed system, not a single process):

  • The node that receives the DELETE (query node, or standalone) is the only one that ever runs the actual physical bulk delete.
  • It notifies all live ingestors synchronously (before responding) to flag the stream deleting locally and stop new writes to it.
  • As a fallback for an ingestor that was down or partitioned when that notification went out, the periodic sync job also checks for the tombstone and self-heals — so the worst case is bounded to one sync interval, not indefinite staleness.
  • Only the originating node's background job ever runs the actual delete-stream call — ingestors just flag and wait, they never independently re-trigger the bulk delete.

Split into two PRs:

Happy to expand on any specific part (crash-recovery ordering, ingestor self-heal timing, why 202 vs 200, etc.) if useful.

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

Labels

None yet

1 participant