Skip to content

feat: drop_ledger drops the whole ledger; root-branch check is struct…#1244

Merged
bplatz merged 5 commits into
mainfrom
feature/drop-ledger-whole-ledger-semantics
May 19, 2026
Merged

feat: drop_ledger drops the whole ledger; root-branch check is struct…#1244
bplatz merged 5 commits into
mainfrom
feature/drop-ledger-whole-ledger-semantics

Conversation

@bplatz
Copy link
Copy Markdown
Contributor

@bplatz bplatz commented May 16, 2026

Sits on top of #1243

drop_ledger was misnamed: it accepted a ledger ID like "mydb:main", normalized it to a branch, and dropped only that one branch. That made dropping a multi-branch ledger a per-call iteration on the caller, and made dropping a ledger via name ("mydb") silently equivalent to dropping just mydb:main while leaving siblings + the cross-branch @shared/dicts/ namespace orphaned.

This PR makes drop_ledger do what it says: remove the entire ledger — every branch, every retracted-but-not-purged record, plus the shared dict namespace. It also replaces the "main is special" name-based guards in drop_branch and rebase_branch with a structural check on source_branch.is_none().

Changes

drop_ledger is now whole-ledger

  • Snapshots every NsRecord under the ledger name via all_records() (not list_branches, which excludes retracted) so hard-drop cleans up retracted-but-not-purged branches too. Enumeration failures propagate as Err — silently coercing to NotFound would let the HTTP route fall through to drop_graph_source and potentially delete an unrelated graph source with the same name.
  • Sorts branches leaf-first via source_branch pointers (cycle-safe). Partial failure leaves orphan parents, never dangling children.
  • Cancels and waits for pending background indexing on each branch before any storage mutation.
  • Per-branch loop: deletes per-branch artifacts (hard) and either retracts (soft) or invokes AdminPublisher::drop_branch (hard). The hard-mode path is parent-aware, so surviving parents keep accurate branches counts even if the operation aborts. Concurrent NotFound is race-as-success; any other nameservice failure bails with ApiError::Drop before touching parents or @shared/. Retry is safe — every step is idempotent under partial prior progress.
  • After every branch is gone, wipes {ledger_name}/@shared/dicts/. No more orphan shared blobs on full-ledger drop.
  • Cache disconnect runs unconditionally per branch so stale state never survives the operation.

Input parsing is explicit

Input Behavior
"mydb" Whole-ledger drop. Canonical form.
"mydb:main" Whole-ledger drop, with a warning attached to the report nudging callers toward the bare name. Backwards-compat for existing examples.
"mydb:dev" (or any non-default branch suffix) Rejected with 400/ApiError::Http. Caller almost certainly meant drop_branch("mydb", "dev"); the error message says so.

This is deliberately the conservative migration: "mydb" cannot accidentally drop only mydb:main, and "mydb:dev" cannot accidentally drop the whole mydb ledger.

Root-branch refusal is structural

Both drop_branch and rebase_branch previously refused the literal string "main". The new check is record-based: record.source_branch.is_none(). That means:

  • A ledger created with a non-default initial branch (e.g. mydb:trunk) correctly refuses operations on its root.
  • A non-root branch that happens to be named "main" (e.g. created as a child of trunk) is droppable / rebaseable like any other.

"main" is now purely a default-name convention — DEFAULT_BRANCH in fluree-db-core/src/ledger_id.rs. No code path treats the string "main" as semantically special.

DropReport and HTTP wire format

Additive shape change:

pub struct DropReport {
    pub ledger_id: String,                // ledger name (bare, no :branch)
    pub status: DropStatus,                // aggregate across branches
    pub artifacts_deleted: usize,          // sum across branches + @shared cleanup
    pub branch_reports: Vec<BranchDropReport>,  // NEW: per-branch, leaf-first
    pub warnings: Vec<String>,
}

The HTTP DropResponse exposes the per-branch detail as branches_dropped: Vec<String> (omitted via skip_serializing_if when empty).

{
  "ledger_id": "mydb",
  "status": "dropped",
  "files_deleted": 73,
  "branches_dropped": ["mydb:feature-x", "mydb:dev", "mydb:main"]
}

The CLI's fluree drop output gains a branch count on multi-branch drops:

Dropped ledger 'oldledger' (deleted 73 artifacts across 3 branches)

Docs

