Repository navigation
fix: batch snapshot deletes and checkpoint-poisoned replication (#477) - #481
Merged
Merged
Conversation
kopia's `snapshot list --json` emits incomplete checkpoint manifests even without `--incomplete` (that flag filters only the text output), and SnapshotListEntry had no `incomplete` field, so serde dropped the marker. A stray checkpoint from an interrupted `kopia snapshot create` was then expected by SnapshotReplication's post-verify forever (#477), and could be picked as "latest" by restore, verify, catalog discovery and succeeded-snapshot resolution. - model: SnapshotListEntry/DirSummary gain `incomplete`, read via incomplete_reason() (top-level or rootEntry.summ; any non-empty reason). - client: snapshot_list/snapshot_list_all are complete-only by default; snapshot_list_all_with_incomplete() serves manifest-id presence checks. - replication: key-set kernels ignore incomplete entries; skipped source checkpoints are counted in status.lastRun.incompleteSkipped, named in the mover log and in the Ready message. - fixture captured from a real kopia 0.23.1 repository. Refs #477
A SnapshotDeleteBatch Job ran one `kopia snapshot delete <id>` process per member, and every process re-opened the repository and reloaded its full index. On a large remote repository that is minutes per member: #477 measured 17 deletes in 37 minutes versus 1,336 in 15 seconds for one multi-id call. The batch is now planned against ONE `snapshot list --all` and deleted with one `kopia snapshot delete <id>... --delete` per 500 ids. Partitioning first is mandatory: kopia's multi-id delete is all-or-nothing (its write session flushes only on success, verified against a real 0.23.1 repository) and one absent id aborts the whole call. - batch_delete::plan_batch_delete is pure and keeps every legacy per-member semantic: absent = already done, the start_time-gated stale-id self-heal, and its matcher now excludes the member's own recorded id (the post-delete view the legacy path re-listed; without it a mid-rewrite pair is ambiguous and the live manifest is orphaned). - KopiaClient::snapshot_delete_many never remaps `no snapshots matched`: in multi-id form it means nothing was committed. - A retryable bulk failure fails the chunk for a controller backoff retry instead of re-opening the repository once per id; a non-retryable one retries each id alone. A failed listing fails the Job outright. - classify: `failed to open repository`, `error loading indexes` and `unexpected EOF` (the #477 backend error) are RepositoryUnavailable, not Unknown. Refs #477
Delete batches were documented as single-flight per repository, but nothing enforced it: fireable_members let every finalizer reconcile fire the NEXT wave while earlier ones ran, and the only brake was the cluster-wide, default-off KOPIUR_MAX_CONCURRENT_DELETE_JOBS - itself a bare LIST-then-compare. A 4,000-snapshot replication prune became ~33 concurrent snapdel Jobs against one B2 repository (#477). - api: Repository/ClusterRepository spec.concurrency.maxConcurrentDeleteJobs (absent = 1, minimum 1 in the schema and the webhook; no "unlimited"). - pool::DeleteAdmissionLedger: observe -> decide -> reserve under one lock (the AdmissionLedger pattern), capping per repository AND, when set, cluster-wide, so concurrent member reconciles cannot each launch a batch. Replaces throttle_verdict. A reservation never parks its own Job, so a sibling reconcile of the same wave still dedups on the 409. - batch Jobs get a 30-minute activeDeadlineSeconds: with a cap of 1, the inherited 48h deadline would let one wedged Job stall a repository's deletions for two days. - batches truncate to an 80 KiB serialized-member budget (under the 100 KiB work-spec env cap) and MAX_BATCH_MEMBERS rises to 1000; the old 200 cap could already build an unbuildable Job with long source paths. - docs: every "already single-flight" claim corrected; ADR-0006 amended. - e2e: scenario 6 now hard-asserts single-flight with the batch held live by the slow mover; new scenario 10 proves a cap of 2 overlaps. Refs #477
Code review of #481: incompleteSkipped was scoped through select_identities, which only sees complete snapshots. An identity whose ONLY manifest is a checkpoint (an interrupted first backup, #477's exact shape) was therefore never reported, and a checkpoint-only source short-circuited to a silent NoIdentitiesMatched. - incomplete_skipped applies the include/exclude matchers to the incomplete entries directly (one shared identity rule with select_identities), so every matched checkpoint is counted. - The NoIdentitiesMatched early return carries the count and names it in its Ready message. - StorageStats.snapshotCount's doc now says it counts complete snapshots, matching the complete-only listing it is fed from. Refs #477
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Sep 24, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Sep 24, 2026
Merged
doonga
pushed a commit
to greyrock-labs/home-ops
that referenced
this pull request
Sep 25, 2026
….10.9 ➔ 0.10.10) (#280) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/home-operations/charts/kopiur](https://github.com/home-operations/kopiur) | patch | `0.10.9` → `0.10.10` | --- ### Release Notes <details> <summary>home-operations/kopiur (ghcr.io/home-operations/charts/kopiur)</summary> ### [`v0.10.10`](https://github.com/home-operations/kopiur/blob/HEAD/CHANGELOG.md#01010-2026-09-25) [Compare Source](home-operations/kopiur@0.10.9...0.10.10) ##### Bug Fixes - batch snapshot deletes and checkpoint-poisoned replication ([#​477](home-operations/kopiur#477)) ([#​481](home-operations/kopiur#481)) ([183337c](home-operations/kopiur@183337c)) - **container:** update image busybox ([`cac8f90`](home-operations/kopiur@cac8f90) → [`fd7dc98`](home-operations/kopiur@fd7dc98)) ([#​479](home-operations/kopiur#479)) ([945a106](home-operations/kopiur@945a106)) - **container:** update image busybox ([`dc2d74b`](home-operations/kopiur@dc2d74b) → [`cac8f90`](home-operations/kopiur@cac8f90)) ([#​478](home-operations/kopiur#478)) ([8531656](home-operations/kopiur@8531656)) - **e2e:** move MinIO/mc to the pgsty fork; preload digest refs via crictl ([#​485](home-operations/kopiur#485)) ([a4f0233](home-operations/kopiur@a4f0233)) - **rust:** update crate clap (4.6.6 → 4.6.7) ([#​465](home-operations/kopiur#465)) ([fcddd45](home-operations/kopiur@fcddd45)) - **rust:** update crate rand (0.10.2 → 0.10.3) ([#​473](home-operations/kopiur#473)) ([026c0f4](home-operations/kopiur@026c0f4)) - **rust:** update crate thiserror (2.0.20 → 2.0.21) ([#​482](home-operations/kopiur#482)) ([1457622](home-operations/kopiur@1457622)) ##### Continuous Integration - **github-action:** update github-actions ([#​468](home-operations/kopiur#468)) ([af27b2e](home-operations/kopiur@af27b2e)) ##### Miscellaneous Chores - **github-action:** update action codecov/codecov-action (v7.1.0 → v7.1.1) ([#​472](home-operations/kopiur#472)) ([5a3f76b](home-operations/kopiur@5a3f76b)) - **mise:** update mise tools ([#​463](home-operations/kopiur#463)) ([69698aa](home-operations/kopiur@69698aa)) - **mise:** update tool aqua:astral-sh/uv (0.12.15 → 0.12.16) ([#​474](home-operations/kopiur#474)) ([8a45270](home-operations/kopiur@8a45270)) - **mise:** update tool aqua:astral-sh/uv (0.12.16 → 0.12.17) ([#​475](home-operations/kopiur#475)) ([b94926b](home-operations/kopiur@b94926b)) - **mise:** update tool node (24.20.0 → v24.21.0) ([#​457](home-operations/kopiur#457)) ([fc67c05](home-operations/kopiur@fc67c05)) - **mise:** update tool oxfmt (0.66.0 → 0.68.0) ([#​459](home-operations/kopiur#459)) ([0614d12](home-operations/kopiur@0614d12)) - **mise:** update tool rust (1.98.0 → 1.98.1) ([#​447](home-operations/kopiur#447)) ([3b2d33e](home-operations/kopiur@3b2d33e)) - **renovate:** inherit the automerge rules from the shared preset ([#​483](home-operations/kopiur#483)) ([5b9feb6](home-operations/kopiur@5b9feb6)) </details> --- ### Configuration 📅 **Schedule**: (in timezone America/New_York) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMDUuMiIsInVwZGF0ZWRJblZlciI6IjQ0LjEwNS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19--> Reviewed-on: https://git.greyrock.io/todd/home-ops/pulls/280
gabrielcosi
pushed a commit
to gabrielcosi/home-ops
that referenced
this pull request
Sep 25, 2026
….10.9 ➔ 0.10.10) (#931) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/home-operations/charts/kopiur](https://github.com/home-operations/kopiur) | patch | `0.10.9` → `0.10.10` | --- ### Release Notes <details> <summary>home-operations/kopiur (ghcr.io/home-operations/charts/kopiur)</summary> ### [`v0.10.10`](https://github.com/home-operations/kopiur/blob/HEAD/CHANGELOG.md#01010-2026-09-25) [Compare Source](home-operations/kopiur@0.10.9...0.10.10) ##### Bug Fixes - batch snapshot deletes and checkpoint-poisoned replication ([#​477](home-operations/kopiur#477)) ([#​481](home-operations/kopiur#481)) ([183337c](home-operations/kopiur@183337c)) - **container:** update image busybox ([`cac8f90`](home-operations/kopiur@cac8f90) → [`fd7dc98`](home-operations/kopiur@fd7dc98)) ([#​479](home-operations/kopiur#479)) ([945a106](home-operations/kopiur@945a106)) - **container:** update image busybox ([`dc2d74b`](home-operations/kopiur@dc2d74b) → [`cac8f90`](home-operations/kopiur@cac8f90)) ([#​478](home-operations/kopiur#478)) ([8531656](home-operations/kopiur@8531656)) - **e2e:** move MinIO/mc to the pgsty fork; preload digest refs via crictl ([#​485](home-operations/kopiur#485)) ([a4f0233](home-operations/kopiur@a4f0233)) - **rust:** update crate clap (4.6.6 → 4.6.7) ([#​465](home-operations/kopiur#465)) ([fcddd45](home-operations/kopiur@fcddd45)) - **rust:** update crate rand (0.10.2 → 0.10.3) ([#​473](home-operations/kopiur#473)) ([026c0f4](home-operations/kopiur@026c0f4)) - **rust:** update crate thiserror (2.0.20 → 2.0.21) ([#​482](home-operations/kopiur#482)) ([1457622](home-operations/kopiur@1457622)) ##### Continuous Integration - **github-action:** update github-actions ([#​468](home-operations/kopiur#468)) ([af27b2e](home-operations/kopiur@af27b2e)) ##### Miscellaneous Chores - **github-action:** update action codecov/codecov-action (v7.1.0 → v7.1.1) ([#​472](home-operations/kopiur#472)) ([5a3f76b](home-operations/kopiur@5a3f76b)) - **mise:** update mise tools ([#​463](home-operations/kopiur#463)) ([69698aa](home-operations/kopiur@69698aa)) - **mise:** update tool aqua:astral-sh/uv (0.12.15 → 0.12.16) ([#​474](home-operations/kopiur#474)) ([8a45270](home-operations/kopiur@8a45270)) - **mise:** update tool aqua:astral-sh/uv (0.12.16 → 0.12.17) ([#​475](home-operations/kopiur#475)) ([b94926b](home-operations/kopiur@b94926b)) - **mise:** update tool node (24.20.0 → v24.21.0) ([#​457](home-operations/kopiur#457)) ([fc67c05](home-operations/kopiur@fc67c05)) - **mise:** update tool oxfmt (0.66.0 → 0.68.0) ([#​459](home-operations/kopiur#459)) ([0614d12](home-operations/kopiur@0614d12)) - **mise:** update tool rust (1.98.0 → 1.98.1) ([#​447](home-operations/kopiur#447)) ([3b2d33e](home-operations/kopiur@3b2d33e)) - **renovate:** inherit the automerge rules from the shared preset ([#​483](home-operations/kopiur#483)) ([5b9feb6](home-operations/kopiur@5b9feb6)) </details> --- ### Configuration 📅 **Schedule**: (in timezone Europe/Berlin) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMDMuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjEwMy4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19--> Reviewed-on: https://git.xcd.dev/gabrielcosi/home-ops/pulls/931
eleboucher
pushed a commit
to eleboucher/homelab
that referenced
this pull request
Sep 26, 2026
….10.9 ➔ 0.10.10) (#2200) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/home-operations/charts/kopiur](https://github.com/home-operations/kopiur) | patch | `0.10.9` → `0.10.10` | --- >⚠️ **Warning** > > Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/567) for more information. --- ### Release Notes <details> <summary>home-operations/kopiur (ghcr.io/home-operations/charts/kopiur)</summary> ### [`v0.10.10`](https://github.com/home-operations/kopiur/blob/HEAD/CHANGELOG.md#01010-2026-09-25) [Compare Source](home-operations/kopiur@0.10.9...0.10.10) ##### Bug Fixes - batch snapshot deletes and checkpoint-poisoned replication ([#​477](home-operations/kopiur#477)) ([#​481](home-operations/kopiur#481)) ([183337c](home-operations/kopiur@183337c)) - **container:** update image busybox ([`cac8f90`](home-operations/kopiur@cac8f90) → [`fd7dc98`](home-operations/kopiur@fd7dc98)) ([#​479](home-operations/kopiur#479)) ([945a106](home-operations/kopiur@945a106)) - **container:** update image busybox ([`dc2d74b`](home-operations/kopiur@dc2d74b) → [`cac8f90`](home-operations/kopiur@cac8f90)) ([#​478](home-operations/kopiur#478)) ([8531656](home-operations/kopiur@8531656)) - **e2e:** move MinIO/mc to the pgsty fork; preload digest refs via crictl ([#​485](home-operations/kopiur#485)) ([a4f0233](home-operations/kopiur@a4f0233)) - **rust:** update crate clap (4.6.6 → 4.6.7) ([#​465](home-operations/kopiur#465)) ([fcddd45](home-operations/kopiur@fcddd45)) - **rust:** update crate rand (0.10.2 → 0.10.3) ([#​473](home-operations/kopiur#473)) ([026c0f4](home-operations/kopiur@026c0f4)) - **rust:** update crate thiserror (2.0.20 → 2.0.21) ([#​482](home-operations/kopiur#482)) ([1457622](home-operations/kopiur@1457622)) ##### Continuous Integration - **github-action:** update github-actions ([#​468](home-operations/kopiur#468)) ([af27b2e](home-operations/kopiur@af27b2e)) ##### Miscellaneous Chores - **github-action:** update action codecov/codecov-action (v7.1.0 → v7.1.1) ([#​472](home-operations/kopiur#472)) ([5a3f76b](home-operations/kopiur@5a3f76b)) - **mise:** update mise tools ([#​463](home-operations/kopiur#463)) ([69698aa](home-operations/kopiur@69698aa)) - **mise:** update tool aqua:astral-sh/uv (0.12.15 → 0.12.16) ([#​474](home-operations/kopiur#474)) ([8a45270](home-operations/kopiur@8a45270)) - **mise:** update tool aqua:astral-sh/uv (0.12.16 → 0.12.17) ([#​475](home-operations/kopiur#475)) ([b94926b](home-operations/kopiur@b94926b)) - **mise:** update tool node (24.20.0 → v24.21.0) ([#​457](home-operations/kopiur#457)) ([fc67c05](home-operations/kopiur@fc67c05)) - **mise:** update tool oxfmt (0.66.0 → 0.68.0) ([#​459](home-operations/kopiur#459)) ([0614d12](home-operations/kopiur@0614d12)) - **mise:** update tool rust (1.98.0 → 1.98.1) ([#​447](home-operations/kopiur#447)) ([3b2d33e](home-operations/kopiur@3b2d33e)) - **renovate:** inherit the automerge rules from the shared preset ([#​483](home-operations/kopiur#483)) ([5b9feb6](home-operations/kopiur@5b9feb6)) </details> --- ### Configuration 📅 **Schedule**: (in timezone Europe/Paris) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMDQuMiIsInVwZGF0ZWRJblZlciI6IjQ0LjEwNC4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19--> Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/2200
perfectra1n
added a commit
that referenced
this pull request
Sep 26, 2026
Brings in 0.10.9/0.10.10 (#467 stream sources + streamExec restores, #481 batched deletes, #485 MinIO fork) and the dependency bumps since the branch last synced. Most conflicts were main editing code this branch had moved out of crates/cli into crates/ops; main's edits are re-applied at the new home: - restore: `RestoreArgs -> RestoreRequest` is now `TryFrom`, with main's total `restore_target_from_args` (no `unreachable!`) refusing by name via `CliError::UnresolvedRestoreTarget`, since `streamExec` has no flag. Main's two target tests move to cli.rs beside the conversion. - ops snapshot: `mover: None` on the built spec, plus main's stream-policy planned-cells test. - ops doctor: the privileged-movers annotation value is `true`, not `allow`. - cli error: keep GoneWhileWaiting/AmbiguousTarget in OpsError (moved), add main's UnresolvedRestoreTarget. Semantic conflicts (merged cleanly, did not compile or silently misbehaved): - kopia fake_shim: main's new test built an `ObjectId` via `.into()`; this branch made it a validated newtype, so use `ObjectId::parse`. - ui: `SnapshotSpec.mover` in a test fixture; exhaustive `RestoreTarget` match in a cli test gains `StreamExec`. - ui policies view: a `stream` source fell through to "(unrecognized source)"; it now renders as `stream/<fileName>`, with a test. Unions: e2e `ws` feature + ui-model dep; e2e scenario list gains both sides' buckets; mise tool bumps from main keep this branch's pnpm; expand tests keep both sides' appended cases. Cargo.lock re-points the new crates at thiserror 2.0.21.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #477. It covers all three root causes from the issue, one commit each so they can be reviewed and bisected on their own.
1. Incomplete kopia checkpoints are never treated as snapshots (
c318ec23)kopia snapshot list --jsonemits incomplete checkpoint manifests even without--incomplete. That flag filters only the text output (seecli/command_snapshot_list.goin kopia 0.23.1).SnapshotListEntryhad noincompletefield, so serde dropped the marker. A single stray checkpoint then failedSnapshotReplication's post-verify on every run. It could also be picked as "latest" by restore, verify, catalog discovery and succeeded-snapshot resolution.SnapshotListEntry/DirSummarynow carryincomplete, read throughincomplete_reason(). It checks the top-level marker orrootEntry.summ, and treats any non-empty reason as incomplete.KopiaClient::snapshot_list/snapshot_list_allare complete-only by default.snapshot_list_all_with_incomplete()is used only for manifest-ID presence checks.status.lastRun.incompleteSkipped. The mover log names their IDs and theReadymessage mentions them.2. One kopia invocation per delete batch (
6f825bd2)A
snapdel-*Job ran onekopia snapshot delete <id>per member, so the repository was opened and its index loaded once per member. It now does onesnapshot list --alland onekopia snapshot delete <id>... --deleteper 500 IDs.batch_delete::plan_batch_deleteis pure and keeps every legacy per-member rule: absent means already done, and the stale-ID self-heal still sits behind thestart_timedata-loss gate. Its matcher excludes the member's own recorded ID, which reproduces the post-delete view the legacy path re-listed. Without that exclusion, a mid-rewrite pin pair is ambiguous and the live manifest is orphaned.classify(): the issue's error (failed to open repository … error loading indexes … unexpected EOF) wasUnknown. It is nowRepositoryUnavailable, and the more specific arms still win.3. Per-repository cap on delete batch Jobs (
dbd0a95f)Delete batches were documented as single-flight per repository, but nothing enforced it. That is how #477 ended up with about 33 concurrent Jobs on one B2 repository.
spec.concurrency.maxConcurrentDeleteJobsonRepository/ClusterRepository. Absent means 1. The minimum is 1, enforced by both the schema and the webhook, and there is no unlimited value.pool::DeleteAdmissionLedgerdoes observe → decide → reserve under one lock (the same pattern asAdmissionLedger), per repository and optionally cluster-wide. It replaces the racythrottle_verdict. A reservation never parks its own Job, so a sibling reconcile of the same wave still dedups on the 409.activeDeadlineSeconds. With a cap of 1, the inherited 48 h deadline would let one wedged Job stall a repository's deletions for two days.MAX_BATCH_MEMBERSgoes from 200 to 1000. With long source paths, the old 200 cap could already produce a Job that could not be built.Verification
mise run fmt-check,clippy,test(89 test binaries),gen-check,complexity-check(29/29, no growth) anddocsare all green.listand onedelete.mass_deletionplussnapshot_replication, 17/17 passing:maxConcurrentDeleteJobs: 2really lets two batches run at once, and never more than two.Reporter: happy to have you run the preview images against your repository. They include the bulk delete, which should drain a backlog like yours in seconds per batch.