Maintenance
Kopia repositories need periodic maintenance to stay healthy. Maintenance compacts indexes, advances epochs, and, most importantly, reclaims storage by garbage-collecting content that deleted snapshots no longer reference. Without it, a repository keeps growing even as you expire old backups.
Kopiur makes maintenance a first-class, default-managed concern. You don’t have to remember to schedule it. Every Repository and ClusterRepository gets a Maintenance resource automatically, and the operator runs kopia maintenance on a schedule for every backend, filesystem and object stores such as S3, Azure, GCS and B2 alike.
Quick vs. full
Section titled “Quick vs. full”kopia has two maintenance passes, and Kopiur schedules them independently:
| Pass | kopia command | What it does | Default schedule |
|---|---|---|---|
| Quick | kopia maintenance run --no-full |
Cheap, frequent: index compaction, epoch advance. | every 6h (0 */6 * * *), 30m jitter |
| Full | kopia maintenance run --full |
Heavier: content garbage-collection + rewrite. This is what reclaims storage. | daily at 03:00 (0 3 * * *), 1h jitter |
A full run does everything a quick run does, so when both are due at once the operator runs full and advances both clocks.
The default-managed model
Section titled “The default-managed model”Maintenance is on by default. For every Repository and ClusterRepository, the operator creates a Maintenance resource named after the repository, with the default schedule above. You can see it with:
$ kubectl get maintenance -ANAMESPACE NAME REPOSITORY OWNER AGEbilling nas-primary nas-primary kopiur/billing/nas-primary 4h44mThere are three ways to control it, in increasing order of explicitness.
Try it end-to-end
Section titled “Try it end-to-end”Watch the default-managed model work: a Maintenance appears with nothing but a Repository, and then force a run on demand. The bundle deploy/examples/tryit/maintenance.yaml is deliberately minimal. It has a namespace, a repo PVC, a KOPIA_PASSWORD Secret, and a filesystem Repository, and no Maintenance resource. The operator creates one for you.
The Repository below is the only CR in the bundle. It carries no spec.maintenance block and there is no standalone Maintenance; the managed one appears the moment this repository is Ready:
# A bare Repository. NOTE there is no `spec.maintenance` block and no standalone# Maintenance CR: maintenance is default-on, so the operator projects an owned# `Maintenance` named `primary` the moment this repository is Ready.apiVersion: kopiur.home-operations.com/v1alpha1kind: Repositorymetadata: name: primary namespace: kopiur-tryitspec: backend: filesystem: path: /repo volume: pvc: name: kopia-repo encryption: passwordSecretRef: name: repo-creds key: KOPIA_PASSWORD create: enabled: trueFill in the single REPLACE_ME (KOPIA_PASSWORD) and apply once:
$ kubectl apply -f deploy/examples/tryit/maintenance.yaml$ kubectl -n kopiur-tryit wait --for=condition=Ready repository/primary --timeout=2m1. The managed Maintenance appears on its own. You wrote none. The operator created primary, named after the repository, with the default quick-6h and full-daily schedule:
$ kubectl -n kopiur-tryit get maintenanceNAME REPOSITORY OWNER AGEprimary primary kopiur/kopiur-tryit/primary 20s2. Request a full run NOW. Stamp the two on-demand annotations. Use --overwrite because the timestamp changes each time:
$ kubectl annotate maintenance primary -n kopiur-tryit --overwrite \ kopiur.home-operations.com/run-requested="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ kopiur.home-operations.com/run-mode=full3. Prove the run completed (deep). The outcome lands in status.manualRun:
$ kubectl -n kopiur-tryit wait \ --for=jsonpath='{.status.manualRun.phase}'=Succeeded \ maintenance/primary --timeout=5m$ kubectl -n kopiur-tryit get maintenance primary \ -o jsonpath='{.status.manualRun}'{"requestedAt":"2026-06-17T14:05:00Z","mode":"full","phase":"Succeeded","completedAt":"2026-06-17T14:05:42Z"}(Illustrative timestamps.)
4. Confirm the full clock advanced. A full run also stamps status.full.lastRunAt:
$ kubectl -n kopiur-tryit get maintenance primary \ -o jsonpath='{.status.full.lastRunAt}'2026-06-17T14:05:42Z # illustrativeTo tear down: kubectl delete namespace kopiur-tryit.
1. Tune it inline on the repository
Section titled “1. Tune it inline on the repository”Set spec.maintenance on the Repository or ClusterRepository to override the schedule, or other settings, while keeping it operator-managed:
# Maintenance — tuned inline on the Repository (docs/src/content/docs/maintenance.mdx)## The simplest way to control maintenance: set `spec.maintenance` on the# Repository/ClusterRepository itself. Maintenance stays operator-managed (the# controller projects and owns a Maintenance for you), you just override the# schedule (or other knobs) instead of authoring a standalone Maintenance.## This shows a filesystem (PVC-backed) repository, but the `spec.maintenance`# block is identical for every backend. Omit the block entirely and you get the# defaults (quick every 6h, full daily at 03:00); set `enabled: false` to opt out.## Field shapes verified against crates/api: externally-tagged backend# (`backend.filesystem`); `schedule.quick`/`schedule.full` are CronSpec# { cron, jitter }.---apiVersion: v1kind: Secretmetadata: name: nas-primary-kopia namespace: billingtype: OpaquestringData: # Filesystem backends need ONLY the repository encryption password. KOPIA_PASSWORD: "choose-something-long-and-random"---apiVersion: kopiur.home-operations.com/v1alpha1kind: Repositorymetadata: name: nas-primary namespace: billingspec: backend: filesystem: path: /repo # mount path INSIDE the mover pod volume: pvc: name: nas-primary # the PVC mounted read-write at `path` encryption: passwordSecretRef: name: nas-primary-kopia key: KOPIA_PASSWORD # Tune maintenance while keeping it operator-managed. maintenance: enabled: true schedule: quick: { cron: "0 */6 * * *", jitter: 30m } # cheap, frequent: index compaction full: { cron: "0 3 * * *", jitter: 1h } # heavy: GC + rewrite — reclaims storagespec.maintenance fields:
| Field | Purpose |
|---|---|
enabled |
Default true. Set false to opt out (see Disabling). |
schedule |
Override quick/full cron + jitter (and timezone). |
mover |
Pod overrides for the maintenance Job (resources, scheduling, security context). |
failurePolicy |
backoffLimit / activeDeadlineSeconds for the Job. |
takeoverPolicy |
Ownership-lease policy (see Ownership). |
namespace |
ClusterRepository only: which namespace the managed Maintenance lives in (defaults to the operator’s namespace). |
2. Author a standalone Maintenance
Section titled “2. Author a standalone Maintenance”For fine-grained control, such as a custom ownership identity or takeover policy, write a Maintenance directly (example 08). When one references a repository, the operator defers to it and never creates a duplicate, even if spec.maintenance is otherwise default-on.
apiVersion: kopiur.home-operations.com/v1alpha1kind: Maintenancemetadata: name: nas-primary-maintenance namespace: billing annotations: # Request an immediate FULL run on next apply. Bump the timestamp to request # another; remove these to stop requesting on-demand runs (the crons keep # firing regardless). kopiur.home-operations.com/run-requested: "2026-01-01T00:00:00Z" kopiur.home-operations.com/run-mode: fullspec: repository: kind: Repository name: nas-primary schedule: quick: cron: "0 */6 * * *" jitter: 30m full: cron: "0 3 * * 0" jitter: 1h timezone: UTC ownership: owner: "kopia-operator/nas-primary" takeoverPolicy: PromptCondition mover: resources: requests: cpu: 250m memory: 1Gi limits: cpu: "2" memory: 4Gi failurePolicy: backoffLimit: 1 activeDeadlineSeconds: 14400 # cap a RUNNING maintenance Job (4h) podStartupDeadlineSeconds: 300 # fail fast if the maintenance mover can't START in 5m (default 300)Disabling maintenance
Section titled “Disabling maintenance”Set spec.maintenance.enabled: false on the repository. The operator stops managing a Maintenance for it.
Ownership and shared repositories
Section titled “Ownership and shared repositories”kopia tracks a single maintenance owner per repository. When several clusters, or several operators, share one repository, only one should run maintenance at a time. spec.ownership encodes who that is and what to do on conflict:
owneris a stable identity string for thisMaintenance. The operator derives it for a managedMaintenance; see the lease format below.ownerAliaseslists previous lease strings still recognized as self. It is the migration path when the lease format changes, described below.takeoverPolicyis a closed set of three values:
| Policy | Behavior when another owner holds the lease |
|---|---|
Never (default, safest) |
Do nothing; surface that the lease is held elsewhere and wait. |
PromptCondition |
Set a condition asking an operator to decide; don’t seize it. |
Force |
Forcibly claim the lease and run. |
The lease is read inside the maintenance Job, which is the only place with repository access for object stores. If the policy declines to take over, the run finishes successfully having done nothing, and records why on the resource’s conditions.
The lease string and kopia’s recorded owner
Section titled “The lease string and kopia’s recorded owner”Every default-managed Maintenance derives its lease string (spec.ownership.owner) from the repository it covers. The format depends on whether the repository has identityDefaults.cluster set. A repository shared across clusters needs a distinct lease per cluster, or every cluster would derive the identical lease and fight over it:
| Repository kind | Cluster identity | Lease string (spec.ownership.owner) |
kopia’s recorded owner (user@hostname) |
|---|---|---|---|
Repository |
unset | kopiur/<namespace>/<name> |
kopiur@kopiur-<namespace>-<name> |
Repository |
east |
kopiur/east/<namespace>/<name> |
kopiur@kopiur.east.<namespace>.<name> |
ClusterRepository |
unset | kopiur/clusterrepository/<name> |
kopiur@kopiur-clusterrepository-<name> |
ClusterRepository |
east |
kopiur/east/clusterrepository/<name> |
kopiur@kopiur.east.clusterrepository.<name> |
ownerAliases — carrying ownership across a lease-format change
Section titled “ownerAliases — carrying ownership across a lease-format change”spec.ownership.ownerAliases lists previous lease strings kopiur should still recognize as itself. This is the migration path for turning on identityDefaults.cluster for the first time on a repository that already has a managed Maintenance. The operator automatically records the pre-cluster lease as an alias on every managed Maintenance for a cluster-identified repository. A run then recognizes kopia’s already-recorded owner as its own and claims and re-stamps it to the new cluster-qualified format, instead of yielding to what would otherwise look like a foreign owner. You don’t set this by hand for a managed Maintenance. Only a standalone one needs it written out explicitly, if you hand-roll the same migration.
Sharing one repository’s maintenance across clusters: pick ONE owner
Section titled “Sharing one repository’s maintenance across clusters: pick ONE owner”kopia has no cross-host lock beyond this lease. So when several clusters’ Repository or ClusterRepository objects all point at the same physical repository, each cluster’s independently-managed Maintenance tries to claim the same lease. The default, takeoverPolicy: Never, makes that safe, because every cluster but the current holder yields. It still isn’t the recommended steady state once you know which cluster should own it:
- Pick the one cluster that runs maintenance for the shared repository. kopia doesn’t care which.
- On every other cluster, set
spec.maintenance.enabled: falseon that repository. The operator then stops creating and reconciling aMaintenancethere at all, instead of running one that exists only to yield forever. - Remove
takeoverPolicy: Forcefrom every cluster except the owner.Forceunconditionally seizes the lease on its next run. Leave it set on more than one cluster and they fight over it every reconcile, each re-seizing it from the other. SetForceonly on the one cluster you intend to own it, and only long enough to claim the lease once, then revert it toNever.
Yielding is the safety net, not the recommended posture. A non-owner cluster left with maintenance enabled just yields loudly, showing Ready=False with reason MaintenanceYielding, rather than fighting for the lease or touching data. But that loud yielding, plus a mover Job that runs for nothing every cron slot, is exactly why the explicit enabled: false posture above is preferred once you know the owner. See Share one repository across clusters for the full walkthrough, with this step placed in the correct order relative to the identity flip.
Self-healing a stale owner
Section titled “Self-healing a stale owner”kopia stamps a maintenance owner when a repository is created. kopiur stamps its own stable, lease-derived owner, per the table above, so that every maintenance Job, each running from a fresh throwaway pod, recognizes itself as the owner and runs. On every bootstrap, meaning the initial connect and each catalog refresh, if the recorded owner doesn’t match, kopiur may re-stamp it. kopia maintenance set --owner is not owner-gated. How readily it re-stamps depends on whether the repository has a cluster identity:
- No
identityDefaults.cluster, a single-cluster repository. Self-healing is unchanged from before multi-cluster support: any stale owner is re-stamped unconditionally. That is safe, because at most one cluster’s operator ever bootstraps this repository. A stale owner can only be kopia’s own short-lived create-time identity, such asnonroot@nas-bootstrap-5trlr, or an older-format stamp from this same operator. It is never another cluster’s. identityDefaults.clusterset, a shared repository. Self-heal re-stamps only an owner that is empty, already the desired owner, or matches a recognizedownerAliasesentry. That is, only this cluster’s own current or legacy formats. A foreign cluster’s owner is always honored and left completely alone. Under the unconditional rule, every cluster sharing the repository would see the other’s owner as “stale” and re-claim it on its own next bootstrap, bouncing the lease back and forth forever.- The tradeoff: an ancient owner this operator has never recognized before is left alone rather than clobbered automatically, even though it may genuinely be stale. That covers a workstation
kopiaCLI, or any owner string that isn’t this cluster’s current lease or a registered alias. Move it with a one-timespec.ownership.takeoverPolicy: Force, or withkopia maintenance set --ownerby hand. Once claimed, this operator’s own lease-derived owner is recognized on every subsequent run, so no further manual steps are needed. RevertForcetoNeverright after; see the “pick ONE owner” section above for why leaving it set is unsafe on a shared repository.
Index-blob health
Section titled “Index-blob health”kopia stores its content index as a set of index blobs. Each backup adds one, and maintenance compacts them back down. So in a healthy repository the count rises during the day and falls after the next full-maintenance run. If maintenance stops keeping up, most often because of a stale owner as described above, but also because of a disabled or failing Maintenance, the count climbs without bound. Once it gets high enough, kopia warns “Found too many index blobs (N)” and backup and restore performance degrades.
kopiur observes the count at bootstrap and after each successful maintenance run — the newer of the two observations wins — and surfaces it three ways, without blocking the repository. The repository stays Ready; this is a degradation warning, not an outage:
- a print column:
kubectl get repositoryandkubectl get clusterrepositoryshow anIndexBlobscolumn in wide output; status.storageStats.indexBlobCount;- when the count crosses the threshold, an
IndexBlobHealth=Falsecondition with reasonTooManyIndexBlobs, and a Kubernetes Warning event with the remediation in its message:
$ kubectl describe clusterrepository nas-shared | grep -A2 TooManyIndexBlobs$ kubectl get events --field-selector reason=TooManyIndexBlobs -AThe threshold knob
Section titled “The threshold knob”spec.health.indexBlobWarnThreshold sets the count above which the warning fires. It’s optional on both Repository and ClusterRepository:
| Value | Meaning |
|---|---|
| absent | Use the built-in default of 1000 (well above a healthy repo). |
| a positive number | Warn when the count exceeds it (lower = earlier warning). |
0 |
Disable the warning entirely. |
spec: health: indexBlobWarnThreshold: 500 # warn sooner than the default 1000When maintenance is running and the count still won’t fall
Section titled “When maintenance is running and the count still won’t fall”Raising the threshold hides the warning. It doesn’t compact anything, it has no effect on kopia’s own hard-coded “Found too many index blobs” message, and it doesn’t change how long your movers take to connect. If maintenance is demonstrably running and the count is still stuck in the thousands, the cause is usually kopia’s epoch-advance gate, and no maintenance schedule can fix it.
kopia cannot compact an index blob until its epoch closes. An epoch may only close once it is older than MinEpochDuration, which is 24h by default, no matter how many blobs accumulate inside it. Compaction then trails two epochs behind. So a fleet producing, say, around 60 index blobs an hour is forced to about 1700 blobs before an epoch may close, and permanently carries 1700 to 3400 uncompacted blobs. Compaction pace was never the bottleneck. The 24h floor is.
spec.parameters.epoch exposes that gate on both Repository and ClusterRepository:
spec: parameters: epoch: minDuration: 6h # kopia's default is 24hEvery field is optional and kopiur has no defaults of its own. Absent means “leave kopia’s current value alone”, so a repository that declares nothing here is completely unaffected. Applying a change rewrites the repository’s format blob, which invalidates other kopia clients’ cached copy of it. They re-read within about 15 minutes. Because of that, kopiur calls set-parameters only when the declared values actually differ from what the repository reports.
Check what landed under status.parameters.epoch. It reports what the repository says, not what you asked for, so a value that failed to apply shows up as a mismatch rather than as silence:
$ kubectl get repository primary -o jsonpath='{.status.parameters.epoch}' | jq{ "enabled": true, "minDuration": "6h", "refreshFrequency": "20m", "cleanupSafetyMargin": "4h", "advanceOnCount": 20, "advanceOnSizeMiB": 10, "checkpointFrequency": 7, "deleteParallelism": 4}The full field set, plus the sharp edges:
# Example 33 — tune kopia's epoch parameters (spec.parameters.epoch)## THE SYMPTOM: `IndexBlobHealth=False` / `TooManyIndexBlobs`, thousands of index# blobs that never go down however often maintenance runs, and mover connects that# take tens of seconds because every one of them reads those blobs.## THE CAUSE, if maintenance is demonstrably running: kopia cannot compact an index# blob until its EPOCH closes. An epoch may only close once it is older than# MinEpochDuration — 24h by default — no matter how many blobs accumulate inside it.# Compaction then trails two epochs behind. So a fleet producing ~60 index blobs an# hour is held at ~1700 blobs per epoch and carries 1700–3400 uncompacted blobs# permanently. No maintenance schedule can fix that: compaction pace is not the# bottleneck, the 24h advance gate is.## THE FIX: lower minDuration so epochs close (and blobs become compactable) sooner.# 6h is a reasonable starting point for a busy repository.## WHY THIS IS A CR FIELD: `kopia repository set-parameters --epoch-min-duration=6h`# run by hand works — the value persists in the format blob and movers pick it up on# connect — but it is invisible to GitOps, survives in no manifest, and a repository# re-bootstrap silently reverts it. Declared here, kopiur re-applies it whenever it# drifts.## ABSENT MEANS "DON'T TOUCH". Every field below is optional and kopiur has no# defaults of its own: omit one and kopia's current value is left exactly as it is.# The flip side is that REMOVING a value you previously set does not restore kopia's# default — it leaves the repository at whatever you last applied. Set it back# explicitly if you want the default again.## NOTE: applying these rewrites the repository's format blob, which invalidates every# other kopia client's cached copy (they re-read within ~15 minutes on their own).# kopiur therefore only calls set-parameters when the declared values actually differ# from what the repository reports — a converged spec costs nothing.## NOT AVAILABLE on a `mode: ReadOnly` repository: set-parameters is a repository-wide# write and kopia refuses it on a read-only connection, so declaring it there is# rejected at admission. In a multi-cluster layout, declare the parameters on the# cluster that owns the repository — they are a property of the repository, not of# each consumer.## See docs/src/content/docs/repository-health.mdx---apiVersion: kopiur.home-operations.com/v1alpha1kind: Repositorymetadata: name: primary namespace: backupsspec: backend: s3: bucket: kopia-backups endpoint: s3.example.com region: us-east-1 auth: secretRef: name: s3-creds encryption: passwordSecretRef: name: kopia-repo key: KOPIA_PASSWORD parameters: epoch: # The gate. kopia's default is 24h; an epoch cannot close before this, # regardless of how many index blobs it holds. minDuration: 6h # The rest are shown for reference — omit them unless you have a reason. # Each comment is kopia's default, which is what you get by leaving it out. # # refreshFrequency: 20m # how often clients re-read epoch state # advanceOnCount: 20 # index blobs that trigger an advance (once past minDuration) # advanceOnSizeMiB: 10 # index size that triggers an advance (MEBIbytes: 10 = 10485760 bytes, # # despite kopia's own log rendering it as "10 MB") # checkpointFrequency: 7 # epochs between full index checkpoints # deleteParallelism: 4 # parallelism for epoch cleanup deletions # Observe the result under `status.parameters.epoch`: # # kubectl get repository primary -n backups -o jsonpath='{.status.parameters.epoch}' # # It reports what the REPOSITORY says, not what you declared, so a value that # failed to apply shows up as a mismatch rather than as silence. # # `cleanupSafetyMargin` appears there too but is deliberately not settable: it is # the grace window that stops kopia deleting index blobs a concurrent writer still # needs, and there is no safe generic advice for lowering it.Three things worth knowing:
- Removing a value does not restore kopia’s default. Under “absent means don’t touch”, deleting
minDurationfrom your manifest leaves the repository at 6h forever. Set it back explicitly if you want 24h again. mode: ReadOnlyrepositories cannot declare parameters.set-parametersis a repository-wide write and kopia refuses it on a read-only connection, so the combination is rejected at admission. In a multi-cluster layout, declare parameters on the cluster that owns the repository. They describe the repository, not each consumer. Two clusters declaring different values for one repository will fight over it.cleanupSafetyMarginis reported but not settable. It is the grace window that stops kopia deleting index blobs a concurrent writer still needs, and there is no safe generic advice for lowering it.
spec.parameters has a second key, blobRetention, which turns on S3, Azure and GCS object lock so a compromised credential cannot delete your backups. It rides the same set-parameters call as epoch, one invocation applied only on drift, and it changes what maintenance can reclaim. Deletes are still issued, but the storage layer refuses them until locks expire, so the repository grows for at least the retention period. See Object lock.
Running maintenance on demand
Section titled “Running maintenance on demand”Maintenance normally fires on its quick and full crons. You can request an out-of-band run at any time by stamping two annotations on the Maintenance. The operator routes it through the same mover, ownership-lease and one-at-a-time path as the scheduled slots, so a manual run can never break the one-job-per-repository rule.
Declaratively, which is GitOps-friendly, set them in Git on the managed Maintenance, as in example 08:
metadata: annotations: # A NEW value requests a new run; re-applying the same value is a no-op. kopiur.home-operations.com/run-requested: "2026-01-01T00:00:00Z" kopiur.home-operations.com/run-mode: full # quick (default when absent) | fullrun-requestedis an RFC3339 timestamp. A new timestamp requests a new run; re-applying the same value does nothing once that request was handled.run-modeisquick, the default when absent, orfull.
The outcome lands in status.manualRun, which carries requestedAt, mode, phase (Running, Succeeded or Failed), and completedAt. The kubectl plugin wraps this as kubectl kopiur maintenance run [NAME | --repository NAME] [--full] [--wait].
Inspecting status
Section titled “Inspecting status”$ kubectl get maintenance nas-primary -n billing -o yamlKey status fields:
| Field | Meaning |
|---|---|
ownership.owner / ownership.claimedAt |
Current lease holder and when it was claimed. |
quick.lastRunAt / full.lastRunAt |
Timestamp of the most recent run of each pass. |
quick.lastHandledAt / full.lastHandledAt |
The most recent cron slot whose Job finished, including a yield (which doesn’t move lastRunAt), so a handled slot never re-fires. |
quick.lastContentReclaimedBytes / full.lastContentReclaimedBytes |
Storage reclaimed. The only place this is surfaced. |
conditions[type=LeaseOwned] |
True when this resource holds the lease and is running; False (with a reason) when waiting on the repository, a held lease, or a failure. |
The running mover Jobs are labeled, so you can watch them directly:
$ kubectl get jobs -n billing -l app.kubernetes.io/component=maintenanceBehavior you can rely on
Section titled “Behavior you can rely on”- Runs at the scheduled time. Spawning is gated on the same cron and jitter logic as
SnapshotSchedule, seeded deterministically per resource, so two replicas agree and the run lands in its window. It is not “every reconcile”. - Waits for the repository, but only when maintenance can’t help. Maintenance runs once the target repository has ever bootstrapped, and keeps running even while the repository is
Degraded. It only defers when the backend is confirmed unreachable or the repository vanished (BackendReachable=Falsewith reasonBackendUnreachableorRepositoryVanished), when the repository has never bootstrapped, or when it is in a terminalFailedstate. A repository that isDegradedonly because its connects keep exceeding the bootstrap deadline (ProbeDeadlineExceeded) still gets maintenance: index compaction is usually the cure for a slow connect, so withholding it would deadlock the repository (#413). While deferred, the resource showsLeaseOwned=False, reason=WaitingForRepository. - One run at a time. The operator never starts a second maintenance Job for a repository while one is in flight.
- Catches up after downtime, once. If the operator is down across several scheduled slots, it runs a single catch-up pass on recovery, not a storm of missed runs.
- Self-cleaning Jobs. Finished maintenance Jobs are removed automatically via
ttlSecondsAfterFinished, and a failed run is retried with backoff. - A handled slot never re-fires. Each scheduled slot runs once. Its outcome, either a real run or a deliberate yield to a foreign lease holder, is recorded durably in
status.<quick|full>.lastHandledAt, so the Job self-cleanup above cannot make the same slot run again. Only a failed slot is retried. - Yielding is loud, not silent. When every run yields, meaning a foreign owner holds the lease and
takeoverPolicyisNever, kopia garbage collection and compaction are not happening. The resource reportsReady=Falsewith reasonMaintenanceYieldingand thetakeoverPolicy: Forceremediation in the message, instead of a misleadingReady=True.
See also
Section titled “See also”deploy/examples/08-maintenance.yaml: a standaloneMaintenance.deploy/examples/01-single-pvc-scheduled.yaml: inlinespec.maintenance.- Share one repository across clusters: the full multi-cluster walkthrough, including the maintenance-ownership steps above in their correct order.
- Troubleshooting → Maintenance isn’t running:
LeaseHeldByOtheron a shared repository, expected vs. stale.