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.
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