End-to-end refresh:

  • docs/api/endpoints.mdPOST /drop reflects whole-ledger semantics, the branches_dropped field, leaf-first ordering, the input-form rules, and the 400 on non-default suffixes. POST /drop-branch link fixed (was /branch/drop / #post-branchdrop).
  • docs/getting-started/rust-api.mddrop_ledger section reframed around whole-ledger semantics; drop_branch callout for single-branch drops; updated idempotency to reflect new NotFound behavior.
  • docs/cli/drop.md — bare-name input, leaf-first semantics, branch-suffix rejection, updated example output.
  • docs/cli/server-integration.md — branch-drop and rebase contracts reframed as root-branch refusals (no more "Cannot be 'main'").
  • docs/concepts/ledgers-and-nameservice.md — "main" is the default, root refusal is structural.
  • docs/operations/admin-and-health.mdPOST /drop summary updated to match.

Stale doc comment fixed

The Rust doc-comment above drop_ledger previously described the old branch-level flow ("Normalizes the ledger ID (ensures :main suffix)" → "Retracts from nameservice") and claimed hard mode still attempts cleanup for not-found ledgers. Rewritten end-to-end:

  • 7-step Operation list reflects whole-ledger flow.
  • Idempotency block notes that NotFound returns without touching storage — orphaned storage with no NsRecord pointer is not swept by drop_ledger, that's a separate admin concern.
  • Documents the abort-with-ApiError::Drop behavior on real nameservice failure and that retry is safe.

New / updated tests:

  • drop_ledger_accepts_bare_name_and_default_suffix — bare name is canonical; report uses the bare name; no warning.
  • drop_ledger_main_suffix_warns_but_works:main form is accepted but attaches the migration warning.
  • drop_ledger_rejects_non_default_branch_suffixmydb:dev is rejected and the error names drop_branch.
  • drop_ledger_hard_clears_every_branch_and_shared — multi-branch ledger with one branch retracted-as-deferred (because it has live children); hard drop_ledger walks leaf-first, wipes @shared/dicts/, and re-creating the alias succeeds.
  • drop_main_refused — refuses the default root with a "root branch" error.
  • drop_branch_refuses_non_main_root — refuses a non-main-named root structurally.
  • drop_branch_allows_non_root_named_main — allows a non-root branch literally named "main".
  • rebase_main_refused / rebase_refuses_non_main_root — same structural refusal for rebase.

LocalStack integration test from the prior PR (s3_testcontainers_hard_drop_clears_ledger) still passes — exercises the same code paths against real S3 + DynamoDB.

Migration notes

  • HTTP POST /drop: callers passing "mydb" now drop the whole ledger; previously they dropped only mydb:main. Callers passing "mydb:main" see the same outcome but a warning is attached.
  • HTTP callers passing "mydb:dev" (any non-default branch suffix) will now get a 400. They should switch to POST /drop-branch.
  • Rust callers mirror the same rules. drop_branch (the right tool for branch-scoped drops) is unchanged in shape.
  • DropReport.ledger_id is now the bare ledger name. Anything asserting against the old mydb:main form needs updating.

…ural

drop_ledger now removes every branch under a ledger name (including
retracted-but-not-purged branches) plus the cross-branch @shared/dicts/
namespace. Branches are dropped leaf-first via source_branch pointers
so a partial failure leaves orphan parents rather than dangling
children. Hard mode uses the parent-aware AdminPublisher::drop_branch
path so surviving parents keep accurate child counts even on abort.

Inputs are validated: the bare ledger name "mydb" is canonical; the
branch-qualified form "mydb:main" is accepted with a warning;
non-default branch suffixes like "mydb:dev" are rejected with a 400
that points the caller at drop_branch. A real per-branch nameservice
failure aborts with ApiError::Drop before touching parents or shared
cleanup; NotFound races are treated as success.

DropReport gains branch_reports for per-branch detail; the HTTP
DropResponse exposes the same as branches_dropped.

The "cannot drop main" guards in drop_branch and rebase_branch are
replaced with structural source_branch.is_none() root checks. "main"
carries no special meaning anymore — a ledger whose root is named
"trunk" refuses operations on "trunk", and a non-root branch named
"main" can be dropped or rebased like any other branch.

Docs updated end-to-end: api/endpoints.md, cli/drop.md,
cli/server-integration.md, concepts/ledgers-and-nameservice.md,
getting-started/rust-api.md, operations/admin-and-health.md. CLI
output now reports per-branch artifact counts on multi-branch drops.

Tests cover whole-ledger leaf-first hard drop with retracted-branch
cleanup and @shared/dicts/ wipe, bare-name canonical form, the
main-suffix backwards-compat warning, the non-default-suffix
rejection, structural root refusal in both drop_branch and
rebase_branch, and the allowance for a non-root branch named "main".
@bplatz bplatz requested review from aaj3f and zonotope May 16, 2026 13:19
Base automatically changed from fix/s3-ledger-drop to main May 16, 2026 21:51
Copy link
Copy Markdown
Contributor

@zonotope zonotope left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing I'd flag here is the missing event for purging a ledger. Lgtm otherwise.

Comment thread fluree-db-api/src/rebase.rs Outdated
let source_name = branch_record.source_branch.as_ref().ok_or_else(|| {
ApiError::InvalidBranch(format!("Branch {branch_id} has no source branch"))
ApiError::InvalidBranch(format!(
"Cannot rebase the root branch '{branch}' of ledger '{ledger_name}' \
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hairsplitting, I know, so take or leave it as you wish. It's totally subjective, but perhaps we could just say "root" here. main (or whatever) is the root of the ledger, and everything else is a branch. It seems weird to me to have a notion of "root branch". I think "root" fits better or, to continue the tree metaphor, we could say "trunk" or "stem". But, like I said, very subjective, so take it or leave it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fd8fe74.

Comment thread fluree-db-api/src/admin.rs Outdated
.map_err(|e| bad_input(format!("Invalid ledger id '{input}': {e}")))?;

if branch == DEFAULT_BRANCH {
let warning = format!(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this should also be an error to eliminate any possibility of surprise, especially since we're no longer treating the main name specially.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in aff03b7.

// stale one. Soft mode just retracts.
let ns_result = if matches!(mode, DropMode::Hard) {
publisher
.drop_branch(&branch.ledger_id)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bypasses the publisher.purge call that we used to make in favor of publisher.drop_branch for every branch. This matters because .purge used to emit the LedgerRectracted event to the subscribers. Now the subscribers won't know to clear their local caches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 573c6f7.

// to `NotFound` would let the HTTP route fall through to the
// drop_graph_source path, potentially deleting an unrelated graph
// source with the same name.
let all = self.nameservice().all_records().await?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will do a full nameservice scan then a filter to get the branches. I think it would be better to do the initial scan scoped to the ledger name prefix for the nameservice back ends that support it, but I think that would require a larger refactor of the nameservice traits, so it probably isn't for this pr.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — not for this PR. Tracking in #1248.

bplatz added 3 commits May 19, 2026 17:14
… branch'

Once 'main' carries no special meaning, calling the no-source-branch
record the 'root branch' is doubly-loaded — every record is a branch,
and one of them is the root. Lean on the tree metaphor instead: the
record whose source_branch is None is the root, and the user's
parameter is just the branch they named.

Reword drop_branch and rebase_branch error messages, and update the
test assertions that check those strings. Behavior unchanged.
drop_ledger previously accepted 'mydb:main' with a warning while
rejecting any other suffix. With 'main' no longer carrying special
meaning, that asymmetry invites the very surprise the PR is trying to
remove — a caller passing 'mydb:main' may believe they're scoped to
the main branch and instead wipe the whole ledger.

Reject every branch-qualified id with the same 400 / ApiError::Http
shape that non-default suffixes already returned, and route the caller
to drop_branch with a concrete suggestion. parse_whole_ledger_input
loses its Option<String> warning slot since no path produces one.

Tests + docs (cli/drop.md, operations/admin-and-health.md,
getting-started/rust-api.md, api/endpoints.md) updated to reflect the
single accepted input form.
The whole-ledger drop path now invokes AdminPublisher::drop_branch
for each branch (parent-aware row sweep) instead of Publisher::purge.
The notifying wrapper had no event hook on that admin method, so
subscribers — ledger cache, query peers — no longer received the
LedgerRetracted signal they used to drive cache invalidation.

Add the same event_bus.notify(LedgerRetracted) emission that
retract/purge already perform. One event per branch drop, matching
the per-branch loop above the call site.
Missed in the prior commit: the S3 testcontainers hard-drop test was
still calling drop_ledger with the ':main'-qualified id, which the
tightened parser now rejects with a 400. Switch to the bare name.
@bplatz bplatz merged commit d90dc40 into main May 19, 2026
14 checks passed
@bplatz bplatz deleted the feature/drop-ledger-whole-ledger-semantics branch May 19, 2026 22:54
@bplatz bplatz mentioned this pull request May 21, 2026
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.

2 participants