Skip to content

Commit 338bc64

Browse files
committed
fix(qdrant-backup-restore): retention data-loss, fail-closed manifests, staging isolation
1 parent fb1b342 commit 338bc64

11 files changed

Lines changed: 770 additions & 1099 deletions

‎qdrant-backup-restore/README.md‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ This directory provides production-grade scripts to back up Qdrant snapshots and
88
a newer one via Homebrew for local dev/testing. The `pharia-helper` container image ships
99
Bash 5.x.)
1010
- **curl**
11-
- **jq** (>= 1.6 — the per-shard manifest/config-mapping jq filters use features not present in older jq;
11+
- **jq** (>= 1.7 — REQUIRED and gated at per-shard task entry: jq 1.6 parses 64-bit peer ids
12+
as IEEE doubles and rounds above 2^53, so every peer lookup fails against real clusters;
1213
1.7+ is recommended for precise handling of large integers such as peer ids. The
1314
`pharia-helper` image ships jq 1.8.x.)
1415
- **mc** (MinIO Client)
@@ -386,16 +387,14 @@ manifest's `backup_set_id` only.
386387

387388
### Per-shard restore peer discovery
388389

389-
**One target cluster per run:** only the FIRST `QDRANT_RESTORE_HOSTS` entry's cluster is
390-
restored to; extra entries are ignored with a WARNING naming the count. Run once per
391-
cluster (legacy `recover_snap`, by contrast, fanned out to every listed host).
390+
**One target cluster per run:** only the FIRST `QDRANT_RESTORE_HOSTS` entry is restored
391+
to (extras warned and ignored) — run once per cluster. See RUNBOOK.md, Restore procedure.
392392

393393
`recover_snap_shards` always discovers the **target** cluster's peers via
394394
`QDRANT_RESTORE_HOSTS`, unconditionally — it does not consult `GET_PEERS_FROM_CLUSTER_INFO` at
395395
all (that variable only still affects the legacy dispatch path). If you deploy this on
396-
Kubernetes, `k8s/restore-job.yaml` sets `GET_PEERS_FROM_CLUSTER_INFO=true` for exactly this
397-
reason; see that file's comment if you also run the legacy `recover_snap` task from the same
398-
manifest.
396+
Kubernetes, use `k8s/restore-per-shard-job.yaml` (the self-consistent per-shard restore
397+
manifest); `k8s/restore-job.yaml` stays fully legacy.
399398

400399
## Common Workflows
401400

‎qdrant-backup-restore/RUNBOOK.md‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,9 @@ This becomes the **quarterly canary** thereafter — do not treat it as a one-ti
159159

160160
### Step 4 — CronJob cutover
161161

162-
Only after Steps 1–3 have passed. This repo's `k8s/backup-cronjob.yaml` already carries the env
163-
and Job-policy changes staged (S3 credentials, `MC_CONFIG_DIR`, `backoffLimit: 0`,
164-
`activeDeadlineSeconds`, `restartPolicy: Never`) — the cutover itself is the one-line command
165-
flip:
162+
Only after Steps 1–3 have passed. This repo's `k8s/backup-cronjob.yaml` carries the per-shard
163+
ENV staged (S3 credentials, `MC_CONFIG_DIR`) but deliberately keeps the LEGACY Job policy —
164+
the cutover is the command flip PLUS the three Job-policy values, applied together:
166165

167166
```diff
168167
args:
@@ -171,6 +170,13 @@ flip:
171170
+ - "cd scripts && ./qdrant_backup_recovery.sh create_snap_shards"
172171
```
173172

173+
and in the same edit set `backoffLimit: 0`, `restartPolicy: Never`, and
174+
`activeDeadlineSeconds: 7200`. Why together: per-shard exits nonzero on any partial failure
175+
while still pruning the collections that succeeded, so a retrying Job (`backoffLimit > 0` or
176+
`OnFailure`) can create+prune several sets back-to-back within ONE firing and collapse
177+
retention history to near-duplicates minutes apart; conversely those values must never apply
178+
while the legacy command still runs (legacy relies on retries and has no runtime ceiling).
179+
174180
**Pre-flight gate — before touching anything, confirm the deployed secret is ready:**
175181

176182
```bash
@@ -184,8 +190,8 @@ deployment.** This file carries repo placeholders — `QDRANT_SOURCE_HOSTS:
184190
URL), and the secret name `qdrant-credentials` — and `apply` replaces the entire `env:` list, so
185191
a site that customized its headless-service name or S3 endpoint would have those values
186192
**reverted to placeholders**, and the first per-shard firing would fail at `mc alias set`. **Port the
187-
command flip above, together with the env and Job-policy additions already staged in this
188-
file, into your site's own deployed manifest or values overlay, then apply *that*.**
193+
command flip + Job-policy trio above, together with the env additions staged in this file,
194+
into your site's own deployed manifest or values overlay, then apply *that*.**
189195

190196
This single swap also activates automatic retention (`create_snap_shards` runs `prune_snap`'s
191197
retention pass on every successful run) — there is no separate retention deploy step.
@@ -241,16 +247,15 @@ any flag:**
241247
| Exists, empty, **or** non-empty with resume history matching THIS exact set id | Resumes — creation skipped, only shards not yet recovered are recovered. | None. |
242248
| Exists, non-empty, no matching history for this set (or config-incompatible) | Aborts. | `QDRANT_RESTORE_FORCE=true` — destructive: deletes and recreates the collection. |
243249

244-
**Launching a restore in Kubernetes — flip BOTH together, or neither.** The shipped
245-
`k8s/restore-job.yaml`'s `args` is still legacy `recover_snap` while its env is staged for per-shard.
246-
Running legacy `recover_snap` as shipped will exit immediately at dispatch —
247-
`GET_PEERS_FROM_CLUSTER_INFO=true` combined with `QDRANT_SOURCE_HOSTS=""` leaves legacy peer
248-
discovery with no host to query (see the manifest's own comment). To actually run
249-
`recover_snap_shards`:
250+
**Launching a restore in Kubernetes.** The per-shard restore ships as its own
251+
internally-consistent manifest — no field flipping required:
252+
253+
```bash
254+
kubectl -n <namespace> create -f k8s/restore-per-shard-job.yaml
255+
```
250256

251-
1. Set `args` to run `recover_snap_shards` instead of `recover_snap`.
252-
2. Confirm `GET_PEERS_FROM_CLUSTER_INFO=true` (already the case in the shipped file).
253-
3. `kubectl -n <namespace> create -f k8s/restore-job.yaml`
257+
(`k8s/restore-job.yaml` remains the fully-legacy `recover_snap` manifest for legacy-era
258+
snapshots; the two are independent and each runs its own default command as shipped.)
254259

255260
**Restore requirements — per-shard restore has no static-hosts mode.** `recover_snap_shards`
256261
hard-requires (a) a **cluster-mode** target whose `GET /cluster` peer discovery works on the

‎qdrant-backup-restore/k8s/README.md‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,9 @@ documented in the main [README.md](../README.md#per-shard-backup--restore); the
265265
sequence (day-0 unblock, restore procedure, verification-failure remediation, transition rules)
266266
is in [RUNBOOK.md](../RUNBOOK.md).
267267

268-
**Cutover status:** `backup-cronjob.yaml` and `restore-job.yaml` ship with the per-shard env vars and
269-
Job-policy fields already in place (S3 credentials, `MC_CONFIG_DIR`, `backoffLimit: 0`,
270-
`activeDeadlineSeconds`, `restartPolicy: Never`, and — on the restore side —
271-
`GET_PEERS_FROM_CLUSTER_INFO=true`), but the `args:` command in both is still the legacy task.
272-
Flipping `create_snap` → `create_snap_shards` (and, when ready, `recover_snap` →
273-
`recover_snap_shards`) is a deliberate, gated step — see RUNBOOK.md — not something that
274-
happens just because the env is staged for it.
268+
**Cutover status:** `backup-cronjob.yaml` stages per-shard env but keeps legacy args and
269+
Job policy — the cutover flip applies both together (RUNBOOK.md Step 4). The per-shard
270+
restore is its own manifest, `restore-per-shard-job.yaml`; `restore-job.yaml` stays legacy.
275271

276272
### Per-shard environment variables
277273

‎qdrant-backup-restore/k8s/backup-cronjob.yaml‎

Lines changed: 11 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,8 @@ metadata:
55
labels:
66
app: qdrant-backup
77
spec:
8-
# Cron schedule (min hour day month weekday)
9-
# "0 0 */7 * 0" restricts BOTH day-of-month and day-of-week — cron ORs them when both are
10-
# restricted, so this actually fires at 00:00 on the 1st/8th/15th/22nd/29th AND every Sunday
11-
# (~9-10x/month), NOT weekly (the schedule VALUE itself is intentionally
12-
# unchanged — changing it is deferred to the cutover decision). For a plain weekly schedule, use
13-
# "0 0 * * 0" instead (day-of-month unrestricted, fires only on Sunday).
8+
# NOTE: cron ORs restricted day-of-month AND day-of-week, so this fires
9+
# ~9-10x/month, not weekly ("0 0 * * 0" would be plain weekly); value kept.
1410
schedule: "0 0 */7 * 0"
1511

1612
# Optional: prevent overlapping jobs
@@ -27,51 +23,22 @@ spec:
2723
failedJobsHistoryLimit: 3
2824
jobTemplate:
2925
spec:
30-
# backoffLimit MUST stay 0 once this CronJob runs create_snap_shards (per-shard):
31-
# create_snap_shards exits nonzero on ANY partial-collection failure but
32-
# still runs its automatic retention pass on the collections that DID
33-
# succeed. With backoffLimit > 0, the Job controller retries by
34-
# spawning fresh pods within the SAME scheduled firing — each retry
35-
# creates another backup set and prunes again, so a single flaky
36-
# firing can create+prune several sets back-to-back and collapse
37-
# QDRANT_BACKUP_RETENTION_SETS worth of history down to near-duplicate
38-
# sets from minutes apart instead of separate nights (found in review).
39-
# 0 means: one attempt, ever; a failure is reported, not silently
40-
# retried into a retention-collapsing loop.
41-
backoffLimit: 0
42-
# Hard ceiling on how long one firing may run, independent of
43-
# CURL_TIMEOUT (below): CURL_TIMEOUT bounds a SINGLE curl call and is
44-
# retried up to ~3x per shard on timeout/`accepted`, so a
45-
# hung peer can consume a multiple of CURL_TIMEOUT before that shard's
46-
# collection is abandoned; activeDeadlineSeconds is the outer backstop
47-
# so a genuinely stuck run is killed rather than blocking the next
48-
# scheduled firing indefinitely (concurrencyPolicy: Forbid above).
49-
activeDeadlineSeconds: 7200
26+
# legacy values — at per-shard cutover set backoffLimit 0 / restartPolicy Never / activeDeadlineSeconds 7200 (RUNBOOK Step 4)
27+
backoffLimit: 3
5028
template:
5129
metadata:
5230
labels:
5331
app: qdrant-backup
5432
spec:
55-
# Never (not OnFailure): OnFailure lets the kubelet respawn the
56-
# SAME container in place on failure, which is exactly the kind of
57-
# extra attempt backoffLimit: 0 above is meant to rule out for this
58-
# job. Matches backup-job.yaml/restore-job.yaml, which already pair
59-
# backoffLimit: 0 with restartPolicy: Never for the same reason.
60-
restartPolicy: Never
33+
restartPolicy: OnFailure
6134
containers:
6235
- name: qdrant-backup
6336
image: ghcr.io/aleph-alpha/shared-images/pharia-helper:latest
6437
imagePullPolicy: IfNotPresent
6538
securityContext:
6639
readOnlyRootFilesystem: true
67-
# Gated cutover (runbook Day-0 sequence): this still
68-
# runs the legacy per-node `create_snap` task. The per-shard
69-
# task is `create_snap_shards` (adds manifest + automatic
70-
# retention). Flipping the command below to
71-
# `create_snap_shards` is the actual cutover step — do NOT
72-
# flip it in this commit; it is gated on the rollout gates
73-
# in RUNBOOK.md (day-0 legacy prune -> shadow run ->
74-
# restore canary -> THEN this flip).
40+
# still LEGACY create_snap — the flip to create_snap_shards is the
41+
# gated cutover step (RUNBOOK Day-0 sequence)
7542
command:
7643
- /bin/bash
7744
args:
@@ -91,24 +58,9 @@ spec:
9158
value: "true"
9259
- name: CURL_TIMEOUT
9360
value: "3000" # increase if more time is needed for a single backup
94-
# S3 credentials: needed once the command above is cut over to
95-
# create_snap_shards (manifest upload, `mc stat` verification,
96-
# prune_snap). Staging these now is what makes the
97-
# cutover a pure command-string change (see the args comment
98-
# above) instead of a second env-adding deploy.
99-
#
100-
# DELIBERATE DEVIATION:
101-
# the three refs below are `optional: true`. This env is staged
102-
# for per-shard while `args` above still runs LEGACY create_snap, which
103-
# never needed these keys — a deployed `qdrant-credentials`
104-
# secret that does not yet carry them must not
105-
# CreateContainerConfigError the still-running legacy backup.
106-
# The Step 4 pre-flight secret gate + Step 2 shadow run
107-
# (RUNBOOK.md) catch missing creds before cutover to
108-
# create_snap_shards, which DOES need them non-optionally at
109-
# that point. QDRANT_API_KEY above keeps its required
110-
# (non-optional) semantics — it was already required by the
111-
# legacy task this CronJob still runs.
61+
# S3 creds staged for the per-shard cutover; optional: true so a
62+
# secret without them cannot break the still-legacy backup (the
63+
# RUNBOOK Step-4 gate verifies them before the flip)
11264
- name: QDRANT_S3_ACCESS_KEY_ID
11365
valueFrom:
11466
secretKeyRef:
@@ -144,14 +96,7 @@ spec:
14496
subPath: qdrant_backup_recovery.sh
14597
- name: scripts
14698
mountPath: /scripts
147-
# Defense-in-depth alongside the script's own TMPDIR default
148-
# (TMPDIR defaults to $PWD == /scripts, already writable via
149-
# the "scripts" emptyDir above): readOnlyRootFilesystem means a
150-
# bare /tmp write from `mc`, curl, or any tool that doesn't go
151-
# through the script's TMPDIR-aware mktemp calls would
152-
# otherwise fail hard (empirically confirmed: "mktemp: :
153-
# Read-only file system" with no /tmp mount under this
154-
# securityContext).
99+
# under readOnlyRootFilesystem, tools writing bare /tmp need this mount
155100
- name: tmp
156101
mountPath: /tmp
157102
volumes:

‎qdrant-backup-restore/k8s/backup-job.yaml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,22 @@ spec:
2929
- name: QDRANT_S3_ACCESS_KEY_ID
3030
valueFrom:
3131
secretKeyRef:
32-
name: qdrant-credentials-minio # change this to your qdrant kubernetes secret name
32+
name: qdrant-credentials # change this to your qdrant kubernetes secret name
3333
key: QDRANT_S3_ACCESS_KEY_ID
3434
- name: QDRANT_S3_SECRET_ACCESS_KEY
3535
valueFrom:
3636
secretKeyRef:
37-
name: qdrant-credentials-minio # change this to your qdrant kubernetes secret name
37+
name: qdrant-credentials # change this to your qdrant kubernetes secret name
3838
key: QDRANT_S3_SECRET_ACCESS_KEY
3939
- name: QDRANT_S3_BUCKET_NAME
4040
valueFrom:
4141
secretKeyRef:
42-
name: qdrant-credentials-minio # change this to your qdrant kubernetes secret name
42+
name: qdrant-credentials # change this to your qdrant kubernetes secret name
4343
key: QDRANT_S3_BUCKET_NAME
4444
- name: QDRANT_API_KEY
4545
valueFrom:
4646
secretKeyRef:
47-
name: qdrant-credentials-minio # change this to your qdrant kubernetes secret name
47+
name: qdrant-credentials # change this to your qdrant kubernetes secret name
4848
key: QDRANT_API_KEY
4949
- name: QDRANT_S3_ENDPOINT_URL
5050
value: "http://minio.default.svc.cluster.local:9000" # change this to your s3 endpoint url

0 commit comments

Comments
 (0)