Merge pull request #696 from EdouardVanbelle/feat/thumbnails-on-backend-storage
This commit is contained in:
@@ -234,27 +234,38 @@ can be safely dropped.
|
||||
|
||||
---
|
||||
|
||||
## 4. Blob consistency (`blobs_consistency`)
|
||||
## 4. Blob consistency — two jobs, split by what they read
|
||||
|
||||
Read-only recoverable job that walks `storage.blobs` and reports
|
||||
divergence between the DB registry and the physical backend.
|
||||
The registry side and the physical side are separate tenants. They
|
||||
used to be one, with `blobs_consistency` probing the backend once per
|
||||
row; that probe found strictly less than the merge-join below, at N
|
||||
round-trips instead of one enumeration, so it was removed.
|
||||
|
||||
### Shallow mode (default)
|
||||
### `blobs_consistency` — database only
|
||||
|
||||
Per row:
|
||||
Walks `storage.blobs` and compares `ref_count` against the reference
|
||||
count computed from `storage.files.blob_hash` +
|
||||
`chunk_manifests.chunk_hashes[]`. On mismatch: `refcount_mismatch`
|
||||
(severity `inconsistent`), repairable under `?repair=true`.
|
||||
|
||||
- `blob_exists(hash)` on the active backend → if false, record
|
||||
`blob_missing_from_backend` (severity `data_loss`)
|
||||
- Compare `ref_count` against the actual reference count computed
|
||||
from `SUM` over `storage.files.blob_hash` + `chunk_manifests.chunk_hashes[]`
|
||||
→ if mismatch, record `refcount_mismatch` (severity `inconsistent`)
|
||||
It opens no backend and makes no network call. `?storage=<name>` and
|
||||
`?deep=true` are inert. Cost is one aggregate SQL per row.
|
||||
|
||||
Cost: one existence probe + one aggregate SQL per row. Fast on
|
||||
S3/Azure (single HEAD).
|
||||
### `backend_consistency` — everything physical
|
||||
|
||||
### Deep mode (`?deep=true`)
|
||||
Merge-joins the backend's enumeration against `storage.blobs`, both
|
||||
ordered by hash, yielding both deltas in one pass:
|
||||
|
||||
Adds a full read of every blob:
|
||||
- bytes with no registry row → `orphan_blob` (severity `inconsistent`)
|
||||
- a registry row with no bytes → `blob_missing_from_backend`
|
||||
(severity `data_loss`)
|
||||
|
||||
`?storage=<name>` scopes it to any declared entry rather than the live
|
||||
backend.
|
||||
|
||||
### Deep mode (`?deep=true`, on `backend_consistency`)
|
||||
|
||||
For every hash present on both sides, adds a full read:
|
||||
|
||||
- Stream the blob through `EncryptedBlobBackend::get_blob_stream`
|
||||
(strips header, decrypts if needed, applies BLAKE3 rescue for
|
||||
|
||||
@@ -100,12 +100,16 @@ This one-shot repair command re-runs the same env-parse the server does at boot,
|
||||
|
||||
### Auditing entries other than the active one
|
||||
|
||||
`blobs_consistency` and `backend_consistency` (recoverable jobs on the Jobs tab) accept `?storage=<name>` to probe any declared entry — not just the live one. Use this to verify a migration target before cutover, or to audit an old backend after cutover but before decommissioning:
|
||||
`backend_consistency` (a recoverable job on the Jobs tab) accepts `?storage=<name>` to audit any declared entry — not just the live one. Use this to verify a migration target before cutover, or to audit an old backend after cutover but before decommissioning:
|
||||
|
||||
```
|
||||
POST /api/admin/jobs/blobs_consistency/trigger?storage=<name>
|
||||
POST /api/admin/jobs/backend_consistency/trigger?storage=<name>
|
||||
```
|
||||
|
||||
Add `?deep=true` to also read every blob back and re-hash it, which catches silent bit-rot. That is a full read of the entry and can take hours.
|
||||
|
||||
`blobs_consistency` does *not* accept `?storage=<name>`: it only reads the database, so there is no entry for it to scope.
|
||||
|
||||
Unknown names 400 at the HTTP layer.
|
||||
|
||||
## Data Storage
|
||||
|
||||
@@ -18,6 +18,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator
|
||||
| `OXICLOUD_CHUNK_DIR` | `{STORAGE_PATH}/.uploads` | Root directory for chunked-upload sessions (REST + NextCloud). Direct (non-chunked) uploads stream straight into the blob store and need no spool directory. Placement guidance: see [Storage Fine Tuning](./storage-fine-tuning.md). |
|
||||
| `OXICLOUD_REUSE_PORT` | `false` | Enable `SO_REUSEPORT` so multiple processes can share the same port. **Disabled by default** — a second accidental instance will fail with "address already in use". Enable only for deliberate multi-worker setups (process supervisor, rolling restart). Not supported on Windows. |
|
||||
| `OXICLOUD_METRICS_LISTEN` | (unset) | Prometheus `/metrics` listener address (e.g. `127.0.0.1:9090`, IPv6 allowed as `[::1]:9090`). **Unset = disabled**: no `/metrics` endpoint is bound and no metrics recorder is installed (zero runtime cost). When set, a separate HTTP listener on this address serves the text-format scrape. **Deliberately NOT merged into the main API** — no auth, CSRF, or DPoP layer in front. Bind to loopback or a private interface unless you intend to expose metrics publicly. Starter counters: `oxicloud_dpop_verify_failed_total{reason}`, `oxicloud_dpop_proof_missing_total`, `oxicloud_dpop_header_missing_on_bound_session_total`, `oxicloud_dpop_replay_detected_total`, `oxicloud_dpop_nonce_challenges_issued_total`. |
|
||||
| `OXICLOUD_STARTUP_JOBS` | `thumb_derived_import?repair=true,thumb_attached_import?repair=true` | Background jobs dispatched once at boot, comma-separated, each `name` or `name?flag=true` using the same syntax as `POST /api/admin/jobs/{name}/trigger`. Flags: `force`, `deep`, `repair`, `storage`. **The default migrates thumbnails out of the legacy `.thumbnails/` directory and deletes the originals**, so the migration completes without anyone triggering it from the admin panel; each sidecar is read back through the normal stack before it is unlinked, and every deletion is audited. An explicit value **replaces** the default; set it empty (`OXICLOUD_STARTUP_JOBS=`) to disable startup jobs, or to `thumb_derived_import,thumb_attached_import` to import without deleting. **Non-blocking** — readiness never waits on a job; entries run sequentially in the background. **Fail-fast** — an unknown job name or flag panics at boot, because a silently-dropped entry means a migration that never runs. A run interrupted by a restart resumes from its cursor on the next boot, so a long migration finishes across restarts. Safe to leave at the default: the jobs are idempotent, and once drained a run does nothing. See [Thumbnail Migration](./thumbnail-migration.md) for the upgrade runbook. |
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ OxiCloud is configured entirely via **environment variables** (no config files n
|
||||
- [Deployment & Docker](/config/deployment) — Docker Compose, Kubernetes Helm chart, image details
|
||||
- [Environment Variables](/config/env) — complete reference of all `OXICLOUD_*` variables
|
||||
- [Storage Fine Tuning](/config/storage-fine-tuning) — sizing the upload caps + spool directories; tmpfs vs real disk; NVMe split layouts
|
||||
- [Thumbnail Migration](/config/thumbnail-migration) — upgrading past `.thumbnails/`: what runs on first boot, taking a snapshot first, verifying afterwards
|
||||
- [Authentication](/config/authentication) — JWT auth, login, refresh, password changes, and auth status
|
||||
- [OIDC / SSO](/config/oidc) — single sign-on with Keycloak, Authentik, Authelia, Google, Azure AD
|
||||
- [WOPI (Office Editing)](/config/wopi) — Collabora Online / OnlyOffice integration
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Thumbnail migration runbook
|
||||
|
||||
Thumbnails used to live as files under `{STORAGE_PATH}/.thumbnails/`.
|
||||
They now live in the content-addressed blob store, alongside file
|
||||
content. This page is for operators upgrading across that change.
|
||||
|
||||
**You do not have to do anything.** The migration runs itself, in the
|
||||
background, on the first boot after the upgrade. The rest of this page
|
||||
is for operators who want to verify it, take a safety net first, or
|
||||
understand what it did.
|
||||
|
||||
## What runs, and when
|
||||
|
||||
Two background jobs, dispatched once at startup and daily thereafter:
|
||||
|
||||
| Job | Migrates | Regenerable if lost? |
|
||||
|---|---|---|
|
||||
| `thumb_derived_import` | Thumbnails the server rendered from file content | Yes — the next request re-renders |
|
||||
| `thumb_attached_import` | Previews a client uploaded (`ext-{file_id}.jpg`) | **No** — there is no render path for these |
|
||||
|
||||
Both import each sidecar into blob storage, read it back to confirm the
|
||||
copy is byte-identical, and only then delete the original. When the
|
||||
directory is empty it is removed, and `.thumbnails/` stops existing.
|
||||
|
||||
Startup dispatch is non-blocking — the server is ready immediately and
|
||||
the migration proceeds behind it. A run interrupted by a restart resumes
|
||||
from where it stopped, so a large installation finishes over several
|
||||
restarts rather than starting again each time.
|
||||
|
||||
This is controlled by `OXICLOUD_STARTUP_JOBS`, which defaults to:
|
||||
|
||||
```
|
||||
OXICLOUD_STARTUP_JOBS=thumb_derived_import?repair=true,thumb_attached_import?repair=true
|
||||
```
|
||||
|
||||
To **import without deleting** — migrate now, inspect, delete later:
|
||||
|
||||
```
|
||||
OXICLOUD_STARTUP_JOBS=thumb_derived_import,thumb_attached_import
|
||||
```
|
||||
|
||||
The sidecars then stay on disk. Trigger the deletion when you are ready
|
||||
from **Admin → Jobs**, using each job's Repair action.
|
||||
|
||||
To disable startup jobs entirely, set the variable to an empty value.
|
||||
|
||||
## Taking a safety net first
|
||||
|
||||
Recommended for any installation where the uploaded previews matter, and
|
||||
cheap enough to be worth it regardless. Both parts must be captured
|
||||
together — a database that references blobs a storage snapshot predates
|
||||
is worse than neither.
|
||||
|
||||
**1. Stop the server.** A snapshot taken while writes are in flight can
|
||||
catch a blob that exists on disk without its database row, or the
|
||||
reverse.
|
||||
|
||||
```bash
|
||||
systemctl stop oxicloud # or: docker compose stop oxicloud
|
||||
```
|
||||
|
||||
**2. Snapshot the database.**
|
||||
|
||||
```bash
|
||||
pg_dump --format=custom --file=oxicloud-preflight.dump "$DATABASE_URL"
|
||||
```
|
||||
|
||||
Use `--format=custom`; restoring it needs `pg_restore --disable-triggers`,
|
||||
because the folder table carries a self-referencing foreign key that a
|
||||
plain SQL restore cannot order correctly.
|
||||
|
||||
**3. Snapshot the storage directory.** At minimum `.thumbnails/`, which
|
||||
is what the migration touches:
|
||||
|
||||
```bash
|
||||
tar -czf oxicloud-thumbnails-preflight.tar.gz -C "$STORAGE_PATH" .thumbnails
|
||||
```
|
||||
|
||||
A whole-directory snapshot is better if you have the space — filesystem
|
||||
or volume snapshots (ZFS, LVM, EBS) are ideal, since they are atomic and
|
||||
near-instant:
|
||||
|
||||
```bash
|
||||
zfs snapshot tank/oxicloud@preflight
|
||||
```
|
||||
|
||||
**4. Start the server.** The migration begins in the background.
|
||||
|
||||
Keep both snapshots until you have run the verification below and are
|
||||
satisfied.
|
||||
|
||||
## Verifying the migration
|
||||
|
||||
Two checks, both from **Admin → Jobs** or the API. Run them after the
|
||||
migration reports no remaining work.
|
||||
|
||||
**1. Every mapping points at a blob that exists.** Run
|
||||
`satellites_consistency`. It walks both thumbnail tables and reports any
|
||||
row whose blob or source is gone. A clean run means nothing was lost in
|
||||
the bookkeeping.
|
||||
|
||||
```
|
||||
POST /api/admin/jobs/satellites_consistency/trigger
|
||||
```
|
||||
|
||||
**2. Every blob still hashes to what it claims.** Run
|
||||
`backend_consistency` with `?deep=true`. It reads every blob back from
|
||||
storage and re-hashes it, which covers the migrated thumbnails along
|
||||
with everything else. This is a full read of your storage and can take
|
||||
hours on a large installation — schedule it accordingly.
|
||||
|
||||
```
|
||||
POST /api/admin/jobs/backend_consistency/trigger?deep=true
|
||||
```
|
||||
|
||||
A clean pass on both means the thumbnails are readable, correctly
|
||||
referenced, and byte-intact in their new home. At that point the
|
||||
snapshots can be discarded.
|
||||
|
||||
## Checking it finished
|
||||
|
||||
`.thumbnails/` is gone. That is the whole test:
|
||||
|
||||
```bash
|
||||
ls -d "$STORAGE_PATH/.thumbnails" # No such file or directory
|
||||
```
|
||||
|
||||
If you instead find `.thumbnails.migrated/`, the migration completed but
|
||||
could not remove the directory, because something that is not a
|
||||
thumbnail was inside it — a `.DS_Store` from macOS Finder is the usual
|
||||
culprit. The tree was moved aside instead of deleted. Its contents are
|
||||
no longer used and it is safe to remove by hand once you have looked at
|
||||
what is in there.
|
||||
|
||||
While either directory is absent, the server skips the legacy read path
|
||||
entirely, at no cost. While `.thumbnails/` is present, reads fall back
|
||||
to it on a miss, which is what makes the migration invisible to users
|
||||
while it runs.
|
||||
|
||||
## If something looks wrong
|
||||
|
||||
Every deletion is written to the audit log, naming the job, the file
|
||||
removed and the blob that replaced it. To review what a migration
|
||||
removed:
|
||||
|
||||
```bash
|
||||
journalctl -u oxicloud | grep sidecar_deleted
|
||||
```
|
||||
|
||||
A sidecar is only ever deleted after its replacement has been read back
|
||||
and compared byte-for-byte, so a file that failed that check is still on
|
||||
disk. Those show up as findings on the job's run in **Admin → Jobs**,
|
||||
with the reason recorded per file.
|
||||
+413
-11
@@ -339,6 +339,97 @@ Same trims as `content_derived_blobs` — no `size`, no `format`, no
|
||||
Generic naming rather than `file_previews` because the family is
|
||||
real, and each member would otherwise be a new table plus a new
|
||||
`BlobReferenceSource` plus a new term in the consistency recompute.
|
||||
|
||||
### Negative verdicts — `blob_hash` must be nullable
|
||||
|
||||
*(Resolved 2026-08-27. Supersedes the "`.skip` markers are an open
|
||||
question" note.)*
|
||||
|
||||
The table says "here is the artifact". It cannot say **"there is
|
||||
deliberately no artifact"**, and absence of a row is ambiguous — it
|
||||
means both *never attempted* and *attempted, not worth it*. Collapsing
|
||||
those destroys the only information a negative cache exists to hold.
|
||||
|
||||
Two live cases, not one:
|
||||
|
||||
* **Transcode not beneficial.** `ImageTranscodeService` can only learn
|
||||
whether WebP is smaller by doing the full decode + encode. When it is
|
||||
not, it records a zero-byte `{file_id}.{ext}.skip` marker so the next
|
||||
GET does not repeat the work.
|
||||
* **Thumbnail not renderable.** `generate_and_persist` returns empty
|
||||
`Bytes` — "moka's zero-weight negative-entry convention" — for sources
|
||||
over `MAX_DECODE_PIXELS` (50 MP) or that fail to decode. This one is
|
||||
**RAM-only**: after moka evicts, a 60-megapixel upload has its full
|
||||
decode attempted again, forever.
|
||||
|
||||
So this is not a transcode quirk. The rule generalises to every `kind`:
|
||||
|
||||
> **Any derivation whose failure is deterministic in the source content
|
||||
> is worth memoising negatively.**
|
||||
|
||||
**The discriminator, or the table fills with noise.** Persist a negative
|
||||
only when it is *both* expensive to compute *and* deterministic in the
|
||||
content. `can_transcode(mime)` and "not an image" are cheap metadata
|
||||
checks — recomputing is free and a row would be pure overhead. It is the
|
||||
ones that cost a decode that earn a row.
|
||||
|
||||
**Permanent vs transient is the load-bearing split.** "Deterministic"
|
||||
above means *a property of the content*, not merely *a failure that
|
||||
happened*:
|
||||
|
||||
| Permanent — cache it | Transient — never cache it |
|
||||
|---|---|
|
||||
| decode failed (corrupt / unsupported) | generation timeout |
|
||||
| over `MAX_DECODE_PIXELS` | decode semaphore closed |
|
||||
| transcode result not smaller | blob read I/O error, OOM under load |
|
||||
|
||||
**Today's code cannot tell them apart, and that must be fixed before any
|
||||
of this is persisted.** `generate_and_persist` collapses *every* error
|
||||
into empty `Bytes` — timeouts and semaphore closures included. That is
|
||||
survivable while the sentinel lives only in moka, which evicts; write
|
||||
the same signal to the database and a thumbnail that timed out once
|
||||
under load is marked unrenderable **forever**. So the renderer must
|
||||
return a typed outcome — rendered / permanently-unrenderable / transient
|
||||
failure — and only the middle one earns a row.
|
||||
|
||||
The asymmetry sets the default. A wrongly-cached transient is silent and
|
||||
permanent; a not-cached permanent merely costs repeated work. **When in
|
||||
doubt, do not cache** — treat unclassified errors as transient.
|
||||
|
||||
**Representation: nullable `blob_hash`.** A sentinel hash was considered
|
||||
and rejected — it stops `blob_hash` naming a real blob, and every future
|
||||
reader has to know the lie. NULL says what is true. Costs:
|
||||
|
||||
* `ContentDerivedReferenceSource` needs `AND blob_hash IS NOT NULL`; a
|
||||
row holding no blob holds no reference.
|
||||
* The dangling-derived check (row 9 of the coverage matrix) needs the
|
||||
same guard, or every negative verdict reports as a broken row.
|
||||
* The `NOT NULL` constraint is dropped.
|
||||
|
||||
**No TTL, and no renderer-version term either.** Both were considered.
|
||||
Time is the wrong axis: the verdict is deterministic in
|
||||
`(content, encoder)` and does not decay, so a TTL re-attempts an OOMing
|
||||
decode on a schedule — reintroducing the exact waste the negative
|
||||
exists to prevent — while still leaving staleness for most of the window
|
||||
after a deploy.
|
||||
|
||||
What makes the mechanism unnecessary is that **negative rows are
|
||||
disposable**: they hold no data, so discarding one costs only a
|
||||
re-derivation. Upgrading the image library invalidates them with a line
|
||||
in the same migration as the dependency bump —
|
||||
|
||||
```sql
|
||||
DELETE FROM storage.content_derived_blobs WHERE blob_hash IS NULL;
|
||||
```
|
||||
|
||||
— which beats a version term that must be remembered and leaves dead
|
||||
rows behind when bumped. Write this down, or someone later builds the
|
||||
expiry logic this paragraph exists to prevent.
|
||||
|
||||
Same shape, outside this table and not solved here:
|
||||
`blob_extracted_text` (no extractable text) and `faces.faces` (no faces
|
||||
detected) are both deterministic negatives currently indistinguishable
|
||||
from "never processed".
|
||||
With `kind` it's a one-line `ALTER … CHECK`:
|
||||
|
||||
| Kind | Why it lands here |
|
||||
@@ -858,8 +949,8 @@ Findings each job reports today, and where the new tables land:
|
||||
| # | Edge | Direction | Mechanism | Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | backend → `storage.blobs` | orphan bytes | `orphan_blob` (backend_consistency) | ✓ |
|
||||
| 2 | `storage.blobs` → backend | missing bytes | `blob_missing_from_backend` | ✓ |
|
||||
| 3 | chunk bytes | corruption | `blob_corrupted`, `blob_unreadable` | ✓ |
|
||||
| 2 | `storage.blobs` → backend | missing bytes | `blob_missing_from_backend` (backend_consistency) | ✓ |
|
||||
| 3 | chunk bytes | corruption | `blob_corrupted`, `blob_unreadable` (backend_consistency, `?deep=true`) | ✓ |
|
||||
| 4 | manifest → chunks | chunk reaped | `chunk_missing` (files_consistency) | ✓ |
|
||||
| 5 | `files` → Blob | dangling | `missing_blob` (files_consistency) | ✓ |
|
||||
| 6 | `storage.blobs.ref_count` | recompute | `refcount_mismatch` | ✓ chunk level only |
|
||||
@@ -949,6 +1040,74 @@ the request. Read order:
|
||||
backend stack, which is where the disk cache lives.
|
||||
4. Generate only if step 2 found no row.
|
||||
|
||||
### HTTP ETag — move to the derived hash when the order flips
|
||||
|
||||
Shipped ahead of this plan (2026-08-24): the thumbnail ETag is
|
||||
`"thumb-{source_hash}-{size}-{format}"` on both the REST and the
|
||||
NextCloud preview endpoint. It replaced a `file_id`-keyed ETag that,
|
||||
combined with `Cache-Control: immutable`, meant replacing a file's
|
||||
content never invalidated the client's copy — `file_id` survives the
|
||||
replacement, so the ETag did too, for a year.
|
||||
|
||||
**That key is still one term short.** A thumbnail is a function of
|
||||
`(source bytes, size, format, RENDERER)`. Change the encoder, a quality
|
||||
setting, or EXIF-rotation handling, and identical inputs produce
|
||||
different bytes under an unchanged ETag — the same staleness class, one
|
||||
level down. It bites when an already-cached thumbnail is re-rendered
|
||||
after a renderer change (sidecar evicted, regenerated on miss).
|
||||
|
||||
**The fix is to key on the derived blob's own hash** — the ETag then
|
||||
*is* the hash of the bytes served, so any change in output invalidates
|
||||
by construction, with no version constant to remember to bump. It is
|
||||
self-consistent for free: `store_derived_blob` is
|
||||
`ON CONFLICT DO NOTHING`, so a re-render never displaces the stored
|
||||
row, and the ETag therefore always equals what the derived tier will
|
||||
serve.
|
||||
|
||||
**It must land with the read-order flip, not before.** Today the
|
||||
derived tier is deliberately read LAST, so an ETag naming the derived
|
||||
hash would describe a tier the response probably did not come from.
|
||||
The two agree at creation — `render_and_persist_all_webp` writes both
|
||||
from the same bytes — but diverge if a sidecar is re-rendered while the
|
||||
derived row stays pinned by `DO NOTHING`. Sidecar is served, ETag
|
||||
describes the other one: an ETag that lies about the body is worse than
|
||||
one that is merely coarse. Two further reasons it has to wait: the
|
||||
derived tier is WebP-only (`variant = size.dir_name()`, no format in
|
||||
the key), so non-WebP clients have no row to key on; and nothing
|
||||
predating this work has a row until `derived_import` backfills.
|
||||
|
||||
So at step 10, alongside the flip: ETag becomes the derived hash, with
|
||||
the current `source_hash` form kept as the fallback for a variant not
|
||||
yet generated and for formats the derived tier does not hold. The
|
||||
`LEFT JOIN` in step 2 above already returns the derived hash in the
|
||||
same query, so the ETag costs no extra round-trip.
|
||||
|
||||
**Attempted early (2026-08-26) and reverted — the constraint above is
|
||||
load-bearing.** The attached half shipped and is correct, because an
|
||||
upload writes its row synchronously before any read can observe it. The
|
||||
*derived* half was brought forward at the same time and had to be backed
|
||||
out: the ETag is computed **before** the body, so on a cache miss no row
|
||||
exists and the handler emits the source-keyed form — then rendering
|
||||
creates the row, and the very next request resolves to the derived hash.
|
||||
The validator changed as a side effect of producing the body, so every
|
||||
first render was immediately stale. Caught by
|
||||
`thumbnail_etag_content_keyed.hurl`, where two consecutive GETs of an
|
||||
unchanged file stopped revalidating to 304.
|
||||
|
||||
**The flip alone does NOT remove the hazard** *(corrected 2026-08-26 —
|
||||
an earlier revision of this paragraph claimed it did)*. A first render
|
||||
still creates the row as a side effect of producing the body, whatever
|
||||
the read order, so two consecutive reads would still straddle its
|
||||
appearance.
|
||||
|
||||
What actually removes it is resolving the ETag **after** generation on
|
||||
the 200 path. A 304 can only fire when the client already holds a
|
||||
validator, which means it has been served before, which means the row
|
||||
exists — so the *conditional* path can safely consult the derived hash
|
||||
up front, while the *generating* path computes it from bytes it now
|
||||
holds. That is a handler restructure, not an ordering change, and it is
|
||||
the actual prerequisite for the derived-hash ETag.
|
||||
|
||||
**The disk cache is `CachedBlobBackend`, reused unchanged.** No
|
||||
thumbnail-specific cache, no second root path. Routing derived
|
||||
blobs through the same stack gets, for free:
|
||||
@@ -1109,8 +1268,101 @@ filesystem and a remote backend for hours). It sweeps the cold tail:
|
||||
reference forever.
|
||||
- Reports imported / skipped-orphan / skipped-jpeg / failed counts.
|
||||
|
||||
**Phase 3 (release N+1): delete** the fallback module and the sidecar
|
||||
directories, gated on the job reporting an empty tail.
|
||||
**Phase 3: the job deletes, not a release.** *(revised 2026-08-26 —
|
||||
supersedes "delete in release N+1, gated on an empty tail")*
|
||||
|
||||
Sidecars are **local disk**. A release cannot know whether every
|
||||
instance has drained, so gating deletion on "the tail is empty" asks an
|
||||
operator to coordinate a fact nothing reports — and there is no way to
|
||||
know when, or whether, they will trigger the jobs at all. Instead the
|
||||
import unlinks each sidecar it has successfully imported, so **each
|
||||
instance drains itself** and the directory becomes removable once
|
||||
genuinely empty.
|
||||
|
||||
Three constraints on that:
|
||||
|
||||
- **Verify readback before unlinking.** Import → read the derived blob
|
||||
back through the normal stack → *then* delete. A store that reported
|
||||
success but landed unreadable would otherwise take the last copy with
|
||||
it. Cheap next to the decode already performed, and it is the
|
||||
difference between a migration and a data-loss bug.
|
||||
- **Only after the read-order flip.** Deleting while the sidecar is
|
||||
still read *first* sends reads to the derived tier as a side effect of
|
||||
the migration — its first production traffic arriving by accident
|
||||
rather than by decision.
|
||||
- **Opt-in** (`?delete_imported=true`). A migration that deletes on its
|
||||
default setting is surprising, and it is the same instinct as
|
||||
no-silent-auto-repair: early runs import only, so an operator can
|
||||
inspect before committing.
|
||||
|
||||
Register it as a **scheduled tick**, not a boot-time trigger: it is
|
||||
idempotent and resumable, so periodic is safe, whereas walking a large
|
||||
`.thumbnails/` during startup delays readiness for nothing.
|
||||
|
||||
**Phase 4: the fallback disables itself.** *(revised 2026-08-29 —
|
||||
supersedes "the only remaining release is removing the fallback read
|
||||
path")*
|
||||
|
||||
Removing the fallback in a release has the same flaw as gating deletion
|
||||
on an empty tail, one level up: sidecars are local disk, so no release
|
||||
can know that every instance has drained. Ed's framing is the answer —
|
||||
the only removal you can actually write is `if the tier is gone, return`.
|
||||
|
||||
So `ThumbnailService::initialize` probes the size directories once at
|
||||
boot and stores the result. When absent, every fallback read
|
||||
short-circuits on a relaxed atomic load, no syscall. The code stays,
|
||||
costs nothing, and can be deleted whenever — or never. No coordination,
|
||||
no named release.
|
||||
|
||||
Two things had to change for absence to be reachable at all:
|
||||
|
||||
- **`initialize` no longer creates the directories.** It
|
||||
`create_dir_all`-ed all three at every boot, so the job removed them
|
||||
and the next restart put them back; the absence this gates on was
|
||||
unreachable by construction. Nothing has written a sidecar since step
|
||||
10d2, so there was nothing to create them for.
|
||||
- **The probe tests the size directories, not the root.** On macOS
|
||||
Finder leaves a `.DS_Store` in the root, which blocks `remove_dir`
|
||||
there permanently. Gating on the root would keep the fallback alive on
|
||||
every developer machine for a reason unrelated to thumbnails. No size
|
||||
directory means no sidecar.
|
||||
|
||||
The root removal now reports its outcome instead of discarding it —
|
||||
it is the one result an operator is waiting for, and `.DS_Store` is a
|
||||
failure worth naming rather than a silent no-op.
|
||||
|
||||
### Prerequisite: one persist function (found 2026-08-26)
|
||||
|
||||
**Four render paths write a sidecar; only one also writes the derived
|
||||
row.** `store_derived_blob` has a single call site — in
|
||||
`render_and_persist_all_webp` — while `fs::write(&thumb_path, …)` has
|
||||
five. `get_thumbnail`, `generate_and_persist` and
|
||||
`generate_all_sizes_background` all persist sidecar-only.
|
||||
|
||||
That breaks the migration's premise rather than merely being untidy: an
|
||||
on-demand render (cache miss, a size never generated, an evicted
|
||||
sidecar) keeps producing un-migrated state *after* the import runs, so
|
||||
the tail never empties and the deletion gate never opens.
|
||||
|
||||
So before the imports can converge, all render paths must go through
|
||||
**one** `persist_thumbnail` that writes the sidecar, the derived blob
|
||||
and the moka entry together — the same single-source move as
|
||||
`storage.copy_file_satellites`. What it writes then becomes a policy in
|
||||
one place, so "stop writing sidecars" is later a one-line change rather
|
||||
than four edits.
|
||||
|
||||
Interim setting is **dual-write**, for two reasons: it is what makes the
|
||||
backlog finite, and it leaves reads untouched while the derived tier is
|
||||
still unproven. Cost is one extra local `fs::write` per render,
|
||||
negligible beside the decode. Note the sidecar *read* path must survive
|
||||
until the directories are empty regardless, so stopping the write early
|
||||
buys nothing.
|
||||
|
||||
Cost to be aware of: `ThumbnailService` holds no `DedupService` — it is
|
||||
a per-call parameter (`dedup: Option<&DedupService>`) — so the
|
||||
consolidation threads it through those paths, and
|
||||
`generate_and_persist` takes a `thumb_path` where it will need the
|
||||
`blob_hash` instead.
|
||||
|
||||
### The "just delete it" opt-out is no longer universally safe
|
||||
|
||||
@@ -1157,7 +1409,35 @@ hardcoded SQL). New sources bolt on independently.
|
||||
after, step 5 lands at scale; per-row HEADs do not survive a 4×
|
||||
row count.
|
||||
7. **`ImageTranscodeService`** — same shape, `kind = 'transcode'`,
|
||||
no new table.
|
||||
no new table. **Scoped 2026-08-27, not started.** The service does
|
||||
not currently write the derived tier at all, which is the same gap
|
||||
`persist_rendered` closed for thumbnails, and it must be closed
|
||||
before `transcode_import` can converge.
|
||||
|
||||
Three concrete pieces, in order:
|
||||
|
||||
* **Thread the source hash to the call site.** `get_transcoded` takes
|
||||
`file_id` only, and the table is content-keyed. The hash IS
|
||||
available one frame up — `file_retrieval_service::try_transcode`
|
||||
is called from a scope holding `dto.content_hash` — so it is a
|
||||
parameter to add, not a lookup to invent. Do **not** hash
|
||||
`original_content` on the fly: that is a BLAKE3 over the whole
|
||||
file on every request.
|
||||
* **Give the service a `BlobHandler`.** It has no field for one
|
||||
(`cache_dir`, `memory_cache`, `stats`), so this is a constructor
|
||||
and DI change — mind the construction order, as
|
||||
`ThumbnailService` hit the same thing and solved it with a
|
||||
per-call parameter instead.
|
||||
* **Write the `.skip` markers as negative rows** — resolved: nullable
|
||||
`blob_hash`, see *Negative verdicts*. Thumbnails need the same
|
||||
treatment for undecodable and over-`MAX_DECODE_PIXELS` sources,
|
||||
which are RAM-only today, so do both together rather than twice.
|
||||
|
||||
Note the cache is keyed `{file_id}:{ext}` in memory and
|
||||
`.transcoded/{ext}/{file_id}.{ext}` on disk, so it also carries the
|
||||
file-vs-content keying mismatch that `transcode_import` has to
|
||||
re-key. Fixing the write path first means the import only has to
|
||||
handle history, not a moving target.
|
||||
8. **`storage.copy_file_satellites` consolidation** — collapse the two
|
||||
copy paths onto one helper, with a manifest-aware reference bump.
|
||||
**Blocks step 9**: adding a file-keyed table before this means
|
||||
@@ -1169,9 +1449,101 @@ hardcoded SQL). New sources bolt on independently.
|
||||
table. Lands with step 5. File-keyed, never in
|
||||
`content_derived_blobs`. Register it in `copy_file_satellites` and
|
||||
declare its version semantics.
|
||||
10. **`derived_import` job + the dual-read fallback** — see the
|
||||
migration section. Phase 3 (deleting the fallback and the sidecar
|
||||
dirs) is a separate later release, gated on an empty tail.
|
||||
10. **Import jobs + the dual-read fallback** — see the migration
|
||||
section. Revised ordering as of 2026-08-26:
|
||||
|
||||
a. **Consolidate onto one `persist_rendered`** (dual-write) — **done
|
||||
2026-08-26**. Was the blocker: only one of four render paths wrote
|
||||
the derived row, so the import could never converge. Every live
|
||||
render now dual-writes. Two paths still pass `None` and stay
|
||||
sidecar-only, which is safe *only* because both are reachable
|
||||
solely through the `ThumbnailPort` impl and nothing holds a
|
||||
`dyn ThumbnailPort` — if either gains a real caller it must take a
|
||||
`DedupService` first. See *Prerequisite: one persist function*.
|
||||
b. **`thumb_derived_import`** (shipped) and **`thumb_attached_import`**
|
||||
(shipped) — two jobs, not one, because the keying differs and that
|
||||
difference is the security boundary. A third, `transcode_import`,
|
||||
is still needed: `ImageTranscodeService` **already exists** and
|
||||
caches `.transcoded/{ext}/{file_id}.{ext}`, so those must be
|
||||
**re-keyed** file→content on import (legitimate only because a
|
||||
transcode is derivable). Its `.skip` markers import as **negative
|
||||
rows** with a NULL `blob_hash` — see *Negative verdicts* — so the
|
||||
verdict survives the deletion of `.transcoded/`, which it
|
||||
otherwise would not.
|
||||
|
||||
**Not next, and deliberately so.** Two prerequisites, both learned
|
||||
the hard way on the thumbnail side:
|
||||
|
||||
* **Format must move into `variant` first.** Transcodes are
|
||||
inherently multi-format, and `variant` is keyed on size alone —
|
||||
the same gap that keeps JPEG thumbnails on the sidecar (see
|
||||
10c). Importing before that means migrating into a schema that
|
||||
cannot hold the data without collisions. One migration unblocks
|
||||
both.
|
||||
* **Step 7 before the import.** `ImageTranscodeService` writes only
|
||||
its file-keyed cache today, so an import would run against a
|
||||
cache still growing and never reach an empty tail — precisely
|
||||
the trap `persist_rendered` had to close for thumbnails.
|
||||
|
||||
Order: format-in-`variant` → step 7 → `transcode_import`.
|
||||
c. **Flip the read order**, derived first — **done 2026-08-26**. Two
|
||||
things it is not: a two-line swap, and an ETag fix.
|
||||
|
||||
A derived miss must **fall through** to the sidecar, where the old
|
||||
code terminated the lookup — while the imports drain, most content
|
||||
has a sidecar and no row, so terminating would report "no
|
||||
thumbnail" for nearly everything.
|
||||
|
||||
And it is **WebP-only**. `store_derived_blob` writes `image/webp`
|
||||
with `variant` keyed on size alone, no format term, so a JPEG
|
||||
request matches the WebP row and gets the wrong codec — a
|
||||
regression the old ordering hid, because the `.jpg` sidecar won
|
||||
first. JPEG clients therefore stay on the sidecar, **and the
|
||||
sidecar cannot be deleted for them** until `variant` encodes
|
||||
format. That is a new prerequisite for (e), not a detail: it means
|
||||
a migration to `(kind, variant, format)` — or a format term inside
|
||||
`variant` — has to land before the directories can go.
|
||||
d. **Enable deletion** in the import jobs (opt-in, readback-verified)
|
||||
— **done 2026-08-27**, both halves, sharing one
|
||||
`verify_and_unlink`.
|
||||
|
||||
d2. **Stop writing sidecars.** *(Added 2026-08-27 — the sequence
|
||||
above was missing this, and (e)'s gate is unreachable without
|
||||
it: dual-write means any render or upload recreates the
|
||||
directory seconds after the job removes it, so "no longer
|
||||
exists" can never hold.)*
|
||||
|
||||
Two sides, and they differ in what a failed write costs:
|
||||
|
||||
* **Rendered / content-keyed — safe now.** Delete the `fs::write`
|
||||
in `persist_rendered`. No gate beyond the read flip, which has
|
||||
landed. Existing sidecars are untouched, so a box that has not
|
||||
imported yet keeps its fallback for old content; new content
|
||||
goes only to the derived tier, which the read path already
|
||||
prefers. If the derived store fails the bytes are still served
|
||||
and simply not cached — regenerable by definition.
|
||||
* **Uploaded / file-keyed — needs a change first.**
|
||||
`upload_thumbnail_impl` logs and still returns 201 when
|
||||
`store_attached_blob` fails, which is safe *only* because the
|
||||
`ext-` sidecar catches it. Remove that sidecar while the store
|
||||
is best-effort and a user's uploaded preview can vanish
|
||||
silently behind a success response. These are the
|
||||
non-regenerable bytes, so **the PUT must fail** before the
|
||||
write is removed.
|
||||
|
||||
Order matters: make the attached store fatal, *then* drop its
|
||||
sidecar. Reversed, it trades a silent data-loss window for an
|
||||
empty directory.
|
||||
|
||||
e. **Remove the fallback read path** once the directory no longer
|
||||
*exists* — not merely once it is empty. Two reasons. Empty is a
|
||||
momentary property an on-demand render can undo, whereas absence
|
||||
is one-way and observable, so the job removes the directory after
|
||||
draining it and that absence is the proof. And it is far cheaper
|
||||
to test: existence is a single `stat`, while emptiness costs an
|
||||
`opendir`/`readdir`/`closedir` — which matters if the fallback
|
||||
ever gates on it per read rather than once at boot. The only
|
||||
remaining release, and no data is at stake by then.
|
||||
11. **`DedupService` → `BlobHandler` rename** — decided, mechanical,
|
||||
34 files. Standalone commit, `src/AGENTS.md` updated with it. Can
|
||||
land at any point; last is easiest, since every earlier slice
|
||||
@@ -1257,10 +1629,40 @@ Schema rename (deferred, requires migration):
|
||||
- `storage.chunk_manifests` → `storage.blob_manifests` (or keep — arguable)
|
||||
- `BlobStorageBackend` trait → `ChunkStorageBackend` — reads and
|
||||
writes physical chunks, not blobs
|
||||
- **`blobs_consistency` job → `chunks_consistency`** — it iterates
|
||||
`storage.blobs`, so it inherits whatever that table is called.
|
||||
|
||||
`file.blob_hash` semantics stay — references a Blob via its
|
||||
manifest OR (for pre-CDC legacy) points directly at a single-chunk
|
||||
Blob whose hash equals its lone chunk's hash.
|
||||
### The job rename has two rules of its own
|
||||
|
||||
**Travel with the schema, never ahead of it.** A job named
|
||||
`chunks_consistency` iterating a table still called `storage.blobs` is
|
||||
*more* confusing than today's mismatch, not less.
|
||||
|
||||
**Never recycle `blobs_consistency`.** Under the corrected taxonomy the
|
||||
manifest job *is* the blob-level job, so the freed name looks
|
||||
available — and reusing it would be the worst outcome available. A job
|
||||
name that survives a release while changing meaning silently breaks
|
||||
`POST /api/admin/jobs/<name>/trigger` URLs, every historical row in
|
||||
`background_runs.job_name`, and any dashboard or alert keyed on it.
|
||||
`manifests_consistency` is unambiguous under either taxonomy; leave it
|
||||
alone. Net effect: one job renamed, not two swapped.
|
||||
|
||||
Budget for the operational cost either way — job names are not internal
|
||||
identifiers. A rename orphans past runs unless `background_runs.job_name`
|
||||
is migrated alongside, and any runbook naming the old one breaks. Worth
|
||||
an alias period or an explicit release note.
|
||||
|
||||
### Explicitly NOT renamed
|
||||
|
||||
- **The `.blob` on-disk suffix** (`<hash>.blob` in `LocalBlobBackend`
|
||||
and `CachedBlobBackend`). Correcting it to `.chunk` would mean
|
||||
renaming every file in every deployment's blob store — a migration
|
||||
whose cost is wildly out of proportion to the clarity gained, and one
|
||||
that can fail halfway. The suffix is an implementation detail no
|
||||
consumer parses; leave it.
|
||||
- **`file.blob_hash`** — semantics stay. It references a Blob via its
|
||||
manifest OR (for pre-CDC legacy) points directly at a single-chunk
|
||||
Blob whose hash equals its lone chunk's hash.
|
||||
|
||||
Scope for this rename: ~23 files touch the SQL, plus a migration
|
||||
for the table rename. Not free. Ship AFTER the tier-2 write-side
|
||||
|
||||
@@ -171,6 +171,58 @@ Native services implement this trait on an existing service type (no
|
||||
new wrapper) and register a single `Arc<dyn JobHandler>` with the
|
||||
scheduler.
|
||||
|
||||
### Self-description — `description` / `mutates` / `repair_description`
|
||||
|
||||
Three defaulted methods on both `JobHandler` and `RecoverableJobHandler`
|
||||
let a job tell the admin UI what it is. `RecoverableAdapter` forwards
|
||||
them, since the registry only ever holds `dyn JobHandler`.
|
||||
|
||||
```rust
|
||||
fn description(&self) -> &'static str { "" }
|
||||
fn mutates(&self) -> Mutates { Mutates::Never }
|
||||
fn repair_description(&self) -> Option<&'static str> { None }
|
||||
|
||||
pub enum Mutates { Never, Always, OnRepairOnly }
|
||||
```
|
||||
|
||||
They surface on `JobSummary` (`GET /api/admin/jobs`) and drive the
|
||||
panel: `Never` earns a read-only badge and triggers straight through,
|
||||
`Always` confirms first, `OnRepairOnly` is safe to run and confirms only
|
||||
when the repair variant is picked. `repair_description.is_some()` is
|
||||
what renders the repair toggle at all, and its text is the confirmation
|
||||
copy.
|
||||
|
||||
**Why three values and not a boolean.** A job can be read-only by
|
||||
default and destructive under `?repair=true`; a boolean has to answer
|
||||
wrongly for one of those two modes, and `false` on something that
|
||||
deletes files is the dangerous direction to be wrong in. It is also
|
||||
where the recovery framework is heading — discovery-only default,
|
||||
mutation behind an opt-in — so a tenant that later grows a repair arm
|
||||
changes this one value and nothing else.
|
||||
|
||||
**Why `Option<&str>` and not `supports_repair: bool` + prose.**
|
||||
Presence gates the toggle, content supplies the wording. Split across
|
||||
two methods they can disagree; and the frontend cannot invent the
|
||||
wording itself, because correcting a counter and unlinking files off
|
||||
disk are not the same warning. The two are independent, not derived
|
||||
from each other: the thumbnail imports are `Always` *and*
|
||||
repair-capable.
|
||||
|
||||
`OnRepairOnly` with no `repair_description` is rejected at registration
|
||||
— it claims to mutate only under a flag it does not support, and would
|
||||
render as safe with no reachable mutating path.
|
||||
|
||||
**Why English in the trait, not `locales/*.json`.** A description that
|
||||
lives away from the behaviour rots the moment a job changes, invisibly,
|
||||
and a translator cannot know what `manifests_consistency` reconciles.
|
||||
i18n can layer on later keyed by job name with these as the fallback,
|
||||
matching the frontend's `t(key, params, fallback)` — a missing
|
||||
translation then degrades to English from code rather than to a blank
|
||||
panel. No rework needed to get there.
|
||||
|
||||
Defaults exist so the methods could be added without touching every
|
||||
job at once; every registered job declares all three today.
|
||||
|
||||
### `JobOutcome`
|
||||
|
||||
```rust
|
||||
@@ -685,6 +737,98 @@ trigger) resumes any `Paused` row per the normal flow.
|
||||
Consistency-check.md's existing consistency-scoped sweep collapses
|
||||
into this general one.
|
||||
|
||||
### Startup jobs — `OXICLOUD_STARTUP_JOBS`
|
||||
|
||||
A comma-separated list of jobs to dispatch once, in the background,
|
||||
after the scheduler is ready. Each entry is a registered job name,
|
||||
optionally with the same query syntax the admin trigger URL uses.
|
||||
|
||||
**The default is both migration jobs, in repair mode:**
|
||||
|
||||
```
|
||||
OXICLOUD_STARTUP_JOBS=thumb_derived_import?repair=true,thumb_attached_import?repair=true
|
||||
```
|
||||
|
||||
An explicit value replaces that list; an empty value disables startup
|
||||
jobs entirely.
|
||||
|
||||
**Why it exists.** Scheduled ticks deliberately never pass `repair` — a
|
||||
job that deletes on its default setting is what no-silent-auto-repair
|
||||
forbids. But that left the migration jobs unable to finish on their
|
||||
own: a deployment whose operator never opens the admin panel re-imports
|
||||
sidecars it already imported, forever, and never drains the directory.
|
||||
|
||||
**Why the default deletes anyway.** Relying on operators to edit `.env`
|
||||
has the same failure mode one level up — the ones who never edit it are
|
||||
exactly the ones whose migration never completes. So this is a
|
||||
deliberate exception to no-silent-auto-repair, and it rests on three
|
||||
properties that must keep holding:
|
||||
|
||||
- **Nothing is deleted before its replacement has been read back.**
|
||||
`verify_and_unlink` imports, reads the blob back through the normal
|
||||
stack, and only then unlinks; a store that reported success but landed
|
||||
unreadable keeps its sidecar. This matters most for
|
||||
`thumb_attached_import`, whose bytes are user-uploaded previews with
|
||||
no render path — a wrong deletion there is permanent, where a wrong
|
||||
deletion of a server-rendered thumbnail costs a re-render.
|
||||
- **Sidecars whose source is gone are deleted without a readback**,
|
||||
because there is nothing to read back and nothing can reference them
|
||||
again. Unrecoverable and unreachable are different things; these are
|
||||
both.
|
||||
- **Every deletion is audited**, so what a boot removed, and from which
|
||||
source, is reconstructable afterwards.
|
||||
|
||||
The consequence to hold in mind: an upgrade deletes on first boot, in
|
||||
every deployment at once, with no operator action. A regression in the
|
||||
readback path would be simultaneous and unrecoverable, so that code is
|
||||
load-bearing. Operators who want to inspect before committing set
|
||||
`OXICLOUD_STARTUP_JOBS=thumb_derived_import,thumb_attached_import` —
|
||||
same jobs, import only.
|
||||
|
||||
It is not a "run everything in repair mode" switch. Each job is named
|
||||
individually and carries its own flags.
|
||||
|
||||
**Validation is fail-fast.** An unknown job name panics at boot — the
|
||||
registry is fully populated by then, so a name that doesn't resolve is a
|
||||
typo or a stale rename, and ignoring it would leave a migration that
|
||||
silently never runs. Unknown flags panic too: a dropped `?repare=true`
|
||||
would leave the job in discovery-only mode while the operator believed
|
||||
the tier was draining, and the symptom ("it never finished") surfaces
|
||||
months later with nothing pointing back at the config.
|
||||
|
||||
**Dispatch is non-blocking.** `tokio::spawn`, so readiness never waits
|
||||
on a job that may walk a filesystem for hours. Jobs in the list run
|
||||
sequentially within that task, not concurrently: they contend for the
|
||||
same directories and pool, and the exclusivity gate would turn overlap
|
||||
into a *skipped* run rather than a queued one.
|
||||
|
||||
**Interrupted runs resume.** The boot recovery sweep above runs first
|
||||
and flips every abandoned `Running` row to `Paused` with its cursor
|
||||
intact; `run_or_resume` then picks Resume over a fresh start. So a
|
||||
migration killed by a restart continues where it stopped, and completes
|
||||
across however many restarts it takes.
|
||||
|
||||
That is a deliberate exception to "do NOT auto-resume" — scoped to the
|
||||
named jobs only. The rule protects against a restart silently resuming
|
||||
work nobody asked for; here somebody did ask, in configuration, and not
|
||||
having to ask again is the entire point. Every other paused run still
|
||||
waits for an operator.
|
||||
|
||||
A resumed run keeps the flags it started with (`repair` / `deep` are
|
||||
persisted to `params` on the fresh open and read back on resume), so
|
||||
editing the config mid-migration does not retroactively change a run
|
||||
already in flight.
|
||||
|
||||
**Safe to leave set.** Each job is idempotent and resumable; once the
|
||||
tier has drained, a run is a `read_dir` over three directories that
|
||||
returns nothing — and after the directory is removed, not even that.
|
||||
|
||||
**Visible in the admin panel.** These are ordinary registered jobs:
|
||||
they appear in `GET /api/admin/jobs`, are triggerable by hand, and
|
||||
record the same runs and findings. Rows named here additionally carry a
|
||||
`startup` object with the configured flags, so an operator can see that
|
||||
a job deletes files on every boot rather than only when someone clicks.
|
||||
|
||||
### Admin surface (recoverable runs)
|
||||
|
||||
Same URL taxonomy as Part 1 — resource-first, action second, all
|
||||
|
||||
@@ -179,7 +179,17 @@ Guardrail:
|
||||
head pair's fingerprint differs from the second entry's — signals "you
|
||||
added new keys but haven't rotated legacy blobs yet".
|
||||
* A **legacy-blob counter** is surfaced in the admin panel per storage entry.
|
||||
The counter is maintained by `blobs_consistency`: during its normal walk it
|
||||
|
||||
> **Retarget (post-split).** This plan names `blobs_consistency` as the
|
||||
> host for the magic-byte branch throughout. That is no longer the right
|
||||
> tenant: `blobs_consistency` became database-only and opens no backend,
|
||||
> while `backend_consistency` owns every physical check and already holds
|
||||
> the enumeration. Read `backend_consistency` wherever the sections below
|
||||
> say `blobs_consistency`. Nothing else about the design changes — the
|
||||
> magic-byte check still rides along on an existing walk, and still lands
|
||||
> in the run's `stats` bag.
|
||||
|
||||
The counter is maintained by that scan: during its normal walk it
|
||||
branches on the magic-byte check and records the legacy count as a run
|
||||
statistic on `jobs.recoverable_runs` (existing surface, no schema hit). The
|
||||
admin panel reads the most recent count and displays it. Refresh cadence is
|
||||
|
||||
@@ -407,10 +407,12 @@ Per slice, plus these end-to-end scenarios in Hurl:
|
||||
6. **In-place encryption rotation refused**: two entries, same S3 bucket,
|
||||
different encryption keys. Trigger migration → refuses with the specific
|
||||
error message pointing at the encryption case and the two-step workaround.
|
||||
7. **`?storage=<name>` on blobs_consistency**: run against `s3_prod` before
|
||||
7. **`?storage=<name>` on backend_consistency**: run against `s3_prod` before
|
||||
cutover. Full walk, `probed_storage` in run row. Then cutover, then rerun
|
||||
against `local_main` — verifies old backend still has everything.
|
||||
8. **Unknown storage name**: `POST /admin/jobs/blobs_consistency/trigger?storage=nope`
|
||||
(This scenario named `blobs_consistency` until that tenant became
|
||||
database-only; entry scoping belongs to whichever job opens a backend.)
|
||||
8. **Unknown storage name**: `POST /admin/jobs/backend_consistency/trigger?storage=nope`
|
||||
→ 400 with known-names list. No run row created.
|
||||
9. **Missing entry at boot**: `active_backend_name = "gone"` but `_ENTRIES`
|
||||
doesn't include it → boot aborts with the specific message pointing at
|
||||
|
||||
+30
@@ -55,6 +55,36 @@ OXICLOUD_SERVER_HOST=0.0.0.0
|
||||
# Recommended: 127.0.0.1:9090 with node_exporter-style scrapers.
|
||||
#OXICLOUD_METRICS_LISTEN=127.0.0.1:9090
|
||||
|
||||
# ── Startup jobs ──────────────────────────────────────────────────────
|
||||
# Background jobs dispatched once, after the scheduler is ready.
|
||||
# Comma-separated; each entry is a registered job name, optionally with
|
||||
# the same flags the admin trigger URL takes (force, deep, repair,
|
||||
# storage).
|
||||
#
|
||||
# DEFAULT (applied when this variable is unset):
|
||||
# thumb_derived_import?repair=true,thumb_attached_import?repair=true
|
||||
#
|
||||
# Those two migrate thumbnails out of the legacy .thumbnails/ directory
|
||||
# into blob storage and then delete the originals, so the migration
|
||||
# completes without anyone having to trigger it from the admin panel.
|
||||
# Each sidecar is read back through the normal stack before it is
|
||||
# unlinked, and every deletion is written to the audit log.
|
||||
#
|
||||
# Dispatch is non-blocking — startup never waits on a job. A run
|
||||
# interrupted by a restart resumes from its cursor on the next boot, so
|
||||
# a long migration finishes across restarts. Safe to leave at the
|
||||
# default: the jobs are idempotent, and once the directory is drained a
|
||||
# run does nothing at all.
|
||||
#
|
||||
# An unknown job name or flag is a FATAL error at boot, not a warning —
|
||||
# a silently ignored entry means a migration that never runs.
|
||||
#
|
||||
# To disable every startup job, set this to the empty value:
|
||||
#OXICLOUD_STARTUP_JOBS=
|
||||
#
|
||||
# To import without deleting (inspect first, delete later by hand):
|
||||
#OXICLOUD_STARTUP_JOBS=thumb_derived_import,thumb_attached_import
|
||||
|
||||
# ── Upload size caps ──────────────────────────────────────────────────
|
||||
# See docs/config/storage-fine-tuning.md for sizing guidance.
|
||||
|
||||
|
||||
@@ -618,8 +618,32 @@ export interface PausedRunBrief {
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a job changes state — `RecoverableJobHandler::mutates()` on the
|
||||
* backend. Three values rather than a boolean because the interesting
|
||||
* case is conditional: a job can be read-only by default and destructive
|
||||
* under `?repair=true`.
|
||||
*
|
||||
* - `never` — read-only under every flag. Render a read-only badge; no
|
||||
* confirmation needed to trigger.
|
||||
* - `always` — changes state on a plain run. Confirm before triggering.
|
||||
* - `on_repair_only` — safe to trigger; confirm only when the repair
|
||||
* toggle is on.
|
||||
*/
|
||||
export type Mutates = 'never' | 'always' | 'on_repair_only';
|
||||
|
||||
export interface JobSummary {
|
||||
name: string;
|
||||
/** One or two sentences on what the job does, in English, authored
|
||||
* next to the handler. Absent for jobs that haven't declared one —
|
||||
* omit the line rather than rendering an empty block. */
|
||||
description?: string;
|
||||
mutates: Mutates;
|
||||
/** Present iff `?repair=true` does something beyond a default run;
|
||||
* describes what it ADDS. Presence is what gates the repair toggle;
|
||||
* the text is the confirmation copy. Independent of `mutates` — the
|
||||
* thumbnail import jobs are `always` AND repair-capable. */
|
||||
repair_description?: string;
|
||||
interval_ms?: number;
|
||||
next_run_at?: string;
|
||||
last_run_at?: string;
|
||||
@@ -639,6 +663,19 @@ export interface JobSummary {
|
||||
* for this job. Distinct from `running` — a paused run is
|
||||
* resumable via the same trigger endpoint. */
|
||||
paused_run?: PausedRunBrief;
|
||||
/** Present iff `OXICLOUD_STARTUP_JOBS` names this job — the flags it
|
||||
* is dispatched with at every boot. Worth showing: a job configured
|
||||
* with `repair: true` deletes on every restart, and the row would
|
||||
* otherwise suggest that only happens when someone clicks Run. */
|
||||
startup?: StartupTrigger;
|
||||
}
|
||||
|
||||
/** Flags a job configured in `OXICLOUD_STARTUP_JOBS` runs with. */
|
||||
export interface StartupTrigger {
|
||||
force: boolean;
|
||||
deep: boolean;
|
||||
repair: boolean;
|
||||
storage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
.slice()
|
||||
// `consistency_batch` is served by the top-bar
|
||||
// action buttons; hiding it here removes the
|
||||
// duplicate table row. `hasBatch` still checks the
|
||||
// duplicate table row. `batchJob` still reads from the
|
||||
// full fetched list so the top buttons only render
|
||||
// when the coordinator is actually registered.
|
||||
.filter((j) => j.name !== 'consistency_batch')
|
||||
@@ -180,7 +180,7 @@
|
||||
// Track whether the coordinator is registered so the
|
||||
// top-bar buttons can gate on it without checking `jobs`
|
||||
// (which now filters it out).
|
||||
hasBatch = fetched.some((j) => j.name === 'consistency_batch');
|
||||
batchJob = fetched.find((j) => j.name === 'consistency_batch') ?? null;
|
||||
loadError = null;
|
||||
} catch (e) {
|
||||
loadError = errorMessage(e);
|
||||
@@ -250,13 +250,15 @@
|
||||
|
||||
// ─── Expansion toggles ─────────────────────────────────────────────
|
||||
|
||||
function toggleJob(name: string) {
|
||||
if (expandedJob === name) {
|
||||
function toggleJob(job: JobSummary) {
|
||||
if (expandedJob === job.name) {
|
||||
expandedJob = null;
|
||||
} else {
|
||||
expandedJob = name;
|
||||
// Lazy-load on first open, refresh on subsequent opens.
|
||||
void loadRuns(name);
|
||||
expandedJob = job.name;
|
||||
// Lazy-load on first open, refresh on subsequent opens. Only
|
||||
// recoverable jobs have runs to load — the others expand purely
|
||||
// to show their description.
|
||||
if (isRecoverable(job)) void loadRuns(job.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,8 +460,9 @@
|
||||
* Per-severity finding counts from `last_outcome.extra.severity_counts`
|
||||
* (a JSON object populated by `run_or_resume`). Missing / older
|
||||
* runs return an empty record — callers should tolerate absent keys.
|
||||
* The three severity values are the ones consistency tenants emit
|
||||
* today: `data_loss`, `inconsistent`, `anomaly`.
|
||||
* Severity values emitted today: `data_loss`, `inconsistent`,
|
||||
* `anomaly`. The set is open (the column is TEXT), so unknown keys
|
||||
* must degrade rather than throw.
|
||||
*/
|
||||
function lastSeverityCounts(job: JobSummary): Record<string, number> {
|
||||
if (!job.last_outcome || job.last_outcome.outcome !== 'ok') return {};
|
||||
@@ -480,6 +483,13 @@
|
||||
return (s.data_loss ?? 0) + (s.inconsistent ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Informational findings. `anomaly` is the wire value; "notice" is
|
||||
* what the panel calls it — there is no separate `notice` severity.
|
||||
* A job that acted on what it found (a repair run deleting an
|
||||
* orphaned sidecar) records the same severity and says so in the
|
||||
* finding's `detail`.
|
||||
*/
|
||||
function anomalyFindingCount(job: JobSummary): number {
|
||||
return lastSeverityCounts(job).anomaly ?? 0;
|
||||
}
|
||||
@@ -627,38 +637,75 @@
|
||||
// Jobs that respect `?deep=true`:
|
||||
// * `consistency_batch` — propagates deep to every child that
|
||||
// understands it
|
||||
// * `blobs_consistency` — deep mode re-reads + re-hashes every
|
||||
// blob for silent bit-rot detection (severity `data_loss`).
|
||||
// Full read of storage; can take hours on big installs — the
|
||||
// "Run" (normal) button on the same row does the cheap
|
||||
// existence probes only.
|
||||
// * `backend_consistency` — deep mode re-reads + re-hashes every
|
||||
// matched blob for silent bit-rot detection (severity
|
||||
// `data_loss`). Full read of storage; can take hours on big
|
||||
// installs — the "Run" button on the same row does the
|
||||
// enumeration merge-join only. This was `blobs_consistency`
|
||||
// until that tenant became database-only.
|
||||
function supportsDeep(name: string): boolean {
|
||||
return name === 'consistency_batch' || name === 'blobs_consistency';
|
||||
return name === 'consistency_batch' || name === 'backend_consistency';
|
||||
}
|
||||
|
||||
// Jobs whose handler consults `args.repair` and applies a
|
||||
// corrective UPDATE against the finding it just emitted. Only the
|
||||
// two ref_count tenants today; `consistency_batch` also accepts
|
||||
// the flag (fans out to both) and is surfaced separately as the
|
||||
// top-bar "Repair ref_counts" button. Keep this list narrow —
|
||||
// adding a job here without a matching backend handler produces a
|
||||
// silently no-op button that confuses operators.
|
||||
function supportsRepair(name: string): boolean {
|
||||
return name === 'blobs_consistency' || name === 'manifests_consistency';
|
||||
// Whether `?repair=true` does anything for this job — declared by the
|
||||
// handler itself via `repair_description()`, not by a name allowlist
|
||||
// here. The allowlist this replaces named only the two ref_count
|
||||
// tenants and silently omitted every repair-capable job added since,
|
||||
// so the thumbnail imports could not be run in repair mode from the
|
||||
// panel at all despite supporting it.
|
||||
function supportsRepair(job: JobSummary): boolean {
|
||||
return !!job.repair_description;
|
||||
}
|
||||
|
||||
async function onTriggerWithRepairConfirm(name: string) {
|
||||
// What the repair adds, in the handler's own words. The backend owns
|
||||
// this string precisely because the wording differs per job: correcting
|
||||
// a counter and unlinking files off disk are not the same warning, and
|
||||
// the frontend has no way to tell them apart.
|
||||
async function onTriggerWithRepairConfirm(job: JobSummary) {
|
||||
const ok = await confirmDialog({
|
||||
title: t('admin.jobs.run_repair_confirm_title', 'Repair drifted ref_counts?'),
|
||||
message: t(
|
||||
'admin.jobs.run_repair_confirm_body_scoped',
|
||||
{ name },
|
||||
'Runs {{name}} and applies a corrective UPDATE to any counter that disagrees with its live reference count. Content-safe: only counters change; blob content and file rows are untouched.'
|
||||
title: t(
|
||||
'admin.jobs.run_repair_confirm_title_scoped',
|
||||
{ name: job.name },
|
||||
'Run {{name}} in repair mode?'
|
||||
),
|
||||
message: job.repair_description ?? '',
|
||||
confirmText: t('admin.jobs.run_repair_confirm', 'Repair'),
|
||||
danger: true
|
||||
});
|
||||
if (ok) await onTrigger(name, { repair: true });
|
||||
if (ok) await onTrigger(job.name, { repair: true });
|
||||
}
|
||||
|
||||
// Confirmation before a plain run of a job that writes. `never` jobs
|
||||
// trigger straight through — that is the point of the flag — and
|
||||
// `on_repair_only` jobs are read-only until the repair variant is
|
||||
// picked, which carries its own confirm.
|
||||
async function onTriggerGuarded(job: JobSummary) {
|
||||
if (job.mutates === 'always') {
|
||||
const ok = await confirmDialog({
|
||||
title: t('admin.jobs.run_mutating_confirm_title', { name: job.name }, 'Run {{name}}?'),
|
||||
message:
|
||||
job.description ||
|
||||
t('admin.jobs.run_mutating_confirm_body', 'This job changes stored state when it runs.'),
|
||||
confirmText: t('admin.jobs.run', 'Run'),
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
await onTrigger(job.name);
|
||||
}
|
||||
|
||||
// Row badge. `never` is the one worth stating outright — it is the
|
||||
// answer to "is it safe to click this on production?", and it is the
|
||||
// question an operator asks before every trigger.
|
||||
function mutatesLabel(job: JobSummary): string | null {
|
||||
switch (job.mutates) {
|
||||
case 'never':
|
||||
return t('admin.jobs.mutates_never', 'read-only');
|
||||
case 'on_repair_only':
|
||||
return t('admin.jobs.mutates_on_repair_only', 'read-only unless repaired');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRunning(job: JobSummary): boolean {
|
||||
@@ -679,11 +726,13 @@
|
||||
// coordinator is registered (should always be true post-Slice 5,
|
||||
// but check defensively so the button doesn't appear on an old
|
||||
// deployment before this component is upgraded).
|
||||
// Coordinator registration flag — set imperatively in
|
||||
// `loadJobs` because `jobs` no longer contains the
|
||||
// `consistency_batch` row (filtered out to avoid duplicating the
|
||||
// top-bar action buttons).
|
||||
let hasBatch = $state(false);
|
||||
// Held as the whole summary rather than a boolean because the
|
||||
// top-bar buttons need its `repair_description` — the coordinator
|
||||
// describes its own repair semantics, same as every table row.
|
||||
// Set imperatively in `loadJobs` because `jobs` no longer contains
|
||||
// the `consistency_batch` row (filtered out to avoid duplicating
|
||||
// the top-bar action buttons).
|
||||
let batchJob = $state<JobSummary | null>(null);
|
||||
</script>
|
||||
|
||||
<section class="jobs-panel">
|
||||
@@ -697,10 +746,12 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="jobs-panel__header-actions">
|
||||
{#if hasBatch}
|
||||
{#if batchJob}
|
||||
{@const batch = batchJob}
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--primary"
|
||||
disabled={busyKeys.has('trigger:consistency_batch')}
|
||||
title={batch.description || undefined}
|
||||
onclick={() => onTrigger('consistency_batch')}
|
||||
>
|
||||
<Icon name="play" />
|
||||
@@ -718,43 +769,28 @@
|
||||
<Icon name="play" />
|
||||
{t('admin.jobs.run_deep', 'Run deep')}
|
||||
</button>
|
||||
<!-- Repair goes behind a confirm because it issues corrective
|
||||
UPDATEs on `storage.blobs.ref_count` and
|
||||
`storage.chunk_manifests.ref_count`. Content-safe (only
|
||||
counters change, matching the auditor's computed truth)
|
||||
and race-safe (each UPDATE recomputes inside the same
|
||||
statement), but writing-a-lot is still writing-a-lot.
|
||||
One click, one confirm, one batch dispatched to both
|
||||
refcount tenants via consistency_batch's arg
|
||||
propagation. See `?repair=true` on
|
||||
`POST /api/admin/jobs/{name}/trigger`. -->
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--warn"
|
||||
disabled={busyKeys.has('trigger:consistency_batch:repair')}
|
||||
title={t(
|
||||
'admin.jobs.run_repair_hint',
|
||||
'Corrects any drifted ref_counts (blobs + manifests) found by the audit. Content-safe — only counters change, not data.'
|
||||
)}
|
||||
onclick={async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t('admin.jobs.run_repair_confirm_title', 'Repair drifted ref_counts?'),
|
||||
message: t(
|
||||
'admin.jobs.run_repair_confirm_body',
|
||||
'Runs the audit against every blob + manifest and applies a corrective UPDATE to any counter that disagrees with its live reference count. Content-safe: only the counter changes; blob content and file rows are untouched. Safe to run at any time; a discovery-only run happens first so you can see the drift before this repair overwrites it.'
|
||||
),
|
||||
confirmText: t('admin.jobs.run_repair_confirm', 'Repair'),
|
||||
danger: true
|
||||
});
|
||||
if (ok) await onTrigger('consistency_batch', { repair: true });
|
||||
}}
|
||||
>
|
||||
<Icon name="cog" />
|
||||
{t('admin.jobs.run_repair', 'Repair ref_counts')}
|
||||
</button>
|
||||
<!-- Repair goes behind a confirm because it fans `?repair=true`
|
||||
out to every sub-check that acts on it. The confirmation
|
||||
text comes from the coordinator's own
|
||||
`repair_description` rather than being written here —
|
||||
what repair means changes as tenants gain repair arms,
|
||||
and this button would otherwise keep describing only the
|
||||
two refcount ones. -->
|
||||
{#if batch.repair_description}
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--warn"
|
||||
disabled={busyKeys.has('trigger:consistency_batch:repair')}
|
||||
title={batch.repair_description}
|
||||
onclick={() => onTriggerWithRepairConfirm(batch)}
|
||||
>
|
||||
<Icon name="cog" />
|
||||
{t('admin.jobs.run_repair', 'Repair ref_counts')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Purge is orthogonal to consistency — it works even
|
||||
when the batch coordinator isn't registered, so it
|
||||
lives outside the {#if hasBatch}. Opens a modal so
|
||||
lives outside the batch block. Opens a modal so
|
||||
the operator picks a retention window with intent
|
||||
(no accidental delete-all). -->
|
||||
<button
|
||||
@@ -798,7 +834,12 @@
|
||||
{@const runsErr = runsErrorByJob[job.name]}
|
||||
{@const runsLoading = runsLoadingByJob[job.name]}
|
||||
{@const expandedRun = expandedRunByJob[job.name] ?? null}
|
||||
{@const canExpand = isRecoverable(job)}
|
||||
<!-- Expandable if there is anything to show: a run history,
|
||||
a description, or both. Gating on `recoverable` alone
|
||||
would leave the plain periodic jobs (dedup_gc,
|
||||
trash_cleanup, …) with no way to reach their
|
||||
description at all. -->
|
||||
{@const canExpand = isRecoverable(job) || !!job.description}
|
||||
<tr class="jobs-panel__row" class:jobs-panel__row--expanded={expandedJob === job.name}>
|
||||
<td>
|
||||
{#if canExpand}
|
||||
@@ -806,7 +847,7 @@
|
||||
type="button"
|
||||
class="jobs-panel__expand"
|
||||
aria-expanded={expandedJob === job.name}
|
||||
onclick={() => toggleJob(job.name)}
|
||||
onclick={() => toggleJob(job)}
|
||||
>
|
||||
<Icon name={expandedJob === job.name ? 'chevron-down' : 'chevron-right'} />
|
||||
<span class="jobs-panel__name">{job.name}</span>
|
||||
@@ -814,8 +855,43 @@
|
||||
{:else}
|
||||
<span class="jobs-panel__name jobs-panel__name--flat">{job.name}</span>
|
||||
{/if}
|
||||
{#if mutatesLabel(job)}
|
||||
<span class="jobs-panel__pill jobs-panel__pill--readonly">
|
||||
{mutatesLabel(job)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<!-- "At boot" belongs in the cadence column: it answers
|
||||
WHEN this job runs, which is the same question
|
||||
`interval_ms` answers. Beside the name it read as a
|
||||
property of the job rather than of its schedule, and
|
||||
these two facts have to be read together — a job with
|
||||
no interval that fires at boot is not on-demand, and
|
||||
the row said "on-demand" next to a badge saying
|
||||
otherwise. -->
|
||||
<td class="jobs-panel__muted">
|
||||
{cadenceLabel(job)}
|
||||
{#if job.startup}
|
||||
<span
|
||||
class="jobs-panel__pill"
|
||||
class:jobs-panel__pill--paused={job.startup.repair}
|
||||
class:jobs-panel__pill--neutral={!job.startup.repair}
|
||||
title={job.startup.repair
|
||||
? t(
|
||||
'admin.jobs.startup_repair_tooltip',
|
||||
'Configured in OXICLOUD_STARTUP_JOBS to run in repair mode at every boot.'
|
||||
)
|
||||
: t(
|
||||
'admin.jobs.startup_tooltip',
|
||||
'Configured in OXICLOUD_STARTUP_JOBS to run at every boot.'
|
||||
)}
|
||||
>
|
||||
{job.startup.repair
|
||||
? t('admin.jobs.startup_repair', 'at boot · repair')
|
||||
: t('admin.jobs.startup', 'at boot')}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="jobs-panel__muted">{cadenceLabel(job)}</td>
|
||||
<td class="jobs-panel__muted">{timeAgo(job.last_run_at)}</td>
|
||||
<td>
|
||||
<div class="jobs-panel__outcome-cell">
|
||||
@@ -898,15 +974,16 @@
|
||||
button — no chevron, no menu, no extra
|
||||
width. Preserves one-click discovery for
|
||||
the common case. -->
|
||||
{@const hasRunVariants = supportsDeep(job.name) || supportsRepair(job.name)}
|
||||
{@const hasRunVariants = supportsDeep(job.name) || supportsRepair(job)}
|
||||
<span class="jobs-panel__split">
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--small"
|
||||
class:jobs-panel__split-main={hasRunVariants}
|
||||
disabled={busyKeys.has(`trigger:${job.name}`)}
|
||||
title={job.description || undefined}
|
||||
onclick={() => {
|
||||
closeAllRunMenus();
|
||||
void onTrigger(job.name);
|
||||
void onTriggerGuarded(job);
|
||||
}}
|
||||
>
|
||||
{t('admin.jobs.run', 'Run')}
|
||||
@@ -942,19 +1019,16 @@
|
||||
<span>{t('admin.jobs.run_deep', 'Run deep')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if supportsRepair(job.name)}
|
||||
{#if supportsRepair(job)}
|
||||
<button
|
||||
type="button"
|
||||
class="jobs-panel__run-menu-item jobs-panel__run-menu-item--warn"
|
||||
role="menuitem"
|
||||
disabled={busyKeys.has(`trigger:${job.name}:repair`)}
|
||||
title={t(
|
||||
'admin.jobs.run_repair_hint',
|
||||
'Corrects any drifted ref_counts (blobs + manifests) found by the audit. Content-safe — only counters change, not data.'
|
||||
)}
|
||||
title={job.repair_description}
|
||||
onclick={() => {
|
||||
closeAllRunMenus();
|
||||
void onTriggerWithRepairConfirm(job.name);
|
||||
void onTriggerWithRepairConfirm(job);
|
||||
}}
|
||||
>
|
||||
<Icon name="cog" />
|
||||
@@ -966,7 +1040,11 @@
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isRunning(job) && canExpand}
|
||||
<!-- `isRecoverable`, not `canExpand`: the latter now also
|
||||
covers rows that expand only to show a description,
|
||||
and those must not gain a Cancel button they never
|
||||
had. -->
|
||||
{#if isRunning(job) && isRecoverable(job)}
|
||||
{#if isRecoverable(job)}
|
||||
<!-- Recoverable running: [Pause] preserves cursor
|
||||
for later resume; [Cancel] abandons terminally
|
||||
@@ -1006,7 +1084,20 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{#if expandedJob === job.name}
|
||||
<!-- First row of the expanded block, and only visible there:
|
||||
the description answers "what is this job" before the
|
||||
run history answers "what did it do", and keeping it
|
||||
folded keeps the collapsed table scannable — 17 rows of
|
||||
two-line prose is not a table any more. -->
|
||||
{#if expandedJob === job.name && job.description}
|
||||
<tr class="jobs-panel__desc-row">
|
||||
<td colspan="6">
|
||||
<p class="jobs-panel__description">{job.description}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
{#if expandedJob === job.name && isRecoverable(job)}
|
||||
<tr class="jobs-panel__runs">
|
||||
<td colspan="6">
|
||||
<div class="jobs-panel__runs-inner">
|
||||
@@ -1621,6 +1712,34 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* "read-only" sits beside the job name and answers the question an
|
||||
operator asks before every trigger. Deliberately quiet — it marks
|
||||
the safe case, so it should not compete with outcome pills. */
|
||||
.jobs-panel__pill--readonly {
|
||||
margin-left: 0.4rem;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Opens the expanded block, so it carries the drawer's background and
|
||||
drops its own separator — the runs table below it is part of the
|
||||
same block, not a new entry. */
|
||||
.jobs-panel__desc-row td {
|
||||
padding-left: 2rem; /* clears the chevron, lines up with the name */
|
||||
border-bottom-color: transparent;
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.jobs-panel__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.jobs-panel__runs {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
@@ -1277,9 +1277,15 @@
|
||||
"run_deep_hint": "Also runs slow variants (blob re-hash, bitrot detection).",
|
||||
"run_repair": "Repair ref_counts",
|
||||
"run_repair_hint": "Corrects any drifted ref_counts (blobs + manifests) found by the audit. Content-safe — only counters change, not data.",
|
||||
"run_repair_confirm_title": "Repair drifted ref_counts?",
|
||||
"run_repair_confirm_body": "Runs the audit against every blob + manifest and applies a corrective UPDATE to any counter that disagrees with its live reference count. Content-safe: only the counter changes; blob content and file rows are untouched. Safe to run at any time; a discovery-only run happens first so you can see the drift before this repair overwrites it.",
|
||||
"run_repair_confirm_body_scoped": "Runs {{name}} and applies a corrective UPDATE to any counter that disagrees with its live reference count. Content-safe: only counters change; blob content and file rows are untouched.",
|
||||
"run_repair_confirm_title_scoped": "Run {{name}} in repair mode?",
|
||||
"run_mutating_confirm_title": "Run {{name}}?",
|
||||
"run_mutating_confirm_body": "This job changes stored state when it runs.",
|
||||
"mutates_never": "read-only",
|
||||
"mutates_on_repair_only": "read-only unless repaired",
|
||||
"startup": "at boot",
|
||||
"startup_repair": "at boot · repair",
|
||||
"startup_tooltip": "Configured in OXICLOUD_STARTUP_JOBS to run at every boot.",
|
||||
"startup_repair_tooltip": "Configured in OXICLOUD_STARTUP_JOBS to run in repair mode at every boot.",
|
||||
"run_variants_menu": "Run variants menu",
|
||||
"run_repair_confirm": "Repair",
|
||||
"triggered_ok_repair": "{{name}}: {{n}} counter(s) repaired",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Derived content as blobs — tier-2 refactor, step 5.
|
||||
-- See `docs/plan/derived-blobs.md`.
|
||||
--
|
||||
-- Maps a source Blob to the artifacts derived FROM it: thumbnails today,
|
||||
-- transcodes next. Both the mapping key and the value are BLAKE3 hashes,
|
||||
-- but they mean different things:
|
||||
--
|
||||
-- * `source_hash` — the Blob the artifact was derived from. A
|
||||
-- *dependent* reference: it keeps nothing alive (the file does), and
|
||||
-- when that Blob dies these rows are deleted with it.
|
||||
-- * `blob_hash` — the derived Blob itself. A reference *holder*: it
|
||||
-- bumps `chunk_manifests.ref_count`, which is why
|
||||
-- `ContentDerivedReferenceSource` must be registered before the first
|
||||
-- row is written, or `dedup_gc` reaps the content on its next sweep.
|
||||
--
|
||||
-- KEYING — the rule this table exists to enforce:
|
||||
--
|
||||
-- Bytes that are a pure deterministic function of the source content
|
||||
-- belong here, content-keyed, and dedupe across every file holding
|
||||
-- that content. Bytes that are user-supplied or user-chosen do NOT:
|
||||
-- they must be file-keyed, because content-keying them lets one user's
|
||||
-- upload be served for another user's identical file. Client-uploaded
|
||||
-- previews (PDF page 1, video poster frames) are the live example and
|
||||
-- belong in a separate file-keyed table.
|
||||
--
|
||||
-- `variant` is opaque text. New axes go INSIDE it, never into new
|
||||
-- columns: 'preview-avif' beside 'preview', '720p-av1' beside '720p'.
|
||||
-- That is what keeps this table from growing a column per rendering
|
||||
-- parameter.
|
||||
--
|
||||
-- No FK on either hash column, for the reason
|
||||
-- `20260701000000_content_search_index.sql` already documents: a hash
|
||||
-- resolves to either `storage.blobs` (legacy whole blob) or
|
||||
-- `storage.chunk_manifests` (CDC file hash), so the reference cannot be
|
||||
-- expressed as a single FK. Orphans are reclaimed by GC and reported by
|
||||
-- the consistency jobs instead.
|
||||
--
|
||||
-- No `size` column: the bytes are content-addressed, so their length is
|
||||
-- an immutable fact the blob layer already owns via `blob_hash`.
|
||||
-- `content_type` IS stored — the thumbnail handler byte-sniffs every
|
||||
-- response today, and this retires that.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS storage.content_derived_blobs (
|
||||
source_hash VARCHAR(64) NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('thumbnail', 'transcode')),
|
||||
variant TEXT NOT NULL,
|
||||
blob_hash VARCHAR(64) NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (source_hash, kind, variant)
|
||||
);
|
||||
|
||||
-- Reverse lookup: "what still references this derived Blob?" — used by
|
||||
-- the manifest-level refcount recompute in `manifests_consistency` and by
|
||||
-- `dedup_gc`'s reap predicate.
|
||||
CREATE INDEX IF NOT EXISTS idx_content_derived_blobs_blob_hash
|
||||
ON storage.content_derived_blobs (blob_hash);
|
||||
|
||||
COMMENT ON TABLE storage.content_derived_blobs IS
|
||||
'Server-derived artifacts (thumbnails, transcodes) keyed by the BLAKE3 of their SOURCE content. Content-keyed on purpose: identical content shares one derivation. User-supplied bytes must NOT be stored here — see docs/plan/derived-blobs.md.';
|
||||
|
||||
COMMENT ON COLUMN storage.content_derived_blobs.source_hash IS
|
||||
'The Blob this was derived from. Dependent reference — holds no ref_count; rows are deleted when the source Blob is reaped.';
|
||||
|
||||
COMMENT ON COLUMN storage.content_derived_blobs.blob_hash IS
|
||||
'The derived Blob. Reference HOLDER — bumps chunk_manifests.ref_count via DedupService::add_reference.';
|
||||
|
||||
COMMENT ON COLUMN storage.content_derived_blobs.variant IS
|
||||
'Opaque rendering discriminator (icon | preview | large | 720p...). New axes go inside this string, never into new columns.';
|
||||
@@ -0,0 +1,332 @@
|
||||
-- Step 8 of `docs/plan/derived-blobs.md` — single-source the copy fan-out.
|
||||
--
|
||||
-- "What follows a file when the file is copied" was written twice: once in
|
||||
-- the `copy_file` CTE (Rust, `file_blob_write_repository.rs`) and once in
|
||||
-- `storage.copy_folder_tree`. They had already drifted — the tree path
|
||||
-- bumped `storage.blobs` only, missing manifests entirely, which was silent
|
||||
-- data loss on a multi-chunk file (fixed in `20261016000000`, and the fix
|
||||
-- had to be written a second time rather than in one place).
|
||||
--
|
||||
-- The plan adds file-keyed satellite tables (`file_attached_blobs`, step 9).
|
||||
-- Adding them against two copy sites means writing the same cascade a third
|
||||
-- and fourth time, into sites that have already proven they drift. So the
|
||||
-- fan-out gets exactly one home first.
|
||||
--
|
||||
-- Two functions land here:
|
||||
--
|
||||
-- * `storage.add_blob_references(TEXT[])` — the manifest-first reference
|
||||
-- contract, expressed once for SQL callers. `DedupService::add_reference`
|
||||
-- is the Rust twin; they must change together, which is why the shared
|
||||
-- contract is spelled out in both doc comments.
|
||||
--
|
||||
-- * `storage.copy_file_satellites(UUID[], UUID[])` — everything that
|
||||
-- follows a file on copy. The body IS the copy-semantics declaration:
|
||||
-- what is absent is a documented decision (see the trailing comments),
|
||||
-- not an omission someone has to notice.
|
||||
--
|
||||
-- Set-based rather than per-row on purpose. A per-row helper would have made
|
||||
-- a 10k-file folder copy 10k function calls; taking arrays keeps the tree
|
||||
-- path's single-statement cost while still having one implementation. The
|
||||
-- single-file path passes one-element arrays.
|
||||
|
||||
-- ── The reference contract, for SQL callers ──────────────────────────────
|
||||
--
|
||||
-- Increment the reference count for each hash in `p_hashes`, counting
|
||||
-- repeats (pass the hash once per referencing row). Returns the hashes that
|
||||
-- matched NEITHER table, so callers can decide how loud to be — a copy
|
||||
-- inherits a pre-existing breakage and should warn, whereas an ingest
|
||||
-- referencing a nonexistent blob is a hard error.
|
||||
--
|
||||
-- MANIFEST FIRST, `storage.blobs` only as fallback. The order is the whole
|
||||
-- point: a CDC file's `blob_hash` names a manifest
|
||||
-- (`chunk_manifests.file_hash`), not a chunk, so bumping `storage.blobs`
|
||||
-- first would match nothing for a multi-chunk file and take no reference at
|
||||
-- all.
|
||||
--
|
||||
-- The `NOT EXISTS (bumped)` guard on the blobs branch is load-bearing. For a
|
||||
-- SINGLE-chunk file the whole-file hash EQUALS its lone chunk's hash (both
|
||||
-- are BLAKE3 over the same bytes), so without the guard one reference would
|
||||
-- be counted at both levels — turning an under-count into an over-count.
|
||||
--
|
||||
-- Mirrors `DedupService::add_reference`, including the asymmetry on
|
||||
-- `orphaned_at`: only `storage.blobs` carries that column, so only the blobs
|
||||
-- branch clears it. A chunk resurrected inside its GC grace window must lose
|
||||
-- its orphan stamp or `dedup_gc` reaps live content.
|
||||
CREATE OR REPLACE FUNCTION storage.add_blob_references(p_hashes TEXT[])
|
||||
RETURNS TEXT[] AS $$
|
||||
DECLARE
|
||||
v_unmatched TEXT[];
|
||||
BEGIN
|
||||
IF p_hashes IS NULL OR cardinality(p_hashes) = 0 THEN
|
||||
RETURN ARRAY[]::TEXT[];
|
||||
END IF;
|
||||
|
||||
WITH hc AS (
|
||||
SELECT h AS blob_hash, COUNT(*)::int AS cnt
|
||||
FROM unnest(p_hashes) AS h
|
||||
WHERE h IS NOT NULL
|
||||
GROUP BY h
|
||||
),
|
||||
bumped_manifests AS (
|
||||
UPDATE storage.chunk_manifests m
|
||||
SET ref_count = m.ref_count + hc.cnt
|
||||
FROM hc
|
||||
WHERE m.file_hash = hc.blob_hash
|
||||
RETURNING m.file_hash
|
||||
),
|
||||
bumped_blobs AS (
|
||||
UPDATE storage.blobs b
|
||||
SET ref_count = b.ref_count + hc.cnt,
|
||||
orphaned_at = NULL
|
||||
FROM hc
|
||||
WHERE b.hash = hc.blob_hash
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM bumped_manifests WHERE file_hash = hc.blob_hash
|
||||
)
|
||||
RETURNING b.hash
|
||||
)
|
||||
SELECT COALESCE(array_agg(hc.blob_hash), ARRAY[]::TEXT[])
|
||||
INTO v_unmatched
|
||||
FROM hc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM bumped_manifests WHERE file_hash = hc.blob_hash)
|
||||
AND NOT EXISTS (SELECT 1 FROM bumped_blobs WHERE hash = hc.blob_hash);
|
||||
|
||||
RETURN v_unmatched;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION storage.add_blob_references(TEXT[]) IS
|
||||
'Manifest-first blob reference increment for SQL callers. Returns hashes '
|
||||
'that matched no registry row. Rust twin: DedupService::add_reference — '
|
||||
'change both together.';
|
||||
|
||||
-- ── What follows a file on copy ──────────────────────────────────────────
|
||||
--
|
||||
-- `p_old_ids[i]` is copied to `p_new_ids[i]`; the new `storage.files` rows
|
||||
-- must already be inserted and visible (both callers insert in an earlier
|
||||
-- statement of the same transaction).
|
||||
--
|
||||
-- Every satellite of a copied file belongs in this body. What is NOT here is
|
||||
-- listed at the bottom, with the reason — the taxonomy is executable rather
|
||||
-- than living in a document that drifts from the code.
|
||||
CREATE OR REPLACE FUNCTION storage.copy_file_satellites(
|
||||
p_old_ids UUID[],
|
||||
p_new_ids UUID[]
|
||||
) RETURNS void AS $$
|
||||
DECLARE
|
||||
v_unmatched TEXT[];
|
||||
BEGIN
|
||||
IF p_old_ids IS NULL OR cardinality(p_old_ids) = 0 THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF p_new_ids IS NULL OR cardinality(p_old_ids) <> cardinality(p_new_ids) THEN
|
||||
-- Positional correspondence is the whole interface; a length
|
||||
-- mismatch would silently attach satellites to the wrong file.
|
||||
RAISE EXCEPTION
|
||||
'copy_file_satellites: id arrays must correspond positionally (% old vs % new)',
|
||||
cardinality(p_old_ids), COALESCE(cardinality(p_new_ids), 0);
|
||||
END IF;
|
||||
|
||||
-- 1. WebDAV dead properties. RFC 4918 §8.8 requires COPY to duplicate
|
||||
-- them: properties describe the resource, and the copy is a resource.
|
||||
INSERT INTO storage.webdav_dead_properties
|
||||
(file_id, namespace, local_name, value)
|
||||
SELECT m.new_id, dp.namespace, dp.local_name, dp.value
|
||||
FROM unnest(p_old_ids, p_new_ids) AS m(old_id, new_id)
|
||||
JOIN storage.webdav_dead_properties dp ON dp.file_id = m.old_id;
|
||||
|
||||
-- 2. A reference on the copied content, so deleting the original cannot
|
||||
-- reap bytes the copy still needs. Read from the NEW rows rather than
|
||||
-- the old ones: that is what makes an unreferenceable copy impossible
|
||||
-- to create, since a row that failed to insert contributes nothing.
|
||||
SELECT storage.add_blob_references(array_agg(f.blob_hash))
|
||||
INTO v_unmatched
|
||||
FROM unnest(p_new_ids) AS n(id)
|
||||
JOIN storage.files f ON f.id = n.id
|
||||
WHERE NOT f.is_trashed;
|
||||
|
||||
IF v_unmatched IS NOT NULL AND cardinality(v_unmatched) > 0 THEN
|
||||
-- Warn, do not abort. A missing registry row means the SOURCE file
|
||||
-- was already broken; the copy merely inherits it. Failing here
|
||||
-- would abort an entire folder copy over one pre-existing fault,
|
||||
-- which is worse than completing it and reporting. The blob-level
|
||||
-- audit jobs are what surface the underlying breakage.
|
||||
RAISE WARNING
|
||||
'copy_file_satellites: % copied file(s) reference a blob with no registry row (first: %); source was already broken',
|
||||
cardinality(v_unmatched), v_unmatched[1];
|
||||
END IF;
|
||||
|
||||
-- ── Deliberately absent ──────────────────────────────────────────────
|
||||
--
|
||||
-- storage.comments (future): NOT copied. A copy is a new artifact; the
|
||||
-- discussion belongs to the original.
|
||||
--
|
||||
-- storage.file_attached_blobs (step 9): WILL be copied here, with a
|
||||
-- reference taken per attached blob_hash via add_blob_references.
|
||||
--
|
||||
-- content_derived_blobs, blob_extracted_text, faces.faces: content-keyed.
|
||||
-- The copy shares the source's hash, so it already sees them — copying
|
||||
-- would duplicate rows that are keyed on the very thing being shared.
|
||||
--
|
||||
-- storage.favorites, recent_items, shares: properties of the ORIGINAL's
|
||||
-- relationship to users, not of its content.
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION storage.copy_file_satellites(UUID[], UUID[]) IS
|
||||
'Single source of truth for what follows a file on copy. Both copy paths '
|
||||
'(single-file and copy_folder_tree) call it. Adding a file-keyed satellite '
|
||||
'table means editing this function, and only this function.';
|
||||
|
||||
-- ── Route copy_folder_tree through it ────────────────────────────────────
|
||||
--
|
||||
-- Only two blocks change versus `20261016000000`: the inline reference bump
|
||||
-- and the per-file dead-property INSERT are both replaced by one
|
||||
-- `copy_file_satellites` call. The folder dead-property INSERT stays inline
|
||||
-- — folders are not files and have no satellite fan-out to share.
|
||||
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
|
||||
p_source_id UUID,
|
||||
p_target_parent_id UUID, -- NULL = copy to root (keeps source drive)
|
||||
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
|
||||
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
|
||||
DECLARE
|
||||
v_root_lpath ltree;
|
||||
v_root_depth INT;
|
||||
v_max_depth INT;
|
||||
v_level INT;
|
||||
v_folders BIGINT := 0;
|
||||
v_files BIGINT := 0;
|
||||
v_inserted BIGINT;
|
||||
v_new_root UUID;
|
||||
v_dest_drive_id UUID;
|
||||
BEGIN
|
||||
-- Validate source exists.
|
||||
SELECT fo.lpath, nlevel(fo.lpath)
|
||||
INTO v_root_lpath, v_root_depth
|
||||
FROM storage.folders fo
|
||||
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
|
||||
|
||||
IF v_root_lpath IS NULL THEN
|
||||
RAISE EXCEPTION 'Source folder not found: %', p_source_id
|
||||
USING ERRCODE = 'P0002'; -- no_data_found
|
||||
END IF;
|
||||
|
||||
-- Resolve destination drive_id once up front (cross-drive copy path).
|
||||
IF p_target_parent_id IS NULL THEN
|
||||
SELECT fo.drive_id INTO v_dest_drive_id
|
||||
FROM storage.folders fo
|
||||
WHERE fo.id = p_source_id;
|
||||
ELSE
|
||||
SELECT fo.drive_id INTO v_dest_drive_id
|
||||
FROM storage.folders fo
|
||||
WHERE fo.id = p_target_parent_id AND NOT fo.is_trashed;
|
||||
IF v_dest_drive_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Target parent folder not found: %', p_target_parent_id
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Temp mapping: every folder in the subtree → new UUID.
|
||||
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
|
||||
old_id UUID PRIMARY KEY,
|
||||
new_id UUID NOT NULL DEFAULT gen_random_uuid()
|
||||
) ON COMMIT DROP;
|
||||
TRUNCATE _copy_map;
|
||||
|
||||
INSERT INTO _copy_map(old_id)
|
||||
SELECT fo.id
|
||||
FROM storage.folders fo
|
||||
WHERE NOT fo.is_trashed
|
||||
AND fo.lpath <@ v_root_lpath;
|
||||
|
||||
SELECT cm.new_id INTO v_new_root
|
||||
FROM _copy_map cm WHERE cm.old_id = p_source_id;
|
||||
|
||||
SELECT MAX(nlevel(fo.lpath))
|
||||
INTO v_max_depth
|
||||
FROM storage.folders fo
|
||||
JOIN _copy_map cm ON fo.id = cm.old_id;
|
||||
|
||||
-- ── Insert folders level by level ──
|
||||
-- Post-D7: `user_id` intentionally omitted from the column list so
|
||||
-- copied rows leave the (now-nullable) column NULL. Provenance is
|
||||
-- carried by `created_by` / `updated_by` (§14 columns) — preserved
|
||||
-- from source so authorship survives the copy.
|
||||
FOR v_level IN v_root_depth .. v_max_depth LOOP
|
||||
INSERT INTO storage.folders(
|
||||
id, name, parent_id,
|
||||
drive_id, created_by, updated_by
|
||||
)
|
||||
SELECT cm.new_id,
|
||||
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
|
||||
THEN p_dest_name ELSE fo.name END,
|
||||
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
|
||||
ELSE pm.new_id END,
|
||||
v_dest_drive_id,
|
||||
fo.created_by,
|
||||
fo.updated_by
|
||||
FROM storage.folders fo
|
||||
JOIN _copy_map cm ON fo.id = cm.old_id
|
||||
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
|
||||
WHERE NOT fo.is_trashed
|
||||
AND nlevel(fo.lpath) = v_level;
|
||||
|
||||
GET DIAGNOSTICS v_inserted = ROW_COUNT;
|
||||
v_folders := v_folders + v_inserted;
|
||||
END LOOP;
|
||||
|
||||
-- Temp mapping for files src→dst (dst ids pre-allocated so we can hand
|
||||
-- both sides to copy_file_satellites below).
|
||||
CREATE TEMP TABLE IF NOT EXISTS _copy_file_map(
|
||||
old_id UUID PRIMARY KEY,
|
||||
new_id UUID NOT NULL DEFAULT gen_random_uuid()
|
||||
) ON COMMIT DROP;
|
||||
TRUNCATE _copy_file_map;
|
||||
|
||||
INSERT INTO _copy_file_map(old_id)
|
||||
SELECT f.id
|
||||
FROM storage.files f
|
||||
JOIN _copy_map cm ON f.folder_id = cm.old_id
|
||||
WHERE NOT f.is_trashed;
|
||||
|
||||
-- ── Batch copy all files (zero-copy: same blob_hash) ──
|
||||
-- Post-D7: `user_id` omitted. Provenance via `created_by`/`updated_by`.
|
||||
INSERT INTO storage.files(
|
||||
id, name, folder_id, blob_hash, size, mime_type,
|
||||
media_sort_date, drive_id, created_by, updated_by
|
||||
)
|
||||
SELECT fm.new_id, f.name, cm.new_id, f.blob_hash, f.size,
|
||||
f.mime_type, f.media_sort_date, v_dest_drive_id, f.created_by,
|
||||
f.updated_by
|
||||
FROM storage.files f
|
||||
JOIN _copy_map cm ON f.folder_id = cm.old_id
|
||||
JOIN _copy_file_map fm ON fm.old_id = f.id
|
||||
WHERE NOT f.is_trashed;
|
||||
|
||||
GET DIAGNOSTICS v_files = ROW_COUNT;
|
||||
|
||||
-- Everything that follows a file on copy — blob references and dead
|
||||
-- properties — in one call, shared with the single-file copy path.
|
||||
--
|
||||
-- Both aggregates order by `old_id`, which is what makes the two arrays
|
||||
-- correspond positionally; `array_agg` without a matching ORDER BY would
|
||||
-- be free to pair a file with another file's satellites.
|
||||
IF v_files > 0 THEN
|
||||
PERFORM storage.copy_file_satellites(
|
||||
(SELECT array_agg(old_id ORDER BY old_id) FROM _copy_file_map),
|
||||
(SELECT array_agg(new_id ORDER BY old_id) FROM _copy_file_map)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Folder dead properties. Files are handled inside copy_file_satellites;
|
||||
-- folders have no other satellites, so this stays here.
|
||||
INSERT INTO storage.webdav_dead_properties
|
||||
(folder_id, namespace, local_name, value)
|
||||
SELECT cm.new_id, dp.namespace, dp.local_name, dp.value
|
||||
FROM storage.webdav_dead_properties dp
|
||||
JOIN _copy_map cm ON dp.folder_id = cm.old_id;
|
||||
|
||||
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,157 @@
|
||||
-- Step 9 of `docs/plan/derived-blobs.md` — the file-keyed half of the pair.
|
||||
--
|
||||
-- `content_derived_blobs` holds bytes that are a pure deterministic function
|
||||
-- of a file's content, so they are keyed by that content and shared across
|
||||
-- every file holding it. This table holds the opposite: bytes a USER supplied
|
||||
-- or chose. Those must never be shared across files, and the key is what
|
||||
-- enforces it.
|
||||
--
|
||||
-- The distinction is a security boundary, not a modelling preference. If a
|
||||
-- client-uploaded preview were content-keyed, user A could upload a file plus
|
||||
-- a preview that misrepresents it; when user B later uploads the same bytes,
|
||||
-- dedup would match and B would be served A's preview. Content-keying is only
|
||||
-- safe when the bytes are derivable from the content by the server — nothing
|
||||
-- to poison, because anyone with the same input gets the same output.
|
||||
--
|
||||
-- Required now rather than deferred: the SPA already generates and PUTs
|
||||
-- previews for PDFs, and there is no server-side regeneration path for them,
|
||||
-- so the sidecar migration has nowhere else to put those bytes.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS storage.file_attached_blobs (
|
||||
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('preview', 'subtitle', 'cover_art')),
|
||||
variant TEXT NOT NULL,
|
||||
blob_hash VARCHAR(64) NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
-- Provenance convention: NOT NULL and NO foreign key. A FK with
|
||||
-- ON DELETE SET NULL loses the audit trail exactly when it matters most,
|
||||
-- and without an ON DELETE clause it would block deleting a user
|
||||
-- outright. Deleting the uploader must not rewrite history, so the id is
|
||||
-- retained even once it no longer resolves. Rows imported with no known
|
||||
-- uploader carry the all-zeros sentinel.
|
||||
uploaded_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (file_id, kind, variant)
|
||||
);
|
||||
|
||||
-- Reverse lookup for the reference recompute and for dedup_gc's reap
|
||||
-- predicate: both ask "does any row reference this blob?".
|
||||
CREATE INDEX IF NOT EXISTS idx_file_attached_blobs_blob_hash
|
||||
ON storage.file_attached_blobs (blob_hash);
|
||||
|
||||
-- ── The routing rule, recorded on both tables ────────────────────────────
|
||||
-- Choosing the wrong table is a silent poisoning bug rather than a compile
|
||||
-- error, so the rule lives where an implementor will actually meet it.
|
||||
|
||||
COMMENT ON TABLE storage.file_attached_blobs IS
|
||||
'User-supplied or user-chosen artifacts (client previews, subtitles, cover art) keyed by FILE. File-keyed on purpose: these bytes are not derivable from the file''s content, so sharing them across files with identical content would let one user''s upload be served for another user''s file. Server-derived bytes must NOT be stored here — see docs/plan/derived-blobs.md.';
|
||||
|
||||
COMMENT ON COLUMN storage.file_attached_blobs.file_id IS
|
||||
'The file these bytes are attached to. ON DELETE CASCADE: the attachment has no meaning without it. Deleting the row does NOT release the blob reference — the owning service does that in its on_file_deleted hook.';
|
||||
|
||||
COMMENT ON COLUMN storage.file_attached_blobs.blob_hash IS
|
||||
'The attached Blob. Reference HOLDER — bumps chunk_manifests.ref_count via DedupService::add_reference. Dedup still applies to the bytes themselves; what is forbidden is sharing the MAPPING across files.';
|
||||
|
||||
COMMENT ON COLUMN storage.file_attached_blobs.variant IS
|
||||
'Opaque discriminator within a kind (preview | en | fr | cover...). New axes go inside this string, never into new columns.';
|
||||
|
||||
COMMENT ON COLUMN storage.file_attached_blobs.uploaded_by IS
|
||||
'Who supplied these bytes. Retained after the user is deleted — deleting a user must not rewrite provenance. The only trace that an Editor on a shared file replaced the owner''s preview.';
|
||||
|
||||
COMMENT ON TABLE storage.content_derived_blobs IS
|
||||
'Server-derived artifacts (thumbnails, transcodes) keyed by the BLAKE3 of their SOURCE content. Content-keyed on purpose: identical content shares one derivation. ROUTING RULE — bytes that are a pure deterministic function of the file''s content belong here; bytes that are user-supplied or user-chosen belong in storage.file_attached_blobs, which is file-keyed and never shared. See docs/plan/derived-blobs.md.';
|
||||
|
||||
-- ── Teach the copy fan-out about it ──────────────────────────────────────
|
||||
--
|
||||
-- Only the attached-blobs arm is new versus `20261019000000`; everything
|
||||
-- else is that definition verbatim. Adding a file-keyed table is now one
|
||||
-- edit in one function, which is the whole point of having consolidated the
|
||||
-- two copy paths first.
|
||||
CREATE OR REPLACE FUNCTION storage.copy_file_satellites(
|
||||
p_old_ids UUID[],
|
||||
p_new_ids UUID[]
|
||||
) RETURNS void AS $$
|
||||
DECLARE
|
||||
v_unmatched TEXT[];
|
||||
BEGIN
|
||||
IF p_old_ids IS NULL OR cardinality(p_old_ids) = 0 THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF p_new_ids IS NULL OR cardinality(p_old_ids) <> cardinality(p_new_ids) THEN
|
||||
-- Positional correspondence is the whole interface; a length
|
||||
-- mismatch would silently attach satellites to the wrong file.
|
||||
RAISE EXCEPTION
|
||||
'copy_file_satellites: id arrays must correspond positionally (% old vs % new)',
|
||||
cardinality(p_old_ids), COALESCE(cardinality(p_new_ids), 0);
|
||||
END IF;
|
||||
|
||||
-- 1. WebDAV dead properties. RFC 4918 §8.8 requires COPY to duplicate
|
||||
-- them: properties describe the resource, and the copy is a resource.
|
||||
INSERT INTO storage.webdav_dead_properties
|
||||
(file_id, namespace, local_name, value)
|
||||
SELECT m.new_id, dp.namespace, dp.local_name, dp.value
|
||||
FROM unnest(p_old_ids, p_new_ids) AS m(old_id, new_id)
|
||||
JOIN storage.webdav_dead_properties dp ON dp.file_id = m.old_id;
|
||||
|
||||
-- 2. A reference on the copied content, so deleting the original cannot
|
||||
-- reap bytes the copy still needs. Read from the NEW rows rather than
|
||||
-- the old ones: that is what makes an unreferenceable copy impossible
|
||||
-- to create, since a row that failed to insert contributes nothing.
|
||||
SELECT storage.add_blob_references(array_agg(f.blob_hash))
|
||||
INTO v_unmatched
|
||||
FROM unnest(p_new_ids) AS n(id)
|
||||
JOIN storage.files f ON f.id = n.id
|
||||
WHERE NOT f.is_trashed;
|
||||
|
||||
IF v_unmatched IS NOT NULL AND cardinality(v_unmatched) > 0 THEN
|
||||
-- Warn, do not abort. A missing registry row means the SOURCE file
|
||||
-- was already broken; the copy merely inherits it. Failing here
|
||||
-- would abort an entire folder copy over one pre-existing fault,
|
||||
-- which is worse than completing it and reporting. The blob-level
|
||||
-- audit jobs are what surface the underlying breakage.
|
||||
RAISE WARNING
|
||||
'copy_file_satellites: % copied file(s) reference a blob with no registry row (first: %); source was already broken',
|
||||
cardinality(v_unmatched), v_unmatched[1];
|
||||
END IF;
|
||||
|
||||
-- 3. File-keyed attachments — client previews, subtitles, cover art.
|
||||
-- DUPLICATED rather than shared, because the key is `file_id` and the
|
||||
-- copy is a different file. `uploaded_by` carries over: the person
|
||||
-- who supplied the bytes did not change because someone copied the
|
||||
-- file, and rewriting it to the copier would forge provenance.
|
||||
--
|
||||
-- Each duplicated row is a new reference on the same blob, so the
|
||||
-- bytes are still deduplicated — it is the MAPPING that must not be
|
||||
-- shared, not the content.
|
||||
WITH copied AS (
|
||||
INSERT INTO storage.file_attached_blobs
|
||||
(file_id, kind, variant, blob_hash, content_type, uploaded_by)
|
||||
SELECT m.new_id, a.kind, a.variant, a.blob_hash, a.content_type, a.uploaded_by
|
||||
FROM unnest(p_old_ids, p_new_ids) AS m(old_id, new_id)
|
||||
JOIN storage.file_attached_blobs a ON a.file_id = m.old_id
|
||||
RETURNING blob_hash
|
||||
)
|
||||
SELECT storage.add_blob_references(array_agg(blob_hash))
|
||||
INTO v_unmatched
|
||||
FROM copied;
|
||||
|
||||
IF v_unmatched IS NOT NULL AND cardinality(v_unmatched) > 0 THEN
|
||||
RAISE WARNING
|
||||
'copy_file_satellites: % attached blob(s) reference no registry row (first: %); source was already broken',
|
||||
cardinality(v_unmatched), v_unmatched[1];
|
||||
END IF;
|
||||
|
||||
-- ── Deliberately absent ──────────────────────────────────────────────
|
||||
--
|
||||
-- storage.comments (future): NOT copied. A copy is a new artifact; the
|
||||
-- discussion belongs to the original.
|
||||
--
|
||||
-- content_derived_blobs, blob_extracted_text, faces.faces: content-keyed.
|
||||
-- The copy shares the source's hash, so it already sees them — copying
|
||||
-- would duplicate rows that are keyed on the very thing being shared.
|
||||
--
|
||||
-- storage.favorites, recent_items, shares: properties of the ORIGINAL's
|
||||
-- relationship to users, not of its content.
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Release the blob reference when an attachment row goes away.
|
||||
--
|
||||
-- `storage.file_attached_blobs.file_id` is `ON DELETE CASCADE`, so deleting a
|
||||
-- file removes its attachment rows inside the database — invisible to Rust.
|
||||
-- The lifecycle hook cannot cover this: `on_file_deleted` fires AFTER
|
||||
-- `delete_file`, by which point the cascade has already run and there is
|
||||
-- nothing left to read. The references would survive with no row behind them,
|
||||
-- and `dedup_gc` would see a positive count forever — bytes pinned for good.
|
||||
--
|
||||
-- `storage.decrement_blob_ref()` already exists for exactly this, on
|
||||
-- `storage.files`. It keys off `OLD.blob_hash` and is otherwise
|
||||
-- table-agnostic, so it applies verbatim — and reusing it keeps the
|
||||
-- manifest-first decrement contract defined in one place rather than
|
||||
-- transcribed into a second trigger that can drift.
|
||||
--
|
||||
-- Only DELETE. Replacing a preview updates `blob_hash` in place
|
||||
-- (`store_attached_blob` is ON CONFLICT DO UPDATE), and the reference to the
|
||||
-- superseded blob is released there, in Rust. Adding UPDATE here would
|
||||
-- double-decrement it.
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_file_attached_blobs_decrement_blob_ref
|
||||
AFTER DELETE ON storage.file_attached_blobs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.decrement_blob_ref();
|
||||
|
||||
COMMENT ON TRIGGER trg_file_attached_blobs_decrement_blob_ref
|
||||
ON storage.file_attached_blobs IS
|
||||
'Releases the blob reference held by an attachment row. Needed because file_id is ON DELETE CASCADE, so rows vanish inside the DB where the Rust lifecycle hooks cannot see them.';
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Put the output format inside `variant`, where the plan says new axes go.
|
||||
--
|
||||
-- `content_derived_blobs.variant` held the size alone (`icon` | `preview` |
|
||||
-- `large`), so a size could hold exactly ONE stored artifact regardless of
|
||||
-- codec. That surfaced when the read order flipped (step 10c): a JPEG request
|
||||
-- matched the WebP row and would have been served the wrong codec, which the
|
||||
-- old ordering hid because the `.jpg` sidecar won first. The flip had to be
|
||||
-- gated to WebP, which in turn means JPEG clients can never leave the sidecar
|
||||
-- — so the sidecar can never be deleted.
|
||||
--
|
||||
-- It blocks transcodes harder still: those are multi-format by nature, so
|
||||
-- without a format term two output codecs of one source collide on the
|
||||
-- primary key.
|
||||
--
|
||||
-- Per the column's own comment — "new axes go inside this string, never into
|
||||
-- new columns" — the axis goes in the string rather than into a fourth PK
|
||||
-- column. The PK stays `(source_hash, kind, variant)`.
|
||||
--
|
||||
-- Shape: `{size}.{ext}` — `preview.webp`, `icon.jpg`, and later `720p.webp`
|
||||
-- for transcodes.
|
||||
--
|
||||
-- The backfill is deterministic rather than a guess: `store_derived_blob` has
|
||||
-- only ever been called with `"image/webp"` for thumbnails, so every existing
|
||||
-- thumbnail row is WebP. `content_type` is checked anyway rather than assumed
|
||||
-- — if that assumption is ever wrong, the row is left alone for a human to
|
||||
-- look at instead of being silently mislabelled.
|
||||
|
||||
UPDATE storage.content_derived_blobs
|
||||
SET variant = variant || '.webp'
|
||||
WHERE kind = 'thumbnail'
|
||||
AND content_type = 'image/webp'
|
||||
-- Idempotent: skip anything already carrying a format suffix, so a
|
||||
-- re-applied migration cannot produce `preview.webp.webp`.
|
||||
AND variant NOT LIKE '%.%';
|
||||
|
||||
-- Anything left without a format suffix did not match the WebP assumption.
|
||||
-- Surfaced as a warning rather than coerced: the read path will simply miss
|
||||
-- those rows and fall back to the sidecar, which is safe, whereas guessing a
|
||||
-- codec would serve the wrong bytes.
|
||||
DO $$
|
||||
DECLARE
|
||||
v_unsuffixed INT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_unsuffixed
|
||||
FROM storage.content_derived_blobs
|
||||
WHERE kind = 'thumbnail' AND variant NOT LIKE '%.%';
|
||||
|
||||
IF v_unsuffixed > 0 THEN
|
||||
RAISE WARNING
|
||||
'derived_variant_encodes_format: % thumbnail row(s) have no format suffix (content_type was not image/webp). They will be ignored by the read path and re-derived on demand; inspect before deleting the sidecars.',
|
||||
v_unsuffixed;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN storage.content_derived_blobs.variant IS
|
||||
'Opaque discriminator carrying every axis but the source and the kind: size AND output format, as {size}.{ext} (preview.webp | icon.jpg | 720p.webp). New axes go inside this string, never into new columns. A format term is required — without one, two codecs of the same source collide on the primary key, and the read path cannot tell which codec a row holds.';
|
||||
@@ -244,11 +244,28 @@ pub trait BlobStorageBackend: Send + Sync + 'static {
|
||||
///
|
||||
/// * `cursor` — opaque continuation token from a prior call, or
|
||||
/// `None` to start from the beginning. Format is per-backend
|
||||
/// (local = last path visited; S3 = continuation token; Azure
|
||||
/// = list marker); callers treat it as opaque.
|
||||
/// — **the last blob hash returned by the previous page**.
|
||||
/// Enumeration resumes strictly AFTER that hash.
|
||||
///
|
||||
/// This is deliberately NOT an opaque backend token. Callers may
|
||||
/// synthesise a cursor from any hash they hold, which is what lets a
|
||||
/// consistency sweep merge-join this stream against a
|
||||
/// `storage.blobs` walk and resume both sides from one checkpoint.
|
||||
/// An opaque token would force the backend side to re-enumerate from
|
||||
/// the beginning on every resume.
|
||||
/// * `limit` — soft cap on batch size; backends may return
|
||||
/// fewer (e.g. end of a shard directory).
|
||||
///
|
||||
/// **Entries MUST be returned in ascending hash order**, and pages must
|
||||
/// be contiguous in that order. Every shipped backend already satisfies
|
||||
/// this — local sorts within each shard and walks shards `00`..`ff`
|
||||
/// (the shard IS the hash prefix, so that is globally sorted); S3 and
|
||||
/// Azure list lexicographically by key, and `blobs/<xx>/<hash>` sorts
|
||||
/// identically to `<hash>`. It is stated here because the merge-join in
|
||||
/// `backend_consistency` depends on it: an unordered backend would
|
||||
/// silently emit bogus `blob_missing_from_backend` findings at
|
||||
/// `data_loss` severity.
|
||||
///
|
||||
/// Returns `(entries, next_cursor)`. `next_cursor = None` means
|
||||
/// enumeration is complete. Each `BackendBlobEntry` carries the
|
||||
/// hash + optional mtime for grace-window filtering.
|
||||
|
||||
@@ -24,6 +24,16 @@ pub struct BlobMetadataDto {
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
/// A stored server-derived artifact: which blob holds it, and what it is.
|
||||
///
|
||||
/// `content_type` is carried so the read path can set the response header
|
||||
/// without byte-sniffing the payload, which is what it does today.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DerivedBlobRef {
|
||||
pub blob_hash: String,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
/// Result of a deduplication store operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DedupResultDto {
|
||||
@@ -83,6 +93,17 @@ pub trait DedupPort: Send + Sync + 'static {
|
||||
/// Check if a blob with the given hash exists.
|
||||
async fn blob_exists(&self, hash: &str) -> bool;
|
||||
|
||||
/// Look up a server-derived artifact by the content it was derived from.
|
||||
///
|
||||
/// The read counterpart of `store_derived_blob`. Returns `None` when no
|
||||
/// such variant has been derived yet — the caller then renders it.
|
||||
async fn find_derived_blob(
|
||||
&self,
|
||||
source_hash: &str,
|
||||
kind: &str,
|
||||
variant: &str,
|
||||
) -> Option<DerivedBlobRef>;
|
||||
|
||||
/// Get metadata for a blob.
|
||||
async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadataDto>;
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ pub enum ThumbnailFormat {
|
||||
}
|
||||
|
||||
impl ThumbnailFormat {
|
||||
/// Every format, for callers that must handle all of them — notably
|
||||
/// `thumb_derived_import`, which claims one sidecar extension per format
|
||||
/// and would silently strand a codec if this list and the write path
|
||||
/// drifted apart.
|
||||
pub const ALL: [ThumbnailFormat; 2] = [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg];
|
||||
|
||||
/// Stable name, byte-identical to the derived `Debug` output (see
|
||||
/// [`ThumbnailSize::as_str`] — same ETag-stability contract).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
@@ -94,6 +100,18 @@ impl ThumbnailFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// Media type, for `content_derived_blobs.content_type` and for any
|
||||
/// response serving these bytes.
|
||||
///
|
||||
/// Beside `ext` deliberately: the two must agree, and an extension
|
||||
/// without a matching media type is how a WebP ends up labelled JPEG.
|
||||
pub fn mime(self) -> &'static str {
|
||||
match self {
|
||||
ThumbnailFormat::Webp => "image/webp",
|
||||
ThumbnailFormat::Jpeg => "image/jpeg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the output format from a request `Accept` header: WebP when the
|
||||
/// client advertises `image/webp`, JPEG otherwise. A plain substring check
|
||||
/// is sufficient — no client sends `image/webp;q=0`, and every WebP-capable
|
||||
|
||||
@@ -578,7 +578,7 @@ impl StorageUsageService {
|
||||
|
||||
pub const USAGE_RECONCILE_JOB_NAME: &str = "usage_reconcile";
|
||||
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs, Mutates};
|
||||
use async_trait::async_trait;
|
||||
|
||||
impl StorageUsageService {
|
||||
@@ -603,6 +603,19 @@ impl JobHandler for StorageUsageService {
|
||||
USAGE_RECONCILE_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Recomputes the cached storage counters from the underlying file \
|
||||
sizes — drives first, then the per-user envelope derived from \
|
||||
them — and corrects any that drifted. This is the corrective \
|
||||
counterpart to drives_consistency, which only reports the drift."
|
||||
}
|
||||
|
||||
/// Rewrites the counters it finds wrong. Safe to trigger: it recomputes
|
||||
/// from the files themselves, so a run is idempotent.
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
/// Runs both reconciliation sweeps — drives first, then users —
|
||||
/// and reports the total number of rows corrected.
|
||||
///
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::infrastructure::scheduler::JobRunArgs;
|
||||
|
||||
/// Cache configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheConfig {
|
||||
@@ -2280,6 +2282,148 @@ pub struct GrantCleanupConfig {
|
||||
pub interval_hours: u64,
|
||||
}
|
||||
|
||||
/// One job to dispatch once at startup, parsed from an entry of
|
||||
/// `OXICLOUD_STARTUP_JOBS`.
|
||||
///
|
||||
/// **Why this exists.** Scheduled ticks deliberately never pass
|
||||
/// `repair` — a job that deletes on its default setting is the thing
|
||||
/// no-silent-auto-repair forbids. But that leaves the migration jobs in
|
||||
/// a state where an operator who never opens the admin panel imports
|
||||
/// forever and never drains: the sidecars are fully redundant, and
|
||||
/// nothing removes them. Naming the job in configuration IS the
|
||||
/// deliberate operator action; it just gets taken once, at boot,
|
||||
/// instead of every time.
|
||||
///
|
||||
/// Not a general "run everything in repair mode" switch. Each job is
|
||||
/// named individually, and the flags are per job.
|
||||
/// Holds a [`JobRunArgs`] rather than re-listing its fields. They are
|
||||
/// the same four flags with the same meanings, and a copy here would
|
||||
/// have to be found and updated the next time the scheduler grows a
|
||||
/// fifth — silently ignoring it in configuration until someone noticed.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StartupJob {
|
||||
/// Registered job name — must match `JobHandler::name`.
|
||||
pub name: String,
|
||||
/// Forwarded verbatim to `JobRegistry::trigger`.
|
||||
pub args: JobRunArgs,
|
||||
}
|
||||
|
||||
/// Parse one `OXICLOUD_STARTUP_JOBS` entry: `name`, or
|
||||
/// `name?repair=true&deep=true`.
|
||||
///
|
||||
/// The query syntax is the one an operator already types at
|
||||
/// `POST /api/admin/jobs/{name}/trigger?repair=true`, so the value is
|
||||
/// literally the request they would otherwise make by hand.
|
||||
///
|
||||
/// **Errors on anything it does not recognise**, rather than ignoring
|
||||
/// it. A silently-dropped `?repare=true` typo would leave the job
|
||||
/// running in discovery-only mode forever while the operator believed
|
||||
/// the tier was draining — the failure would surface as "the migration
|
||||
/// never finishes" months later, with nothing in the logs pointing at
|
||||
/// the config. Same reasoning as fail-fast on any broken config.
|
||||
fn parse_startup_job(raw: &str) -> Result<StartupJob, String> {
|
||||
let raw = raw.trim();
|
||||
let (name, query) = match raw.split_once('?') {
|
||||
Some((n, q)) => (n.trim(), q),
|
||||
None => (raw, ""),
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err("empty job name".to_string());
|
||||
}
|
||||
|
||||
let mut job = StartupJob {
|
||||
name: name.to_string(),
|
||||
args: JobRunArgs::default(),
|
||||
};
|
||||
|
||||
for pair in query.split('&').filter(|p| !p.is_empty()) {
|
||||
let (key, value) = pair
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("`{pair}` is not key=value (job `{name}`)"))?;
|
||||
// Booleans accept only `true`/`false` — the same rule the HTTP
|
||||
// trigger enforces, so a value that works in one place works in
|
||||
// the other. See memory `bug_axum_query_bool_only_accepts_true_false`.
|
||||
let as_bool = || match value {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
other => Err(format!(
|
||||
"`{key}={other}` on job `{name}`: expected true or false"
|
||||
)),
|
||||
};
|
||||
match key {
|
||||
"force" => job.args.force = as_bool()?,
|
||||
"deep" => job.args.deep = as_bool()?,
|
||||
"repair" => job.args.repair = as_bool()?,
|
||||
"storage" => job.args.storage = Some(value.to_string()),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"unknown flag `{other}` on job `{name}`: expected force, deep, repair \
|
||||
or storage"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// What runs at boot when `OXICLOUD_STARTUP_JOBS` is unset.
|
||||
///
|
||||
/// **Both migration jobs, both in repair mode** — they import their
|
||||
/// sidecars and then delete them. Chosen deliberately: an operator who
|
||||
/// never edits `.env` is the normal case, and a migration nobody
|
||||
/// triggers never finishes, so a default that only imports would leave
|
||||
/// every untouched deployment carrying a fully-redundant `.thumbnails/`
|
||||
/// forever.
|
||||
///
|
||||
/// This is a destructive default, which is a real exception to
|
||||
/// no-silent-auto-repair, so what makes it safe has to hold:
|
||||
///
|
||||
/// * **Nothing is deleted before its replacement has been read back.**
|
||||
/// `verify_and_unlink` imports, reads the blob back through the normal
|
||||
/// stack, and only then unlinks. A store that reported success but
|
||||
/// landed unreadable keeps its sidecar. That readback is the whole
|
||||
/// safety argument — it matters most for `thumb_attached_import`,
|
||||
/// whose bytes are user-uploaded previews with no render path, so a
|
||||
/// wrong deletion there is permanent where a wrong deletion of a
|
||||
/// server-rendered thumbnail costs only a re-render.
|
||||
/// * **Sidecars whose source is gone are deleted without a readback**,
|
||||
/// because there is nothing to read back and nothing can ever
|
||||
/// reference them again. Unrecoverable and unreachable are different
|
||||
/// things; these are both.
|
||||
/// * **Every deletion is audited**, so an operator can reconstruct what
|
||||
/// a boot removed and from which source.
|
||||
///
|
||||
/// The consequence to be aware of when changing this: an upgrade
|
||||
/// deletes on first boot, in every deployment at once, with no operator
|
||||
/// action. A regression in the readback path would therefore be
|
||||
/// simultaneous and unrecoverable. Treat that code as load-bearing.
|
||||
///
|
||||
/// Set `OXICLOUD_STARTUP_JOBS=` (empty) to disable startup jobs
|
||||
/// entirely; any explicit value replaces this list rather than adding
|
||||
/// to it.
|
||||
const DEFAULT_STARTUP_JOBS: &str =
|
||||
"thumb_derived_import?repair=true,thumb_attached_import?repair=true";
|
||||
|
||||
/// Parse the whole `OXICLOUD_STARTUP_JOBS` value. Empty → no startup
|
||||
/// jobs (an explicit opt-out); unset → [`DEFAULT_STARTUP_JOBS`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// On any malformed entry. A startup-job list that half-parses is worse
|
||||
/// than one that fails: the server would come up looking healthy with a
|
||||
/// migration that never runs.
|
||||
fn parse_startup_jobs(raw: &str) -> Vec<StartupJob> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|entry| {
|
||||
parse_startup_job(entry).unwrap_or_else(|e| {
|
||||
panic!("OXICLOUD_STARTUP_JOBS: {e}");
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Default for GrantCleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -2543,6 +2687,17 @@ pub struct AppConfig {
|
||||
/// bind to loopback / a private interface without exposing
|
||||
/// metrics publicly.
|
||||
pub metrics_listen: Option<std::net::SocketAddr>,
|
||||
/// Jobs to dispatch once, in the background, after the scheduler is
|
||||
/// ready. Env: `OXICLOUD_STARTUP_JOBS` — comma-separated, each entry
|
||||
/// `name` or `name?repair=true`, mirroring the admin trigger URL.
|
||||
///
|
||||
/// Empty by default. Intended for the migration jobs, whose
|
||||
/// scheduled ticks import but deliberately never delete: naming one
|
||||
/// here is the operator's standing consent to the deletion, given
|
||||
/// once in configuration instead of per run in the panel.
|
||||
///
|
||||
/// Dispatch is non-blocking — readiness never waits on a job.
|
||||
pub startup_jobs: Vec<StartupJob>,
|
||||
/// Cache configuration
|
||||
pub cache: CacheConfig,
|
||||
/// Timeout configuration
|
||||
@@ -2671,6 +2826,7 @@ impl Default for AppConfig {
|
||||
plugins: PluginConfig::default(),
|
||||
faces: FacesConfig::default(),
|
||||
metrics_listen: None,
|
||||
startup_jobs: parse_startup_jobs(DEFAULT_STARTUP_JOBS),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2718,6 +2874,19 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Jobs to fire once at boot. Unset keeps DEFAULT_STARTUP_JOBS (set
|
||||
// by `Default`); any explicit value REPLACES it, and an empty value
|
||||
// is the opt-out.
|
||||
//
|
||||
// Panics on a malformed entry rather than warning: unlike metrics,
|
||||
// a startup job that silently fails to parse leaves a migration
|
||||
// that never runs, and the symptom ("the tier never drained")
|
||||
// surfaces months later with nothing pointing back at the config
|
||||
// line.
|
||||
if let Ok(raw) = env::var("OXICLOUD_STARTUP_JOBS") {
|
||||
config.startup_jobs = parse_startup_jobs(&raw);
|
||||
}
|
||||
|
||||
// Database configuration
|
||||
if let Ok(connection_string) = env::var("OXICLOUD_DB_CONNECTION_STRING") {
|
||||
config.database.connection_string = connection_string;
|
||||
@@ -3783,6 +3952,89 @@ pub fn default_config() -> AppConfig {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn startup_job_parses_name_and_flags() {
|
||||
let jobs = parse_startup_jobs(
|
||||
"thumb_derived_import?repair=true, thumb_attached_import ,blobs_consistency?deep=true&force=false",
|
||||
);
|
||||
assert_eq!(jobs.len(), 3);
|
||||
|
||||
assert_eq!(jobs[0].name, "thumb_derived_import");
|
||||
assert!(jobs[0].args.repair);
|
||||
assert!(!jobs[0].args.deep);
|
||||
|
||||
// Bare name → all flags default off, which is the discovery-only
|
||||
// run. Naming a migration job without `repair` imports and stops.
|
||||
assert_eq!(jobs[1].name, "thumb_attached_import");
|
||||
assert!(!jobs[1].args.repair);
|
||||
|
||||
assert!(jobs[2].args.deep);
|
||||
assert!(!jobs[2].args.force);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_job_accepts_storage_scope() {
|
||||
let jobs = parse_startup_jobs("backend_consistency?storage=s3_prod&deep=true");
|
||||
assert_eq!(jobs[0].args.storage.as_deref(), Some("s3_prod"));
|
||||
assert!(jobs[0].args.deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_jobs_empty_value_is_the_opt_out() {
|
||||
assert!(parse_startup_jobs("").is_empty());
|
||||
assert!(parse_startup_jobs(" , ,").is_empty());
|
||||
}
|
||||
|
||||
/// Both migration jobs drain themselves out of the box, deletion
|
||||
/// included. Pinned rather than left implicit because this is a
|
||||
/// destructive default: it deletes on first boot after an upgrade,
|
||||
/// everywhere, with no operator action. Whoever changes this line
|
||||
/// should have to change a test that says so.
|
||||
///
|
||||
/// What keeps it safe is the readback in `verify_and_unlink` — import,
|
||||
/// read the blob back through the normal stack, and only then unlink.
|
||||
/// That matters most for `thumb_attached_import`, whose bytes are
|
||||
/// user-uploaded and have no render path to rebuild them.
|
||||
#[test]
|
||||
fn default_startup_jobs_drain_both_thumbnail_tiers() {
|
||||
let jobs = AppConfig::default().startup_jobs;
|
||||
let names: Vec<&str> = jobs.iter().map(|j| j.name.as_str()).collect();
|
||||
assert_eq!(names, ["thumb_derived_import", "thumb_attached_import"]);
|
||||
assert!(jobs.iter().all(|j| j.args.repair));
|
||||
assert!(jobs.iter().all(|j| !j.args.deep && !j.args.force));
|
||||
}
|
||||
|
||||
/// A misspelled flag must not parse. Silently ignoring `repare=true`
|
||||
/// leaves the job in discovery-only mode while the operator believes
|
||||
/// the tier is draining — a failure that surfaces months later as
|
||||
/// "the migration never finished", with nothing pointing at the
|
||||
/// config line.
|
||||
#[test]
|
||||
#[should_panic(expected = "unknown flag `repare`")]
|
||||
fn startup_job_rejects_a_misspelled_flag() {
|
||||
parse_startup_jobs("thumb_derived_import?repare=true");
|
||||
}
|
||||
|
||||
/// Booleans take only true/false — the same rule the HTTP trigger
|
||||
/// enforces, so a value that works in one place works in the other.
|
||||
#[test]
|
||||
#[should_panic(expected = "expected true or false")]
|
||||
fn startup_job_rejects_a_non_boolean_flag_value() {
|
||||
parse_startup_jobs("thumb_derived_import?repair=yes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "not key=value")]
|
||||
fn startup_job_rejects_a_valueless_flag() {
|
||||
parse_startup_jobs("thumb_derived_import?repair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "empty job name")]
|
||||
fn startup_job_rejects_flags_with_no_job() {
|
||||
parse_startup_jobs("?repair=true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_allowlist_accepts_any_email() {
|
||||
let cfg = MagicLinkConfig::default();
|
||||
|
||||
+168
-33
@@ -440,22 +440,6 @@ impl AppServiceFactory {
|
||||
// `blob_backend` into DedupService.
|
||||
let blob_backend_for_consistency = blob_backend.clone();
|
||||
|
||||
// Every table holding blob references. Built ONCE and shared by the
|
||||
// GC reap predicate and the consistency recompute so the two cannot
|
||||
// disagree about what "referenced" means — a disagreement reaps live
|
||||
// content. New blob-owning tables register here.
|
||||
// See docs/plan/derived-blobs.md.
|
||||
let blob_reference_registry = {
|
||||
use crate::infrastructure::repositories::pg::blob_reference_sources::{
|
||||
ChunksReferenceSource, FilesReferenceSource,
|
||||
};
|
||||
let mut registry =
|
||||
crate::application::ports::blob_reference_ports::BlobReferenceRegistry::new();
|
||||
registry.register(Arc::new(FilesReferenceSource::new(db_pool.clone())));
|
||||
registry.register(Arc::new(ChunksReferenceSource::new(db_pool.clone())));
|
||||
Arc::new(registry)
|
||||
};
|
||||
|
||||
// Deduplication service — PRIMARY blob storage engine (PostgreSQL-backed index)
|
||||
let dedup_service = Arc::new(
|
||||
crate::infrastructure::services::dedup_service::DedupService::new(
|
||||
@@ -463,8 +447,7 @@ impl AppServiceFactory {
|
||||
db_pool.clone(),
|
||||
maintenance_pool.clone(),
|
||||
)
|
||||
.with_blob_lifecycle(blob_lifecycle)
|
||||
.with_reference_registry(blob_reference_registry.clone()),
|
||||
.with_blob_lifecycle(blob_lifecycle),
|
||||
);
|
||||
dedup_service.initialize().await?;
|
||||
|
||||
@@ -1493,6 +1476,51 @@ impl AppServiceFactory {
|
||||
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
|
||||
.await;
|
||||
|
||||
// Step 10 migration tenant: backfills `content_derived_blobs` from
|
||||
// the on-disk thumbnail sidecars that predate it. Idempotent, so it
|
||||
// is safe to trigger repeatedly — Phase 3 (deleting the sidecars) is
|
||||
// gated on a run reporting zero imported. Registered unconditionally
|
||||
// rather than behind a flag: a migration nobody can find is a
|
||||
// migration nobody runs.
|
||||
//
|
||||
// `.thumbnails` lives under the storage path, matching
|
||||
// `ThumbnailService::new(&self.storage_path, …)` above.
|
||||
let _ = Arc::new(
|
||||
crate::infrastructure::services::thumb_derived_import_service::ThumbDerivedImport::new(
|
||||
std::path::Path::new(&self.storage_path).join(".thumbnails"),
|
||||
core.dedup_service.clone(),
|
||||
),
|
||||
)
|
||||
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
|
||||
.await;
|
||||
|
||||
// Both satellite tables, checked for mappings whose Blob is gone.
|
||||
// Nothing else can: a row whose SOURCE was reaped still holds a valid
|
||||
// reference to a real artifact with a correct refcount, so every
|
||||
// other check agrees the system is healthy while the artifact is
|
||||
// pinned forever. Read-only.
|
||||
let _ = Arc::new(
|
||||
crate::infrastructure::services::satellites_consistency_service::SatellitesConsistencyCheck::new(
|
||||
maintenance_pool.clone(),
|
||||
),
|
||||
)
|
||||
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
|
||||
.await;
|
||||
|
||||
// Its file-keyed twin: `ext-{file_id}.jpg` previews the user uploaded,
|
||||
// which no copy path duplicates today. Separate job, separate keying —
|
||||
// routing these into the content-keyed table would share one user's
|
||||
// preview onto every file with identical content.
|
||||
let _ = Arc::new(
|
||||
crate::infrastructure::services::thumb_attached_import_service::ThumbAttachedImport::new(
|
||||
std::path::Path::new(&self.storage_path).join(".thumbnails"),
|
||||
core.dedup_service.clone(),
|
||||
maintenance_pool.clone(),
|
||||
),
|
||||
)
|
||||
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
|
||||
.await;
|
||||
|
||||
// Third recoverable-run tenant. Iterates `storage.files`
|
||||
// and reports parent-folder-trashed cascade misses,
|
||||
// `missing_blob` (data-loss indicator — file references
|
||||
@@ -1508,24 +1536,21 @@ impl AppServiceFactory {
|
||||
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
|
||||
.await;
|
||||
|
||||
// Fourth recoverable-run tenant. Iterates `storage.blobs`
|
||||
// and verifies each row against the physical backend AND
|
||||
// against the reference-counting invariants that `dedup_gc`
|
||||
// relies on. Three per-row checks (subject-iteration in
|
||||
// action): `blob_missing_from_backend` (data_loss, bytes
|
||||
// gone from disk), `refcount_mismatch` (inconsistent,
|
||||
// dedup counter drift), and `blob_corrupted` (data_loss,
|
||||
// deep mode only — bit-rot). Complements
|
||||
// `files_consistency` without doubling work: probing
|
||||
// per-unique-blob preserves dedup savings vs probing
|
||||
// per-file-chunk. See memory
|
||||
// `project_cdc_dual_storage_registries` for the rationale.
|
||||
// Fourth recoverable-run tenant. Iterates `storage.blobs` and
|
||||
// checks the reference-counting invariant `dedup_gc` relies on:
|
||||
// `refcount_mismatch` (inconsistent — an under-count lets GC reap
|
||||
// a live blob, an over-count pins a dead one), repairable under
|
||||
// `?repair=true`.
|
||||
//
|
||||
// DB-only, and takes no backend. Physical checks — missing bytes,
|
||||
// orphaned bytes, bit-rot — all belong to `backend_consistency`,
|
||||
// which merge-joins the backend enumeration against this same
|
||||
// table in one pass. This tenant used to probe the backend once
|
||||
// per row for missing bytes, which found strictly less than the
|
||||
// merge-join at N round-trips instead of one enumeration.
|
||||
let _ = Arc::new(
|
||||
crate::infrastructure::services::blobs_consistency_service::BlobsConsistencyCheck::new(
|
||||
maintenance_pool.clone(),
|
||||
core.blob_backend.clone(),
|
||||
core.config.storage_entries.clone(),
|
||||
self.storage_path.clone(),
|
||||
// Same registry instance GC reaps from — see
|
||||
// DedupService::reference_registry.
|
||||
core.dedup_service.reference_registry(),
|
||||
@@ -2810,6 +2835,116 @@ impl AppServiceFactory {
|
||||
registered
|
||||
);
|
||||
|
||||
// `OXICLOUD_STARTUP_JOBS` — dispatch each named job once, now.
|
||||
//
|
||||
// Exists for the migration jobs. Their scheduled ticks import but
|
||||
// never delete (`repair` defaults false, per no-silent-auto-repair),
|
||||
// so a deployment whose operator never opens the admin panel keeps
|
||||
// importing sidecars it already imported and never drains the
|
||||
// directory. Naming the job in configuration IS the deliberate
|
||||
// consent that rule asks for; it is simply given once, at boot,
|
||||
// rather than per run.
|
||||
//
|
||||
// Validated here, dispatched in the background:
|
||||
//
|
||||
// * Unknown names **panic**. The registry is fully populated at this
|
||||
// point, so a name that does not resolve is a typo or a rename, and
|
||||
// the failure mode of ignoring it is a migration that silently
|
||||
// never runs. Fail at boot, where the operator is watching.
|
||||
// * Dispatch is `tokio::spawn` — readiness must never wait on a job
|
||||
// that walks a filesystem for hours.
|
||||
// * Sequential within the task, not concurrent: these jobs contend
|
||||
// for the same directory and DB, and the exclusivity gate would
|
||||
// turn overlap into a skipped run rather than a queued one.
|
||||
// * Safe on every boot, including a crash loop: each is idempotent
|
||||
// and resumable, and once drained a run is a `read_dir` that
|
||||
// returns nothing.
|
||||
//
|
||||
// **Killed mid-run, this resumes from the cursor.** The boot
|
||||
// recovery sweep runs earlier in this function and flips every row
|
||||
// the dead process abandoned in `Running` to `Paused`, keeping its
|
||||
// cursor. `run_or_resume` then picks Resume over a fresh start, so
|
||||
// a job interrupted by a restart continues where it stopped rather
|
||||
// than rescanning from the beginning — and a long migration
|
||||
// completes across however many restarts it takes.
|
||||
//
|
||||
// That is a deliberate exception to `boot_recovery_sweep`'s "we do
|
||||
// not auto-resume; operators trigger the resume explicitly". The
|
||||
// rule exists so a restart never silently resumes work nobody
|
||||
// asked for. Here somebody did ask, in configuration, and the whole
|
||||
// point of the option is not having to ask again. The exception is
|
||||
// scoped to the named jobs; every other paused run still waits for
|
||||
// an operator.
|
||||
//
|
||||
// The resumed run keeps the flags it started with — `repair` and
|
||||
// `deep` are persisted to the run's `params` on the fresh open and
|
||||
// read back on resume — so editing the config mid-migration does
|
||||
// not retroactively change a run already in flight.
|
||||
if !self.config.startup_jobs.is_empty() {
|
||||
let mut planned = Vec::with_capacity(self.config.startup_jobs.len());
|
||||
for job in &self.config.startup_jobs {
|
||||
if app_state.core.job_registry.get(&job.name).await.is_none() {
|
||||
panic!(
|
||||
"OXICLOUD_STARTUP_JOBS names `{}`, which is not a registered job. \
|
||||
Check the spelling against GET /api/admin/jobs.",
|
||||
job.name
|
||||
);
|
||||
}
|
||||
planned.push(job.clone());
|
||||
}
|
||||
|
||||
let registry = app_state.core.job_registry.clone();
|
||||
tokio::spawn(async move {
|
||||
for job in planned {
|
||||
// Audited, not merely logged: a startup job may delete
|
||||
// files, and "who asked for this" must be answerable
|
||||
// afterwards. The answer is the configuration, which is
|
||||
// exactly what this line records.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "job.startup_trigger",
|
||||
job = %job.name,
|
||||
force = job.args.force,
|
||||
deep = job.args.deep,
|
||||
repair = job.args.repair,
|
||||
storage = ?job.args.storage,
|
||||
"👮🏻♂️ dispatching `{}` from OXICLOUD_STARTUP_JOBS",
|
||||
job.name,
|
||||
);
|
||||
match registry.trigger(&job.name, &job.args).await {
|
||||
// Debug, not info. The engine already logs every
|
||||
// dispatch as `job.run` with the outcome and timing —
|
||||
// that is the point of routing through `trigger`
|
||||
// rather than calling handlers directly. An info line
|
||||
// here made every startup job report completion
|
||||
// twice, from two layers, saying the same thing. The
|
||||
// `job.startup_trigger` audit line above already
|
||||
// records that the startup path was the caller.
|
||||
Some(outcome) => tracing::debug!(
|
||||
target: "oxicloud::scheduler",
|
||||
event = "job.startup_completed",
|
||||
job = %job.name,
|
||||
outcome = outcome.kind(),
|
||||
"startup job `{}` finished ({})",
|
||||
job.name,
|
||||
outcome.kind(),
|
||||
),
|
||||
// Unreachable — the name was resolved above, and
|
||||
// nothing unregisters. Logged rather than panicking
|
||||
// because this is a detached task by then.
|
||||
None => tracing::error!(
|
||||
target: "oxicloud::scheduler",
|
||||
event = "job.startup_vanished",
|
||||
job = %job.name,
|
||||
"startup job `{}` disappeared from the registry between \
|
||||
validation and dispatch",
|
||||
job.name,
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(app_state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,6 +756,15 @@ impl DedupPort for StubDedupPort {
|
||||
false
|
||||
}
|
||||
|
||||
async fn find_derived_blob(
|
||||
&self,
|
||||
_source_hash: &str,
|
||||
_kind: &str,
|
||||
_variant: &str,
|
||||
) -> Option<crate::application::ports::dedup_ports::DerivedBlobRef> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_blob_metadata(&self, _hash: &str) -> Option<BlobMetadataDto> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::blob_reference_ports::{BlobReferenceSource, RefLevel};
|
||||
use crate::application::ports::blob_reference_ports::{
|
||||
BlobReferenceRegistry, BlobReferenceSource, RefLevel,
|
||||
};
|
||||
use crate::domain::errors::DomainError;
|
||||
|
||||
/// Aliases used inside the emitted fragments.
|
||||
@@ -26,6 +28,8 @@ use crate::domain::errors::DomainError;
|
||||
/// sweep and silently correlate against itself.
|
||||
const FILES_ALIAS: &str = "cnt_f";
|
||||
const MANIFEST_ALIAS: &str = "cnt_m";
|
||||
const DERIVED_ALIAS: &str = "cnt_d";
|
||||
const ATTACHED_ALIAS: &str = "cnt_a";
|
||||
|
||||
/// Fragment for [`FilesReferenceSource`], as a free function so the SQL
|
||||
/// shape can be tested without constructing a pool — it is a property of
|
||||
@@ -87,6 +91,81 @@ fn chunks_ref_sql(level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fragment for [`ContentDerivedReferenceSource`].
|
||||
///
|
||||
/// **Manifest level only.** A derived artifact's `blob_hash` names a Blob
|
||||
/// (its own manifest), never a chunk. Contributing at the chunk level would
|
||||
/// double-count, because a thumbnail is almost always single-chunk and its
|
||||
/// manifest hash therefore equals its lone chunk's hash.
|
||||
fn content_derived_ref_sql(level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
match level {
|
||||
RefLevel::Chunk => None,
|
||||
RefLevel::Manifest => Some(format!(
|
||||
"(SELECT COUNT(*) FROM storage.content_derived_blobs {DERIVED_ALIAS} \
|
||||
WHERE {DERIVED_ALIAS}.blob_hash = {outer_hash_expr})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Short-circuiting existence form, used by `dedup_gc`'s reap predicate.
|
||||
fn content_derived_exists_sql(level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
match level {
|
||||
RefLevel::Chunk => None,
|
||||
RefLevel::Manifest => Some(format!(
|
||||
"EXISTS (SELECT 1 FROM storage.content_derived_blobs {DERIVED_ALIAS} \
|
||||
WHERE {DERIVED_ALIAS}.blob_hash = {outer_hash_expr})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fragment for [`FileAttachedReferenceSource`].
|
||||
///
|
||||
/// **Manifest level only**, for the same reason as the derived source: an
|
||||
/// attached artifact's `blob_hash` names a Blob, never a chunk, and these are
|
||||
/// almost always single-chunk — so contributing at the chunk level would
|
||||
/// double-count against the aliased hash.
|
||||
fn file_attached_ref_sql(level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
match level {
|
||||
RefLevel::Chunk => None,
|
||||
RefLevel::Manifest => Some(format!(
|
||||
"(SELECT COUNT(*) FROM storage.file_attached_blobs {ATTACHED_ALIAS} \
|
||||
WHERE {ATTACHED_ALIAS}.blob_hash = {outer_hash_expr})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Short-circuiting existence form, used by `dedup_gc`'s reap predicate.
|
||||
fn file_attached_exists_sql(level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
match level {
|
||||
RefLevel::Chunk => None,
|
||||
RefLevel::Manifest => Some(format!(
|
||||
"EXISTS (SELECT 1 FROM storage.file_attached_blobs {ATTACHED_ALIAS} \
|
||||
WHERE {ATTACHED_ALIAS}.blob_hash = {outer_hash_expr})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every built-in blob-reference source, in one place.
|
||||
///
|
||||
/// THE definition of "what references a blob". `DedupService::new` uses it
|
||||
/// as its construction default and hands it to the consistency jobs via
|
||||
/// `reference_registry()`, so GC and the sweeps cannot disagree — and the
|
||||
/// golden tests that pin the generated SQL exercise the same set production
|
||||
/// runs, rather than a test-local approximation of it.
|
||||
pub fn built_in_registry(pool: Arc<PgPool>) -> BlobReferenceRegistry {
|
||||
let mut registry = BlobReferenceRegistry::new();
|
||||
registry.register(Arc::new(FilesReferenceSource::new(pool.clone())));
|
||||
registry.register(Arc::new(ChunksReferenceSource::new(pool.clone())));
|
||||
// Registered before anything writes a derived blob: dedup_gc's reap
|
||||
// predicate must already know this table exists, or the first sweep
|
||||
// after the first thumbnail deletes it.
|
||||
registry.register(Arc::new(ContentDerivedReferenceSource::new(pool.clone())));
|
||||
// Same rule as above: registered before the first attachment is written,
|
||||
// so dedup_gc's reap predicate already knows the table exists.
|
||||
registry.register(Arc::new(FileAttachedReferenceSource::new(pool)));
|
||||
registry
|
||||
}
|
||||
|
||||
// ─── storage.files ───────────────────────────────────────────────────────
|
||||
|
||||
/// References held by `storage.files.blob_hash`.
|
||||
@@ -261,6 +340,171 @@ fn decode_uuid_cursor(bytes: &[u8]) -> Result<Uuid, DomainError> {
|
||||
Ok(Uuid::from_bytes(raw))
|
||||
}
|
||||
|
||||
// ─── storage.content_derived_blobs ───────────────────────────────────────
|
||||
|
||||
/// References held by `storage.content_derived_blobs.blob_hash` — the
|
||||
/// DERIVED artifact, not the source it came from.
|
||||
///
|
||||
/// **`source_hash` is deliberately not a reference.** It is a dependent
|
||||
/// pointer: the source Blob is kept alive by the file that owns it, and when
|
||||
/// that Blob is reaped these rows go with it. Counting `source_hash` here
|
||||
/// would pin every source Blob for as long as a thumbnail existed.
|
||||
pub struct ContentDerivedReferenceSource {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl ContentDerivedReferenceSource {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlobReferenceSource for ContentDerivedReferenceSource {
|
||||
fn source_name(&self) -> &'static str {
|
||||
"content_derived"
|
||||
}
|
||||
|
||||
fn ref_count_sql(&self, level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
content_derived_ref_sql(level, outer_hash_expr)
|
||||
}
|
||||
|
||||
fn ref_exists_sql(&self, level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
content_derived_exists_sql(level, outer_hash_expr)
|
||||
}
|
||||
|
||||
async fn count_references(&self, blob_hash: &str) -> Result<u64, DomainError> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.content_derived_blobs WHERE blob_hash = $1",
|
||||
)
|
||||
.bind(blob_hash)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("BlobRefSource", format!("derived count: {e}")))?;
|
||||
Ok(n.max(0) as u64)
|
||||
}
|
||||
|
||||
async fn list_referenced_blobs(
|
||||
&self,
|
||||
cursor: Option<Vec<u8>>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<String>, Option<Vec<u8>>), DomainError> {
|
||||
// Paged by `blob_hash` itself — unlike files it IS the value we
|
||||
// return, and DISTINCT keeps a Blob shared by several variants from
|
||||
// appearing more than once per page.
|
||||
let after: Option<String> = match cursor {
|
||||
Some(bytes) => Some(String::from_utf8(bytes).map_err(|e| {
|
||||
DomainError::internal_error("BlobRefSource", format!("bad derived cursor: {e}"))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT DISTINCT blob_hash FROM storage.content_derived_blobs
|
||||
WHERE ($1::text IS NULL OR blob_hash > $1)
|
||||
ORDER BY blob_hash
|
||||
LIMIT $2",
|
||||
)
|
||||
.bind(after)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("BlobRefSource", format!("derived page: {e}")))?;
|
||||
|
||||
let next = rows
|
||||
.last()
|
||||
.map(|(h,)| h.clone().into_bytes())
|
||||
.filter(|_| rows.len() == limit);
|
||||
Ok((rows.into_iter().map(|(h,)| h).collect(), next))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── storage.file_attached_blobs ─────────────────────────────────────────
|
||||
|
||||
/// References held by `storage.file_attached_blobs.blob_hash` — bytes a user
|
||||
/// supplied for one specific file.
|
||||
///
|
||||
/// Structurally the twin of [`ContentDerivedReferenceSource`]: same level,
|
||||
/// same shape, different table. The difference that matters is upstream — the
|
||||
/// row is keyed by `file_id` rather than by content, so the same bytes
|
||||
/// attached to two files are two rows and therefore two references. Dedup
|
||||
/// still applies to the bytes; what must not be shared is the mapping.
|
||||
///
|
||||
/// `file_id` is deliberately not a reference at this layer: it is an
|
||||
/// `ON DELETE CASCADE` foreign key, so the row disappears with the file, and
|
||||
/// the blob reference it held is released by the owning service's
|
||||
/// `on_file_deleted` hook.
|
||||
pub struct FileAttachedReferenceSource {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl FileAttachedReferenceSource {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlobReferenceSource for FileAttachedReferenceSource {
|
||||
fn source_name(&self) -> &'static str {
|
||||
"file_attached"
|
||||
}
|
||||
|
||||
fn ref_count_sql(&self, level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
file_attached_ref_sql(level, outer_hash_expr)
|
||||
}
|
||||
|
||||
fn ref_exists_sql(&self, level: RefLevel, outer_hash_expr: &str) -> Option<String> {
|
||||
file_attached_exists_sql(level, outer_hash_expr)
|
||||
}
|
||||
|
||||
async fn count_references(&self, blob_hash: &str) -> Result<u64, DomainError> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.file_attached_blobs WHERE blob_hash = $1",
|
||||
)
|
||||
.bind(blob_hash)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("BlobRefSource", format!("attached count: {e}"))
|
||||
})?;
|
||||
Ok(n.max(0) as u64)
|
||||
}
|
||||
|
||||
async fn list_referenced_blobs(
|
||||
&self,
|
||||
cursor: Option<Vec<u8>>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<String>, Option<Vec<u8>>), DomainError> {
|
||||
// Paged by `blob_hash`, same as the derived source: it IS the value
|
||||
// returned, and DISTINCT collapses one Blob attached to several files.
|
||||
let after: Option<String> = match cursor {
|
||||
Some(bytes) => Some(String::from_utf8(bytes).map_err(|e| {
|
||||
DomainError::internal_error("BlobRefSource", format!("bad attached cursor: {e}"))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT DISTINCT blob_hash FROM storage.file_attached_blobs
|
||||
WHERE ($1::text IS NULL OR blob_hash > $1)
|
||||
ORDER BY blob_hash
|
||||
LIMIT $2",
|
||||
)
|
||||
.bind(after)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("BlobRefSource", format!("attached page: {e}")))?;
|
||||
|
||||
let next = rows
|
||||
.last()
|
||||
.map(|(h,)| h.clone().into_bytes())
|
||||
.filter(|_| rows.len() == limit);
|
||||
Ok((rows.into_iter().map(|(h,)| h).collect(), next))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -130,9 +130,11 @@ impl FileBlobWriteRepository {
|
||||
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", fid)),
|
||||
None => Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
"folder_id is required to determine the target drive",
|
||||
// Same reasoning as the owner lookup below: caller error, not
|
||||
// server error.
|
||||
None => Err(DomainError::validation_error(
|
||||
"folder_id is required: the destination folder determines the \
|
||||
target drive",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -289,9 +291,15 @@ impl FileBlobWriteRepository {
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
return Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
"folder_id is required to determine file owner",
|
||||
// A missing required field is the caller's error, not the
|
||||
// server's. As `internal_error` this surfaced as 500 /
|
||||
// `error_type: Internal Error`, which the SPA cannot tell apart
|
||||
// from the server breaking — so a malformed upload looked like an
|
||||
// outage. The OpenAPI body description called the field optional,
|
||||
// which is how it came to be omitted in the first place.
|
||||
return Err(DomainError::validation_error(
|
||||
"folder_id is required: the destination folder determines the \
|
||||
file's owner and drive",
|
||||
));
|
||||
};
|
||||
|
||||
@@ -596,8 +604,25 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
new_name: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
// Atomic CTE: read source file → insert new row with same blob_hash → increment ref_count.
|
||||
// Single round-trip; blob content is NOT copied (dedup makes this zero-copy).
|
||||
// Two statements in one transaction: insert the new row (same
|
||||
// blob_hash — blob content is never copied, dedup makes this
|
||||
// zero-copy), then run the shared satellite fan-out.
|
||||
//
|
||||
// `storage.copy_file_satellites` is the single home for everything
|
||||
// that follows a file on copy — dead properties and the
|
||||
// manifest-aware blob reference — shared with
|
||||
// `storage.copy_folder_tree`. Two sites implementing that
|
||||
// separately is what let the tree path ship a version that missed
|
||||
// manifests entirely (migration `20261019000000`).
|
||||
//
|
||||
// It cannot be a CTE arm: data-modifying CTEs all observe the same
|
||||
// snapshot, so a function called alongside the INSERT would not see
|
||||
// the new `storage.files` row it needs to read `blob_hash` from,
|
||||
// and the dead-property INSERT would fail its foreign key. Hence a
|
||||
// real transaction — which also fixes the reference being
|
||||
// best-effort before: a failed `add_reference` used to log a
|
||||
// warning and leave a copy holding no reference at all, the exact
|
||||
// shape that gets its content reaped.
|
||||
//
|
||||
// §14: `created_by = $4 = updated_by = caller_id` — the caller
|
||||
// authored this copy. The previous binding used
|
||||
@@ -607,8 +632,10 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
let target_fid = target_folder_id.clone();
|
||||
let rename_to = new_name.map(|s| s.to_string());
|
||||
|
||||
let row = retry_on_deadlock("files.copy", || {
|
||||
sqlx::query_as::<
|
||||
let row = retry_on_deadlock("files.copy", || async {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
@@ -662,20 +689,6 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
blob_hash,
|
||||
created_by,
|
||||
updated_by
|
||||
),
|
||||
-- RFC 4918 §8.8 — dead properties MUST be duplicated on
|
||||
-- COPY. With the id-keyed store (migration
|
||||
-- 20260830000001) this is a single batch INSERT keyed on
|
||||
-- the new file's id. Runs in the same query as the file
|
||||
-- INSERT so either both land or neither does — atomic
|
||||
-- by virtue of being one statement.
|
||||
dead_prop_copy AS (
|
||||
INSERT INTO storage.webdav_dead_properties
|
||||
(file_id, namespace, local_name, value)
|
||||
SELECT (SELECT id FROM new_file),
|
||||
dp.namespace, dp.local_name, dp.value
|
||||
FROM storage.webdav_dead_properties dp
|
||||
WHERE dp.file_id = $1::uuid
|
||||
)
|
||||
SELECT id_text, name, folder_id, size, mime_type,
|
||||
created_at, updated_at,
|
||||
@@ -687,7 +700,22 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
.bind(&target_fid)
|
||||
.bind(&rename_to)
|
||||
.bind(caller_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(ref new_row) = row {
|
||||
// `new_row.0` is the new file's id as text; PG casts it.
|
||||
sqlx::query(
|
||||
"SELECT storage.copy_file_satellites(ARRAY[$1::uuid], ARRAY[$2::uuid])",
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(&new_row.0)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -705,14 +733,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
|
||||
let blob_hash = &row.7;
|
||||
|
||||
// Increment blob reference count (best-effort; INSERT already succeeded)
|
||||
if let Err(e) = self.dedup.add_reference(blob_hash).await {
|
||||
tracing::warn!(
|
||||
"Failed to increment blob ref for copy {}: {}",
|
||||
&blob_hash[..12],
|
||||
e
|
||||
);
|
||||
}
|
||||
// No `add_reference` here: `copy_file_satellites` took it inside the
|
||||
// transaction above, so a copy that exists always holds a reference.
|
||||
|
||||
tracing::info!(
|
||||
"📋 BLOB COPY: {} (hash: {}, zero-copy via dedup)",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{JobOutcome, JobRunArgs};
|
||||
use super::types::{JobOutcome, JobRunArgs, Mutates};
|
||||
|
||||
/// Implemented by every service that wants to run on a fixed interval
|
||||
/// through the periodic scheduler.
|
||||
@@ -92,4 +92,40 @@ pub trait JobHandler: Send + Sync {
|
||||
fn is_recoverable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// What this job does, in one or two sentences, for the admin UI.
|
||||
///
|
||||
/// English, in the trait, beside the behaviour it describes — not in
|
||||
/// `locales/*.json`. A description that lives away from the code rots
|
||||
/// the moment a job changes, invisibly, and a translator cannot know
|
||||
/// what `manifests_consistency` reconciles. i18n can layer on later
|
||||
/// keyed by job name with this as the fallback, so a missing
|
||||
/// translation degrades to English rather than to a blank panel.
|
||||
///
|
||||
/// Defaulted to `""` so adding it to the existing jobs is incremental
|
||||
/// rather than one breaking change; the UI omits the line when empty.
|
||||
fn description(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
|
||||
/// Whether a run changes state, and under what conditions. See
|
||||
/// [`Mutates`] for why this is not a boolean.
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Never
|
||||
}
|
||||
|
||||
/// `Some(..)` when `?repair=true` does something beyond a default run,
|
||||
/// describing what it ADDS; `None` when the flag is inert.
|
||||
///
|
||||
/// One method rather than a `supports_repair` boolean plus prose: its
|
||||
/// presence drives whether the UI offers the toggle, its content drives
|
||||
/// the confirmation text. A boolean would leave the frontend to invent
|
||||
/// wording for a destructive action it does not understand.
|
||||
///
|
||||
/// Independent of [`Self::mutates`], not derived from it — the thumbnail
|
||||
/// import jobs are [`Mutates::Always`] *and* repair-capable, inserting
|
||||
/// rows on a plain run and additionally unlinking sidecars under repair.
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,5 +37,7 @@ pub use recoverable::{
|
||||
RecoverableJobHandler, RunOutcome, RunProgress, RunStatus, RunSummary, derive_progress,
|
||||
record_or_log, run_or_resume,
|
||||
};
|
||||
pub use registry::{JobEntry, JobRegistry, JobSummary, PausedRunBrief, RegisterError};
|
||||
pub use types::{ErrCause, JobOutcome, JobRunArgs};
|
||||
pub use registry::{
|
||||
JobEntry, JobRegistry, JobSummary, PausedRunBrief, RegisterError, StartupTrigger,
|
||||
};
|
||||
pub use types::{ErrCause, JobOutcome, JobRunArgs, Mutates};
|
||||
|
||||
@@ -48,7 +48,7 @@ use uuid::Uuid;
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
use super::handler::JobHandler;
|
||||
use super::types::{JobOutcome, JobRunArgs};
|
||||
use super::types::{JobOutcome, JobRunArgs, Mutates};
|
||||
|
||||
// ─── Run status ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -238,6 +238,47 @@ pub trait RecoverableJobHandler: Send + Sync {
|
||||
/// URL fragment: `POST /api/admin/jobs/{name}/trigger`.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// What this job does, for the admin UI.
|
||||
///
|
||||
/// English, in the trait, beside the behaviour it describes — not in
|
||||
/// `locales/*.json`. A description that lives away from the code rots the
|
||||
/// moment a job changes, invisibly, and a translator cannot know what
|
||||
/// `manifests_consistency` reconciles. i18n can layer on later keyed by
|
||||
/// job name, with this as the fallback, so a missing translation degrades
|
||||
/// to English rather than a blank panel.
|
||||
///
|
||||
/// Defaulted so adding it to ~15 existing jobs is incremental rather than
|
||||
/// one breaking change.
|
||||
fn description(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
|
||||
/// Whether a run changes state, and under what conditions.
|
||||
///
|
||||
/// Three values rather than a boolean because there are three cases, and
|
||||
/// the interesting one is conditional: a job can be read-only by default
|
||||
/// and destructive under `?repair=true`. A boolean forces that job to
|
||||
/// answer wrongly for one of its two modes — `false` on something that
|
||||
/// can delete files is actively misleading.
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Never
|
||||
}
|
||||
|
||||
/// `Some(..)` when `?repair=true` does something beyond a default run,
|
||||
/// describing what it ADDS; `None` when the flag is inert.
|
||||
///
|
||||
/// One method rather than a `supports_repair` boolean plus prose: its
|
||||
/// presence drives whether the UI offers the toggle, its content drives
|
||||
/// the confirmation text. A boolean would leave the frontend to invent
|
||||
/// wording for a destructive action it does not understand.
|
||||
///
|
||||
/// Independent of [`Self::mutates`], not derived from it — the import
|
||||
/// jobs are [`Mutates::Always`] *and* repair-capable, inserting rows on a
|
||||
/// plain run and additionally unlinking files under repair.
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Long-running scan. See trait-level doc for the contract.
|
||||
///
|
||||
/// `store` — bound to THIS run (a single row in
|
||||
@@ -361,7 +402,15 @@ pub trait JobStore: Send + Sync {
|
||||
/// `"stale_used_bytes"`, `"missing_blob"`). Never rename across
|
||||
/// releases; new failure modes get new values.
|
||||
///
|
||||
/// `severity` — one of `"data_loss"`, `"inconsistent"`, `"anomaly"`.
|
||||
/// `severity` — one of:
|
||||
/// - `"data_loss"` — bytes / rows unreachable or gone.
|
||||
/// - `"inconsistent"` — counters or materialised values wrong,
|
||||
/// content intact.
|
||||
/// - `"anomaly"` — surprising state worth surfacing, no known impact.
|
||||
/// This is the level the admin panel labels "notices"; there is no
|
||||
/// separate `notice` severity, and a job that acted on what it found
|
||||
/// says so in `detail` rather than in a fourth severity that would
|
||||
/// render identically.
|
||||
///
|
||||
/// `resource_id` — the file / folder / drive / blob the finding
|
||||
/// pertains to. `None` for run-wide findings (e.g. "backend
|
||||
@@ -993,6 +1042,21 @@ impl JobHandler for RecoverableAdapter {
|
||||
// downstream.
|
||||
true
|
||||
}
|
||||
|
||||
// The registry only ever sees `dyn JobHandler`, so the tenant's own
|
||||
// metadata has to be forwarded through the wrapper or it is invisible
|
||||
// to `GET /api/admin/jobs`. Silently returning the JobHandler defaults
|
||||
// here would leave every recoverable job undescribed and reported as
|
||||
// read-only — including ones that delete files.
|
||||
fn description(&self) -> &'static str {
|
||||
self.inner.description()
|
||||
}
|
||||
fn mutates(&self) -> Mutates {
|
||||
self.inner.mutates()
|
||||
}
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
self.inner.repair_description()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ergonomics: JobRegistry extension for recoverable jobs ─────────────────
|
||||
@@ -1504,6 +1568,47 @@ mod tests {
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The registry only ever sees `dyn JobHandler`, so a recoverable
|
||||
/// tenant's metadata reaches `GET /api/admin/jobs` only if the adapter
|
||||
/// forwards it. Falling back to the `JobHandler` defaults here would
|
||||
/// report every recoverable job as undescribed and read-only —
|
||||
/// including the imports, which delete files under repair.
|
||||
#[tokio::test]
|
||||
async fn adapter_forwards_job_metadata_from_inner_handler() {
|
||||
struct Annotated;
|
||||
#[async_trait]
|
||||
impl RecoverableJobHandler for Annotated {
|
||||
fn name(&self) -> &str {
|
||||
"annotated"
|
||||
}
|
||||
async fn run_resumable(
|
||||
&self,
|
||||
_store: &dyn JobStore,
|
||||
_args: &JobRunArgs,
|
||||
_resume_cursor: Option<Vec<u8>>,
|
||||
) -> RunOutcome {
|
||||
RunOutcome::completed()
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"walks a thing"
|
||||
}
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::OnRepairOnly
|
||||
}
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
Some("fixes the thing")
|
||||
}
|
||||
}
|
||||
|
||||
let provider: Arc<dyn JobStoreProvider> = Arc::new(MemProvider::new());
|
||||
let adapter = RecoverableAdapter::new(Arc::new(Annotated), provider);
|
||||
let as_handler: &dyn JobHandler = &adapter;
|
||||
|
||||
assert_eq!(as_handler.description(), "walks a thing");
|
||||
assert_eq!(as_handler.mutates(), Mutates::OnRepairOnly);
|
||||
assert_eq!(as_handler.repair_description(), Some("fixes the thing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_run_completes_and_marks_status_completed() {
|
||||
let provider = Arc::new(MemProvider::new());
|
||||
|
||||
@@ -20,7 +20,7 @@ use serde::Serialize;
|
||||
use tokio::sync::{RwLock, Semaphore};
|
||||
|
||||
use super::handler::JobHandler;
|
||||
use super::types::{JobOutcome, JobRunArgs};
|
||||
use super::types::{JobOutcome, JobRunArgs, Mutates};
|
||||
|
||||
/// A registered job plus its runtime state. Held as `Arc<JobEntry>`
|
||||
/// inside the registry so the engine can hold a snapshot across an
|
||||
@@ -135,6 +135,13 @@ impl JobRegistry {
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<(), RegisterError> {
|
||||
let name = handler.name().to_string();
|
||||
// A job declaring it mutates only under a flag it does not support
|
||||
// is self-contradictory, and the UI would render it as safe with no
|
||||
// way to reach the mutating path. Cheap to catch here, invisible
|
||||
// otherwise.
|
||||
if handler.mutates() == Mutates::OnRepairOnly && handler.repair_description().is_none() {
|
||||
return Err(RegisterError::RepairOnlyWithoutRepair(name));
|
||||
}
|
||||
let mut guard = self.entries.write().await;
|
||||
if guard.contains_key(&name) {
|
||||
return Err(RegisterError::DuplicateName(name));
|
||||
@@ -220,17 +227,21 @@ impl JobRegistry {
|
||||
};
|
||||
JobSummary {
|
||||
name,
|
||||
description: entry.handler.description(),
|
||||
mutates: entry.handler.mutates(),
|
||||
repair_description: entry.handler.repair_description(),
|
||||
interval_ms: entry.interval.map(|d| d.as_millis() as u64),
|
||||
next_run_at: state.next_run_at,
|
||||
last_run_at,
|
||||
last_outcome,
|
||||
running: state.current_run_start.is_some(),
|
||||
recoverable: entry.handler.is_recoverable(),
|
||||
// Populated in `list_jobs` handler via a single
|
||||
// DB round-trip — kept out of the registry
|
||||
// snapshot to avoid pulling a DB dependency into
|
||||
// the in-memory scheduler state.
|
||||
// Both populated in the `list_jobs` handler — one
|
||||
// from a DB round-trip, one from AppConfig. Kept
|
||||
// out of the registry snapshot so the in-memory
|
||||
// scheduler state pulls in neither dependency.
|
||||
paused_run: None,
|
||||
startup: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -283,6 +294,11 @@ impl Default for JobRegistry {
|
||||
pub enum RegisterError {
|
||||
#[error("job name already registered: {0}")]
|
||||
DuplicateName(String),
|
||||
#[error(
|
||||
"job {0} declares mutates = OnRepairOnly but no repair_description() — \
|
||||
it claims to mutate only under a flag it does not support"
|
||||
)]
|
||||
RepairOnlyWithoutRepair(String),
|
||||
}
|
||||
|
||||
/// Per-job row in the `GET /api/admin/jobs` response.
|
||||
@@ -304,6 +320,17 @@ pub enum RegisterError {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JobSummary {
|
||||
pub name: String,
|
||||
/// One or two sentences on what the job does. Empty for jobs that
|
||||
/// haven't declared one yet — the UI omits the line rather than
|
||||
/// rendering a blank block.
|
||||
#[serde(skip_serializing_if = "str::is_empty")]
|
||||
pub description: &'static str,
|
||||
pub mutates: Mutates,
|
||||
/// `Some` iff the job does something extra under `?repair=true`.
|
||||
/// Presence is what gates the repair toggle in the UI; the string
|
||||
/// is the confirmation text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repair_description: Option<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interval_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -320,6 +347,32 @@ pub struct JobSummary {
|
||||
/// picks Resume when the latest row is Paused).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub paused_run: Option<PausedRunBrief>,
|
||||
/// Populated iff `OXICLOUD_STARTUP_JOBS` names this job — the flags
|
||||
/// it will be dispatched with at every boot.
|
||||
///
|
||||
/// Surfaced because the panel would otherwise be silently wrong
|
||||
/// about the most consequential thing on the row: a job configured
|
||||
/// with `repair=true` deletes files on every restart, and reading
|
||||
/// the row you would think that only happens when someone clicks.
|
||||
/// Filled by the `list_jobs` handler, which has the config; the
|
||||
/// registry deliberately doesn't.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub startup: Option<StartupTrigger>,
|
||||
}
|
||||
|
||||
/// The flags a job configured in `OXICLOUD_STARTUP_JOBS` runs with.
|
||||
///
|
||||
/// Mirrors `JobRunArgs` on the wire rather than embedding it, because
|
||||
/// this is an API shape the admin panel switches on, and `JobRunArgs`
|
||||
/// is an internal dispatch type free to change without a frontend
|
||||
/// release.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StartupTrigger {
|
||||
pub force: bool,
|
||||
pub deep: bool,
|
||||
pub repair: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub storage: Option<String>,
|
||||
}
|
||||
|
||||
/// Enough info about a paused recoverable run for the admin panel to
|
||||
@@ -393,6 +446,78 @@ mod tests {
|
||||
assert!(matches!(err, RegisterError::DuplicateName(_)));
|
||||
}
|
||||
|
||||
/// A job declaring `OnRepairOnly` without a `repair_description` has
|
||||
/// no reachable mutating path — the UI gates the repair toggle on
|
||||
/// that string's presence, so the job would render as safe and stay
|
||||
/// read-only forever. Catch it at wiring time rather than let it read
|
||||
/// as a working configuration.
|
||||
#[tokio::test]
|
||||
async fn repair_only_without_repair_description_rejected() {
|
||||
struct Contradictory;
|
||||
#[async_trait]
|
||||
impl JobHandler for Contradictory {
|
||||
fn name(&self) -> &str {
|
||||
"contradictory"
|
||||
}
|
||||
async fn run(&self, _args: &JobRunArgs) -> JobOutcome {
|
||||
JobOutcome::ok(0)
|
||||
}
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::OnRepairOnly
|
||||
}
|
||||
// repair_description() left at its `None` default — the bug.
|
||||
}
|
||||
|
||||
let reg = JobRegistry::new();
|
||||
let err = reg
|
||||
.try_register(Arc::new(Contradictory), None, None)
|
||||
.await
|
||||
.expect_err("OnRepairOnly without a repair_description must be rejected");
|
||||
assert!(matches!(err, RegisterError::RepairOnlyWithoutRepair(_)));
|
||||
}
|
||||
|
||||
/// The registry hands `dyn JobHandler` to the admin snapshot, so a
|
||||
/// tenant's own metadata is only visible if it survives that erasure.
|
||||
#[tokio::test]
|
||||
async fn snapshot_carries_job_metadata() {
|
||||
struct Described;
|
||||
#[async_trait]
|
||||
impl JobHandler for Described {
|
||||
fn name(&self) -> &str {
|
||||
"described"
|
||||
}
|
||||
async fn run(&self, _args: &JobRunArgs) -> JobOutcome {
|
||||
JobOutcome::ok(0)
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"does a thing"
|
||||
}
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
Some("also deletes the thing")
|
||||
}
|
||||
}
|
||||
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(Arc::new(Described), None, None).await;
|
||||
let snap = reg.snapshot().await;
|
||||
let row = snap.iter().find(|j| j.name == "described").unwrap();
|
||||
assert_eq!(row.description, "does a thing");
|
||||
assert_eq!(row.mutates, Mutates::Always);
|
||||
assert_eq!(row.repair_description, Some("also deletes the thing"));
|
||||
|
||||
// Undeclared jobs stay at the safe defaults so the panel can tell
|
||||
// "read-only" from "not yet described" — empty string, not prose.
|
||||
reg.register(handler("bare"), None, None).await;
|
||||
let snap = reg.snapshot().await;
|
||||
let bare = snap.iter().find(|j| j.name == "bare").unwrap();
|
||||
assert_eq!(bare.description, "");
|
||||
assert_eq!(bare.mutates, Mutates::Never);
|
||||
assert!(bare.repair_description.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[should_panic(expected = "DI wiring bug")]
|
||||
async fn register_panics_on_duplicate() {
|
||||
|
||||
@@ -167,10 +167,52 @@ impl fmt::Display for ErrCause {
|
||||
}
|
||||
}
|
||||
|
||||
/// When a job changes state.
|
||||
///
|
||||
/// Drives how the admin UI presents a trigger: `Never` earns a read-only
|
||||
/// badge, `OnRepairOnly` is safe to run and warns only when the toggle is on,
|
||||
/// `Always` warns regardless.
|
||||
///
|
||||
/// Three values rather than a boolean because there are three cases, and the
|
||||
/// interesting one is conditional. `false` on a job that can delete files
|
||||
/// under `?repair=true` is actively misleading; `true` on one that is
|
||||
/// read-only by default is equally wrong. `OnRepairOnly` names the case a
|
||||
/// boolean cannot, and it is where the recovery framework is heading —
|
||||
/// discovery-only by default, mutation behind an explicit opt-in — so a
|
||||
/// consistency tenant that later grows a repair arm changes this one value
|
||||
/// and nothing else.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Mutates {
|
||||
/// Read-only under every flag. All consistency tenants.
|
||||
Never,
|
||||
/// Changes state on a plain run. GC, janitors, the import jobs.
|
||||
Always,
|
||||
/// Read-only by default; mutates only under `?repair=true`. Pairing this
|
||||
/// with `repair_description() == None` is contradictory — a job claiming
|
||||
/// it mutates only under a flag it does not support.
|
||||
OnRepairOnly,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mutates_serialises_snake_case() {
|
||||
// The admin UI switches on these strings — a rename is a breaking
|
||||
// change to the panel, not just to Rust callers.
|
||||
assert_eq!(serde_json::to_string(&Mutates::Never).unwrap(), "\"never\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Mutates::Always).unwrap(),
|
||||
"\"always\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Mutates::OnRepairOnly).unwrap(),
|
||||
"\"on_repair_only\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joboutcome_kind_label() {
|
||||
assert_eq!(JobOutcome::ok(0).kind(), "ok");
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
//! Fifth tenant of Part 2 (recoverable-run engine).
|
||||
//!
|
||||
//! Iterates the storage backend's blob-enumeration surface and
|
||||
//! reports every blob physically present on the backend that has NO
|
||||
//! matching row in `storage.blobs`. Complements
|
||||
//! `blobs_consistency` (which walks the DB and probes the backend):
|
||||
//! together they close the reference graph.
|
||||
//! **Merge-joins** the backend's blob enumeration against
|
||||
//! `storage.blobs`, both ordered by hash, so a single pass yields the
|
||||
//! delta in *both* directions rather than one.
|
||||
//!
|
||||
//! ### Per-row check
|
||||
//! It previously walked the backend and probed the DB with
|
||||
//! `WHERE hash = ANY($1)` over each page, which could only ever see
|
||||
//! backend-only entries: a row whose bytes are gone never appears in a
|
||||
//! backend listing, so it was invisible here by construction. That half
|
||||
//! was left to `blobs_consistency`'s per-row HEAD probe, which does not
|
||||
//! survive the row counts this plan produces — see
|
||||
//! `docs/plan/derived-blobs.md`. That probe is now gone: this tenant
|
||||
//! owns every backend-side check, and `blobs_consistency` is DB-only.
|
||||
//!
|
||||
//! ### Per-row checks
|
||||
//!
|
||||
//! * `orphan_blob` (severity `inconsistent`) — bytes on disk / S3 /
|
||||
//! Azure with no registry row. Not data-loss (nothing broken —
|
||||
//! just storage overhead), but points at dedup_gc or
|
||||
//! ingest-path drift. Recovery = register-registry-row (if the
|
||||
//! bytes are still needed) OR delete the file (if truly orphan).
|
||||
//! * `blob_missing_from_backend` (severity `data_loss`) — a registry
|
||||
//! row whose bytes are absent. The opposite direction and the more
|
||||
//! serious one: an orphan wastes space, this loses a file.
|
||||
//! * `blob_corrupted` (severity `data_loss`, `?deep=true` only) —
|
||||
//! the key exists on both sides but the bytes behind it no longer
|
||||
//! hash to it. Silent bit-rot.
|
||||
//! * `blob_unreadable` (severity `data_loss`, `?deep=true` only) —
|
||||
//! the key exists but the bytes cannot be read at all: decrypt
|
||||
//! failure (missing key), transport error, permissions. Same impact
|
||||
//! as corruption from a file's point of view, different remedy,
|
||||
//! hence a separate kind. Triage on the recorded `error`.
|
||||
//!
|
||||
//! ### Deep mode
|
||||
//!
|
||||
//! The last two moved here from `blobs_consistency`, which used to
|
||||
//! carry a backend solely for them. Re-hashing is backend work end to
|
||||
//! end — the only DB input is the hash — and this walk already holds
|
||||
//! the matched key pairs, which is exactly the set worth reading. It
|
||||
//! costs a full read of every blob, so it is opt-in.
|
||||
//!
|
||||
//! ### Why the two orderings agree
|
||||
//!
|
||||
//! The merge-join's premise is that the backend's byte order and the
|
||||
//! database's `ORDER BY hash` rank identically. They do, because hashes
|
||||
//! are lowercase BLAKE3 hex of fixed length: over `[0-9a-f]` digits
|
||||
//! precede letters in both, and there is no case to fold. A hash column
|
||||
//! that ever admitted uppercase or variable length would break this
|
||||
//! silently and in both directions at once.
|
||||
//!
|
||||
//! ### Run-level check
|
||||
//!
|
||||
@@ -42,7 +77,6 @@
|
||||
//! denominator (backend count ≈ blob count on a healthy install;
|
||||
//! deviation IS the finding).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -54,14 +88,18 @@ use crate::infrastructure::scheduler::{
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, ProgressKind, RecoverableJobHandler,
|
||||
RunOutcome, RunStatus, record_or_log,
|
||||
};
|
||||
use crate::infrastructure::services::blob_diagnostics::affected_files;
|
||||
|
||||
pub const BACKEND_CONSISTENCY_JOB_NAME: &str = "backend_consistency";
|
||||
|
||||
/// Same `params` JSONB key `blobs_consistency` uses — kept identical
|
||||
/// so operators grepping run rows see the same convention across
|
||||
/// both storage-audit tenants.
|
||||
pub const PROBED_STORAGE_PARAM: &str =
|
||||
crate::infrastructure::services::blobs_consistency_service::PROBED_STORAGE_PARAM;
|
||||
/// `params` JSONB key under which the entry name being enumerated is
|
||||
/// stashed on a Fresh run (matches `TARGET_NAME_PARAM` on
|
||||
/// `backend_migration`). Resumed runs re-read it so a paused audit
|
||||
/// survives restart without the admin re-specifying the target.
|
||||
///
|
||||
/// Defined here rather than in `blobs_consistency`, which no longer
|
||||
/// touches a backend and so has no entry to scope.
|
||||
pub const PROBED_STORAGE_PARAM: &str = "probed_storage";
|
||||
|
||||
/// Batch size for backend enumeration + DB probe. 500 is enough to
|
||||
/// amortise the DB round-trip while keeping the cancel-poll cadence
|
||||
@@ -130,6 +168,16 @@ impl RecoverableJobHandler for BackendConsistencyCheck {
|
||||
BACKEND_CONSISTENCY_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Merge-joins the storage backend's blob enumeration against \
|
||||
storage.blobs, both ordered by hash, so one pass yields the delta \
|
||||
in both directions: bytes on the backend no DB row claims, and \
|
||||
rows whose bytes are gone. Add ?deep=true to also read every \
|
||||
matched blob back and re-hash it, catching silent bit-rot — that \
|
||||
is a full read of storage and can take hours. Read-only in both \
|
||||
modes: nothing is uploaded or deleted."
|
||||
}
|
||||
|
||||
/// Approximate total: on a healthy install every backend blob
|
||||
/// has a `storage.blobs` row, so the DB count is a proxy for
|
||||
/// the backend count. The fraction deviating from 1.0 at run
|
||||
@@ -234,6 +282,49 @@ impl RecoverableJobHandler for BackendConsistencyCheck {
|
||||
);
|
||||
}
|
||||
|
||||
// Deep mode — read every matched blob back and re-hash it, rather
|
||||
// than trusting that a key present on both sides means the bytes
|
||||
// behind it are still the bytes that key names.
|
||||
//
|
||||
// It lives here rather than in `blobs_consistency` because it is
|
||||
// a backend operation end to end: the only DB input is the hash,
|
||||
// which this merge-join already holds. Keeping it there forced
|
||||
// that tenant to carry a backend for one flag, which is the
|
||||
// overlap this split removes.
|
||||
//
|
||||
// Persisted to `params.deep` on a Fresh run so a Resume picks up
|
||||
// the same mode (a Paused deep scan must not silently continue
|
||||
// shallow) and the admin run-detail view can show what the scan
|
||||
// actually verified. Written BEFORE the walk so a crash mid-batch
|
||||
// still leaves the marker.
|
||||
let deep = if is_fresh {
|
||||
let v = if args.deep { "true" } else { "false" };
|
||||
if let Err(e) = store.set_string_param("deep", v).await {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("failed to persist deep flag to params: {e}"),
|
||||
};
|
||||
}
|
||||
args.deep
|
||||
} else {
|
||||
match store.get_string_param("deep").await {
|
||||
Ok(Some(v)) => v == "true",
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("read `deep` from params: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
if deep {
|
||||
tracing::info!(
|
||||
target: "oxicloud::consistency",
|
||||
event = "backend_consistency.deep_mode_active",
|
||||
run_id = %store.run_id(),
|
||||
"deep mode: re-reading + re-hashing every matched blob (bit-rot detection)"
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor = opaque backend continuation token, UTF-8-encoded.
|
||||
// Each backend defines its own format (local = shard/hash,
|
||||
// S3 = ListObjectsV2 continuation token, Azure = list
|
||||
@@ -376,60 +467,174 @@ impl RecoverableJobHandler for BackendConsistencyCheck {
|
||||
return RunOutcome::completed();
|
||||
}
|
||||
|
||||
// Batch DB probe: which of these hashes have a
|
||||
// `storage.blobs` row? One `WHERE hash = ANY($1)` per
|
||||
// batch — indexed lookup, cheap even on millions of
|
||||
// rows.
|
||||
let batch_hashes: Vec<String> = page.blobs.iter().map(|e| e.hash.clone()).collect();
|
||||
let db_present: HashSet<String> = if batch_hashes.is_empty() {
|
||||
HashSet::new()
|
||||
} else {
|
||||
match sqlx::query_as::<_, (String,)>(
|
||||
r#"SELECT hash FROM storage.blobs WHERE hash = ANY($1)"#,
|
||||
)
|
||||
.bind(&batch_hashes[..])
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows.into_iter().map(|(h,)| h).collect(),
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("db probe: {e}"),
|
||||
};
|
||||
}
|
||||
// ── Merge-join, not a one-sided probe ────────────────
|
||||
//
|
||||
// Both sides are ordered by hash ascending — the backend by
|
||||
// contract (`BlobStorageBackend::list_blob_hashes`), the DB by
|
||||
// `ORDER BY hash` — so one pass yields BOTH deltas instead of
|
||||
// one:
|
||||
//
|
||||
// * present on the backend, absent from the DB → `orphan_blob`
|
||||
// * present in the DB, absent from the backend →
|
||||
// `blob_missing_from_backend` (data loss, not overhead)
|
||||
//
|
||||
// The old form probed `WHERE hash = ANY($1)` over the backend
|
||||
// page, so it could only ever see the first kind: a row whose
|
||||
// bytes are gone never appears in a backend listing and was
|
||||
// invisible here by construction.
|
||||
//
|
||||
// Ordering is the whole premise, so it is worth being explicit
|
||||
// about why the two agree. Hashes are lowercase BLAKE3 hex of
|
||||
// fixed length, and over `[0-9a-f]` the database collation and
|
||||
// byte order rank identically (digits before letters in both,
|
||||
// no case folding to disagree about). A hash column that ever
|
||||
// admitted uppercase or variable length would break this
|
||||
// silently, in both directions.
|
||||
let db_hashes: Vec<String> = match sqlx::query_as::<_, (String,)>(
|
||||
r#"SELECT hash FROM storage.blobs
|
||||
WHERE ($1::text IS NULL OR hash > $1)
|
||||
ORDER BY hash
|
||||
LIMIT $2"#,
|
||||
)
|
||||
.bind(cursor.as_deref())
|
||||
.bind(BATCH_SIZE as i64)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows.into_iter().map(|(h,)| h).collect(),
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("db page: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
for entry in &page.blobs {
|
||||
if db_present.contains(&entry.hash) {
|
||||
continue;
|
||||
}
|
||||
if let Some(mtime) = entry.mtime
|
||||
&& mtime > grace_cutoff
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The two pages cover different ranges, so only the overlap can
|
||||
// be judged. Beyond `horizon` a hash missing from one side may
|
||||
// simply be on the next page of the other, and emitting there
|
||||
// would invent findings in both directions. When a side is
|
||||
// exhausted its entries cannot be "on a later page", so the
|
||||
// other side's tail becomes judgeable.
|
||||
let backend_last = page.blobs.last().map(|e| e.hash.as_str());
|
||||
let db_last = db_hashes.last().map(|s| s.as_str());
|
||||
let backend_done = page.next_cursor.is_none();
|
||||
let db_done = db_hashes.len() < BATCH_SIZE;
|
||||
|
||||
finding_count += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
BACKEND_CONSISTENCY_JOB_NAME,
|
||||
"orphan_blob",
|
||||
"inconsistent",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"hash": entry.hash,
|
||||
"mtime": entry.mtime.map(|t| t.to_rfc3339()),
|
||||
"backend": backend.backend_type(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let horizon: Option<&str> = match (backend_last, db_last) {
|
||||
_ if backend_done && db_done => None, // judge everything
|
||||
(Some(b), Some(d)) if backend_done => Some(b.max(d)),
|
||||
(Some(b), Some(d)) if db_done => Some(b.max(d)),
|
||||
(Some(b), Some(d)) => Some(b.min(d)),
|
||||
(Some(b), None) => Some(b),
|
||||
(None, Some(d)) => Some(d),
|
||||
(None, None) => None,
|
||||
};
|
||||
let in_range = |h: &str| horizon.is_none_or(|limit| h <= limit);
|
||||
|
||||
let mut bi = page.blobs.iter().peekable();
|
||||
let mut di = db_hashes.iter().peekable();
|
||||
loop {
|
||||
match (bi.peek(), di.peek()) {
|
||||
// Present on both sides. Shallow: nothing to say — the
|
||||
// key exists where the registry claims. Deep: the key
|
||||
// matching says nothing about the bytes behind it, so
|
||||
// read them back and re-hash.
|
||||
//
|
||||
// Guarded by `in_range` so a pair past the horizon is
|
||||
// not read twice — the cursor stops at the horizon, so
|
||||
// that pair comes round again next batch and is
|
||||
// verified then.
|
||||
(Some(b), Some(d)) if b.hash == **d => {
|
||||
if deep && in_range(&b.hash) {
|
||||
finding_count +=
|
||||
self.verify_bytes(store, backend.as_ref(), &b.hash).await;
|
||||
}
|
||||
bi.next();
|
||||
di.next();
|
||||
}
|
||||
// Backend-only: bytes with no registry row.
|
||||
(Some(b), d_opt)
|
||||
if d_opt.is_none_or(|d| b.hash.as_str() < d.as_str())
|
||||
&& in_range(&b.hash) =>
|
||||
{
|
||||
// Grace window: the write path is
|
||||
// durability-before-visibility, so bytes exist
|
||||
// briefly before their row does. Without this every
|
||||
// in-flight upload reads as an orphan.
|
||||
if !matches!(b.mtime, Some(m) if m > grace_cutoff) {
|
||||
finding_count += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
BACKEND_CONSISTENCY_JOB_NAME,
|
||||
"orphan_blob",
|
||||
"inconsistent",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"hash": b.hash,
|
||||
"mtime": b.mtime.map(|t| t.to_rfc3339()),
|
||||
"backend": backend.backend_type(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
bi.next();
|
||||
}
|
||||
// DB-only: a row whose bytes are gone. Severity is
|
||||
// `data_loss`, not `inconsistent` — an orphan wastes
|
||||
// space, this loses a file.
|
||||
(b_opt, Some(d))
|
||||
if b_opt.is_none_or(|b| d.as_str() < b.hash.as_str()) && in_range(d) =>
|
||||
{
|
||||
finding_count += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
BACKEND_CONSISTENCY_JOB_NAME,
|
||||
"blob_missing_from_backend",
|
||||
"data_loss",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"hash": d,
|
||||
"backend": backend.backend_type(),
|
||||
"note": "registry row with no bytes on the backend",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
di.next();
|
||||
}
|
||||
// Past the horizon on both sides, or both exhausted.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Advance cursor + checkpoint. Scanned count tracks
|
||||
// both blobs and unknowns since we walked both.
|
||||
// Advance to the horizon, not the backend's own cursor.
|
||||
//
|
||||
// One hash serves both sides: they share an ordering, so "resume
|
||||
// after H" means `start_after(H)` on the backend and
|
||||
// `WHERE hash > H` in the DB. Advancing past the horizon would
|
||||
// skip the un-judged tail of whichever side reached further.
|
||||
//
|
||||
// Scanned count covers blobs and unknowns, since both were
|
||||
// walked.
|
||||
let batch_len = (page.blobs.len() + page.unknowns.len()) as u64;
|
||||
cursor = page.next_cursor;
|
||||
let exhausted = backend_done && db_done;
|
||||
cursor = if exhausted {
|
||||
None
|
||||
} else {
|
||||
horizon.map(|h| h.to_string())
|
||||
};
|
||||
|
||||
// Neither side exhausted yet no horizon means neither returned a
|
||||
// row — nothing left to compare, and continuing would spin on the
|
||||
// same empty pages forever.
|
||||
if cursor.is_none() && !exhausted && horizon.is_none() {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::consistency",
|
||||
event = "backend_consistency.no_horizon",
|
||||
run_id = %store.run_id(),
|
||||
"both sides returned no rows before exhaustion; ending the sweep"
|
||||
);
|
||||
}
|
||||
|
||||
let cursor_bytes = cursor
|
||||
.as_ref()
|
||||
.map(|s| s.as_bytes().to_vec())
|
||||
@@ -440,8 +645,7 @@ impl RecoverableJobHandler for BackendConsistencyCheck {
|
||||
};
|
||||
}
|
||||
|
||||
// Backend returned no next_cursor → enumeration
|
||||
// complete. Emit the completion log and return.
|
||||
// Both sides drained → the sweep is complete.
|
||||
if cursor.is_none() {
|
||||
tracing::info!(
|
||||
target: "oxicloud::consistency",
|
||||
@@ -456,3 +660,106 @@ impl RecoverableJobHandler for BackendConsistencyCheck {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendConsistencyCheck {
|
||||
/// Deep-mode per-blob verification. Reads the blob back, re-hashes it,
|
||||
/// and records what it finds. Returns the number of findings recorded
|
||||
/// (0 or 1) so the caller's counter stays the single tally.
|
||||
///
|
||||
/// Moved here from `blobs_consistency` along with the rest of the
|
||||
/// backend-touching work: the merge-join already holds a verified
|
||||
/// key pair, which is exactly the set worth reading.
|
||||
async fn verify_bytes(
|
||||
&self,
|
||||
store: &dyn JobStore,
|
||||
backend: &dyn BlobStorageBackend,
|
||||
hash: &str,
|
||||
) -> u64 {
|
||||
match recompute_hash(backend, hash).await {
|
||||
// The bytes still hash to the key they are filed under.
|
||||
Ok(computed) if computed == hash => 0,
|
||||
// Silent bit-rot. `computed_hash` is reported rather than a
|
||||
// bare "mismatch" because the value is diagnostic: a one-bit
|
||||
// flip, a truncation and a whole-object swap leave distinct
|
||||
// signatures.
|
||||
Ok(computed) => {
|
||||
let affected = affected_files(self.pool.as_ref(), hash).await;
|
||||
record_or_log(
|
||||
store,
|
||||
BACKEND_CONSISTENCY_JOB_NAME,
|
||||
"blob_corrupted",
|
||||
"data_loss",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"hash": hash,
|
||||
"computed_hash": computed,
|
||||
"backend": backend.backend_type(),
|
||||
"affected_files": affected,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
1
|
||||
}
|
||||
// Bytes are there by key but cannot be read at all: decrypt
|
||||
// failure (missing key), transport error, permissions. Same
|
||||
// impact as corruption from a file's point of view — the
|
||||
// content is inaccessible — but a different remedy, which is
|
||||
// why it is a separate kind rather than folded into
|
||||
// `blob_corrupted`. Operators triage on `error`.
|
||||
Err(e) => {
|
||||
let affected = affected_files(self.pool.as_ref(), hash).await;
|
||||
record_or_log(
|
||||
store,
|
||||
BACKEND_CONSISTENCY_JOB_NAME,
|
||||
"blob_unreadable",
|
||||
"data_loss",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"hash": hash,
|
||||
"backend": backend.backend_type(),
|
||||
"affected_files": affected,
|
||||
"error": e.to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
tracing::warn!(
|
||||
target: "oxicloud::consistency",
|
||||
event = "backend_consistency.blob_unreadable",
|
||||
run_id = %store.run_id(),
|
||||
hash = %hash,
|
||||
error = %e,
|
||||
"🚨 blob unreadable in deep mode — recorded finding, continuing"
|
||||
);
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-mode helper — read the blob from the backend and recompute its
|
||||
/// BLAKE3 hash. Returns the recomputed hex string; callers compare it
|
||||
/// against the expected hash themselves. Returning the actual hash (not
|
||||
/// a bool) lets the finding surface WHAT the bytes now hash to, which is
|
||||
/// diagnostic gold: a one-bit flip has a very different signature from a
|
||||
/// chunk-boundary corruption or a truncated read. `Err(_)` on any
|
||||
/// backend-side error — the caller records that as `blob_unreadable`
|
||||
/// rather than as corruption.
|
||||
async fn recompute_hash(
|
||||
backend: &dyn BlobStorageBackend,
|
||||
expected_hash: &str,
|
||||
) -> Result<String, crate::common::errors::DomainError> {
|
||||
use crate::common::errors::DomainError;
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut stream = backend.get_blob_stream(expected_hash).await?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let bytes = chunk.map_err(|e| {
|
||||
DomainError::internal_error("BackendConsistency", format!("stream read: {e}"))
|
||||
})?;
|
||||
hasher.update(&bytes);
|
||||
}
|
||||
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use crate::common::config::NamedStorageEntry;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::infrastructure::scheduler::{
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, RecoverableJobHandler, RunOutcome,
|
||||
RunStatus, record_or_log,
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, Mutates, RecoverableJobHandler,
|
||||
RunOutcome, RunStatus, record_or_log,
|
||||
};
|
||||
use crate::infrastructure::services::encrypted_blob_backend::{EncryptedBlobBackend, HeadCheck};
|
||||
use crate::infrastructure::services::entry_backend::{
|
||||
@@ -188,6 +188,20 @@ impl RecoverableJobHandler for BackendMigrationService {
|
||||
BACKEND_MIGRATION_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Copies every blob payload from the backend the server booted with \
|
||||
to the one the current storage settings describe. Covers legacy \
|
||||
whole-file blobs and CDC chunks in a single walk. Resumable — a \
|
||||
paused or crashed run continues from its cursor rather than \
|
||||
restarting."
|
||||
}
|
||||
|
||||
/// Writes bytes to the target backend. Source bytes are left in place —
|
||||
/// the copy is additive, so an aborted migration loses nothing.
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
/// Definitive count — one row per blob. `SELECT COUNT(*) FROM
|
||||
/// storage.blobs` on a modern PG is a sub-second index-only scan
|
||||
/// even at millions of rows.
|
||||
|
||||
@@ -62,8 +62,8 @@ use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use crate::common::config::NamedStorageEntry;
|
||||
use crate::common::migration_progress::MigrationProgress;
|
||||
use crate::infrastructure::scheduler::{
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, RecoverableJobHandler, RunOutcome,
|
||||
RunStatus, record_or_log,
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, Mutates, RecoverableJobHandler,
|
||||
RunOutcome, RunStatus, record_or_log,
|
||||
};
|
||||
use crate::infrastructure::services::encrypted_blob_backend::BlobFormat;
|
||||
use crate::infrastructure::services::entry_backend::build_entry_backend_typed;
|
||||
@@ -135,6 +135,20 @@ impl RecoverableJobHandler for BackendRotateService {
|
||||
BACKEND_ROTATE_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Brings every blob's on-disk format in line with the storage \
|
||||
entry's current head key: encrypts plaintext, re-encrypts under a \
|
||||
rotated key, decrypts when the head is 'none', and upgrades \
|
||||
legacy blobs to v1. Blobs already in the right format are skipped, \
|
||||
so re-running after a key change is cheap."
|
||||
}
|
||||
|
||||
/// Rewrites blobs **in place**. Unlike a migration this has no additive
|
||||
/// fallback — the previous ciphertext is gone once a blob is rewritten.
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
/// Definitive count — one row per blob. Same query as
|
||||
/// `backend_migration::count_total`; the two walk the same rows.
|
||||
async fn count_total(&self) -> Option<u64> {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Reverse-lookup helpers shared by the storage consistency tenants.
|
||||
//!
|
||||
//! `blobs_consistency` (DB-side: refcount drift) and
|
||||
//! `backend_consistency` (backend-side: missing / orphaned / corrupted
|
||||
//! bytes) both answer the same operator question when they emit a
|
||||
//! finding — *which files does this hash break?* — so the query lives
|
||||
//! here rather than in whichever tenant happened to need it first.
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Cap on reverse-lookup file names surfaced in a finding's detail.
|
||||
/// Keeps detail JSON bounded when a broken blob is referenced by
|
||||
/// hundreds of files.
|
||||
const AFFECTED_FILES_SAMPLE: i64 = 5;
|
||||
|
||||
/// Sample of file names that reference this blob — either directly
|
||||
/// (`files.blob_hash = $hash`, legacy pre-CDC) or transitively via a
|
||||
/// manifest (`chunk_hashes @> ARRAY[$hash]`, the post-CDC dominant
|
||||
/// path). Capped so a chunk shared by 10 000 files doesn't blow up the
|
||||
/// finding detail JSON. Order is arbitrary — this samples for
|
||||
/// diagnosis, it does not enumerate.
|
||||
///
|
||||
/// Returns an empty vec on query error: a finding with no sample is
|
||||
/// still a finding, and failing the sweep because the diagnostic
|
||||
/// garnish didn't load would trade the whole scan for a nicety.
|
||||
pub(crate) async fn affected_files(pool: &PgPool, hash: &str) -> Vec<String> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT DISTINCT f.name
|
||||
FROM storage.files f
|
||||
WHERE f.blob_hash = $1
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM storage.chunk_manifests m
|
||||
WHERE m.file_hash = f.blob_hash
|
||||
AND $1 = ANY(m.chunk_hashes)
|
||||
)
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(AFFECTED_FILES_SAMPLE)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
rows.into_iter().map(|(n,)| n).collect()
|
||||
}
|
||||
@@ -1,32 +1,12 @@
|
||||
//! Fourth tenant of Part 2 (recoverable-run engine).
|
||||
//!
|
||||
//! Iterates `storage.blobs` — the content-addressable registry —
|
||||
//! and verifies each row against the physical backend AND against
|
||||
//! the reference-counting invariants that `dedup_gc` relies on.
|
||||
//! Iterates `storage.blobs` — the content-addressable registry — and
|
||||
//! checks the reference-counting invariant `dedup_gc` relies on.
|
||||
//!
|
||||
//! Three per-row checks (subject-iteration principle in action —
|
||||
//! one walk, multiple branches):
|
||||
//! **Database only.** It opens no backend and makes no network call;
|
||||
//! `?storage=<name>` and `?deep=true` are both inert here.
|
||||
//!
|
||||
//! * `blob_missing_from_backend` (severity `data_loss`) — the DB
|
||||
//! row says the hash exists but `BlobStorageBackend::blob_exists`
|
||||
//! returns false. Bytes gone from disk / S3 / Azure. Any file
|
||||
//! whose manifest references this hash (or whose whole-file
|
||||
//! `blob_hash` points at it) will fail to read.
|
||||
//!
|
||||
//! * `blob_corrupted` (severity `data_loss`, deep mode only) —
|
||||
//! bytes exist on the backend but their BLAKE3 no longer matches
|
||||
//! the hash under which they're indexed. Silent bit-rot. Only
|
||||
//! runs when the operator passes `?deep=true` because it costs a
|
||||
//! full read of every blob.
|
||||
//!
|
||||
//! * `blob_unreadable` (severity `data_loss`, deep mode only) —
|
||||
//! `blob_exists` returned true but the read pipeline errored (can't
|
||||
//! decrypt, network glitch, permission error, etc.). Distinct from
|
||||
//! `blob_corrupted` (which requires successful read + hash mismatch);
|
||||
//! here we can't get bytes out at all. Same operator impact — any
|
||||
//! file referencing this hash is inaccessible — but the remedy
|
||||
//! differs (key recovery, retry, or blob replacement, depending on
|
||||
//! the recorded `error` field).
|
||||
//! One per-row check:
|
||||
//!
|
||||
//! * `refcount_mismatch` (severity `inconsistent`) —
|
||||
//! `storage.blobs.ref_count` disagrees with the actual reference
|
||||
@@ -36,85 +16,57 @@
|
||||
//! a blob is being pinned longer than needed. Content-safe either
|
||||
//! way (the storage.blobs row is fine, the counter is wrong).
|
||||
//!
|
||||
//! ### Complements `files_consistency`
|
||||
//! ### Why nothing physical lives here any more
|
||||
//!
|
||||
//! `files_consistency` (Slice 6/10) iterates files and verifies DB
|
||||
//! integrity. `blobs_consistency` iterates the storage registry and
|
||||
//! verifies physical existence + counter integrity. Together they
|
||||
//! cover both sides of the reference graph. Neither doubles the
|
||||
//! other's work — probing per-blob (here) instead of per-file-chunk
|
||||
//! preserves dedup savings: a chunk shared by 5 files gets probed
|
||||
//! ONCE.
|
||||
//! This tenant used to probe `BlobStorageBackend::blob_exists` once
|
||||
//! per row for `blob_missing_from_backend`, and under `?deep=true`
|
||||
//! read and re-hashed every blob for `blob_corrupted` /
|
||||
//! `blob_unreadable`.
|
||||
//!
|
||||
//! ### Not covered here
|
||||
//! All three moved to `backend_consistency`, which merge-joins the
|
||||
//! backend's enumeration against this same table in one ordered pass.
|
||||
//! It reports the same missing bytes, plus the backend-only orphans a
|
||||
//! DB walk cannot see by construction, at one enumeration instead of
|
||||
//! N round-trips — and a deep pass there re-hashes the matched pairs
|
||||
//! it already holds. Keeping the probe here bought nothing and made
|
||||
//! every scheduled sweep pay for it.
|
||||
//!
|
||||
//! * **Orphan bytes on the backend** (files on disk with no DB row)
|
||||
//! — belongs in the future `backend_consistency` tenant which
|
||||
//! iterates the backend itself. Requires the `list_blob_hashes`
|
||||
//! trait extension and per-backend enumeration impls.
|
||||
//! What is left is the half that needs no backend at all: a counter,
|
||||
//! and the two tables that determine what it should be.
|
||||
//!
|
||||
//! ### Elsewhere in the graph
|
||||
//!
|
||||
//! * **Physical existence, orphan bytes, bit-rot** —
|
||||
//! `backend_consistency`.
|
||||
//! * **File-side DB integrity** (parent folder, blob reference,
|
||||
//! denormalised size) — `files_consistency`.
|
||||
//! * **Manifest-level integrity** (`storage.chunk_manifests` rows
|
||||
//! pointing at reaped chunks) — already covered by
|
||||
//! `files_consistency::chunk_missing`.
|
||||
//! pointing at reaped chunks) — `files_consistency::chunk_missing`.
|
||||
//! * **The OTHER refcount** (`chunk_manifests.ref_count`, which every
|
||||
//! whole-Blob reference lands on) — `manifests_consistency`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::application::ports::blob_reference_ports::{BlobReferenceRegistry, RefLevel};
|
||||
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use crate::common::config::NamedStorageEntry;
|
||||
use crate::infrastructure::scheduler::{
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, RecoverableJobHandler, RunOutcome,
|
||||
RunStatus, record_or_log,
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, Mutates, RecoverableJobHandler,
|
||||
RunOutcome, RunStatus, record_or_log,
|
||||
};
|
||||
use crate::infrastructure::services::entry_backend::build_entry_backend;
|
||||
use crate::infrastructure::services::blob_diagnostics::affected_files;
|
||||
|
||||
pub const BLOBS_CONSISTENCY_JOB_NAME: &str = "blobs_consistency";
|
||||
|
||||
/// `params` JSONB key under which the entry name being probed is
|
||||
/// stashed on a Fresh run (matches `TARGET_NAME_PARAM` on
|
||||
/// `backend_migration`). Resumed runs re-read it so a paused audit
|
||||
/// survives restart without the admin re-specifying the target.
|
||||
pub const PROBED_STORAGE_PARAM: &str = "probed_storage";
|
||||
|
||||
/// Rows per batch. Blobs are numerous (millions on a busy install)
|
||||
/// but per-row work is one indexed backend probe + one indexed SQL
|
||||
/// ref-count query. 200 balances cancel-poll cadence against
|
||||
/// round-trip amortisation.
|
||||
/// but per-row work is now a single indexed SQL ref-count comparison,
|
||||
/// with no backend round-trip. 200 balances cancel-poll cadence
|
||||
/// against round-trip amortisation.
|
||||
const BATCH_SIZE: i64 = 200;
|
||||
|
||||
/// Grace window — rows created within this window are skipped by
|
||||
/// the physical-existence probe because the write path is
|
||||
/// durability-before-visibility: `dedup_service` writes bytes, then
|
||||
/// registers the row a few ms later. A scan catching a row
|
||||
/// mid-write would false-positive it as `blob_missing_from_backend`.
|
||||
/// Same shape `dedup_gc` uses (see its `grace_secs`).
|
||||
const CREATE_GRACE: Duration = Duration::hours(1);
|
||||
|
||||
/// Cap on reverse-lookup file names surfaced in a finding's detail.
|
||||
/// Keeps detail JSON size bounded when a broken blob is referenced
|
||||
/// by hundreds of files.
|
||||
const AFFECTED_FILES_SAMPLE: i64 = 5;
|
||||
|
||||
pub struct BlobsConsistencyCheck {
|
||||
pool: Arc<PgPool>,
|
||||
/// The default backend to probe when `args.storage` is `None` —
|
||||
/// the currently-active LIVE backend, injected at DI time. Runs
|
||||
/// with `?storage=<name>` build a fresh backend for the named
|
||||
/// entry instead (via [`build_entry_backend`]).
|
||||
backend: Arc<dyn BlobStorageBackend>,
|
||||
/// Snapshot of `AppConfig.storage_entries` used to resolve
|
||||
/// `args.storage` to a `NamedStorageEntry`. Empty for the
|
||||
/// legacy zero-entries path — `?storage=<name>` runs then
|
||||
/// fail-fast with a clear "no entries declared" message.
|
||||
storage_entries: Vec<NamedStorageEntry>,
|
||||
/// Ambient `AppConfig.storage_path` — used as the `root_dir`
|
||||
/// fallback for a Local target entry with no `_ROOT_DIR`. Same
|
||||
/// fallback rule the boot path uses.
|
||||
storage_path_fallback: PathBuf,
|
||||
/// The chunk-level page query, assembled once from the blob-reference
|
||||
/// registry so this recompute and `dedup_gc` agree on what "referenced"
|
||||
/// means. Built at construction rather than per page so the sweep runs a
|
||||
@@ -166,7 +118,6 @@ fn chunk_page_sql(registry: &BlobReferenceRegistry) -> String {
|
||||
b.hash AS hash,
|
||||
b.size AS size,
|
||||
b.ref_count AS ref_count,
|
||||
b.created_at AS created_at,
|
||||
({expected})::bigint AS actual_ref_count
|
||||
FROM storage.blobs b
|
||||
WHERE ($1::text IS NULL OR b.hash > $1)
|
||||
@@ -176,18 +127,9 @@ fn chunk_page_sql(registry: &BlobReferenceRegistry) -> String {
|
||||
}
|
||||
|
||||
impl BlobsConsistencyCheck {
|
||||
pub fn new(
|
||||
pool: Arc<PgPool>,
|
||||
backend: Arc<dyn BlobStorageBackend>,
|
||||
storage_entries: Vec<NamedStorageEntry>,
|
||||
storage_path_fallback: PathBuf,
|
||||
reference_registry: Arc<BlobReferenceRegistry>,
|
||||
) -> Self {
|
||||
pub fn new(pool: Arc<PgPool>, reference_registry: Arc<BlobReferenceRegistry>) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
backend,
|
||||
storage_entries,
|
||||
storage_path_fallback,
|
||||
chunk_page_sql: chunk_page_sql(&reference_registry),
|
||||
}
|
||||
}
|
||||
@@ -209,7 +151,6 @@ struct BlobRow {
|
||||
hash: String,
|
||||
size: i64,
|
||||
ref_count: i32,
|
||||
created_at: DateTime<Utc>,
|
||||
/// Real reference count derived from the actual references —
|
||||
/// files' whole-file `blob_hash` PLUS every chunk hash across
|
||||
/// `storage.chunk_manifests`. Compared to `ref_count` (the
|
||||
@@ -223,6 +164,28 @@ impl RecoverableJobHandler for BlobsConsistencyCheck {
|
||||
BLOBS_CONSISTENCY_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Walks storage.blobs and reports rows whose ref_count disagrees \
|
||||
with the references that actually exist. An under-count lets \
|
||||
dedup_gc reap a blob that is still in use; an over-count pins \
|
||||
one nothing needs. Database only — it never touches the storage \
|
||||
backend, so it is cheap and safe to run at any time. Missing, \
|
||||
orphaned or corrupted bytes are backend_consistency's job."
|
||||
}
|
||||
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::OnRepairOnly
|
||||
}
|
||||
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
Some(
|
||||
"Rewrites drifted ref_count values to the recomputed truth. \
|
||||
Does not delete blobs or resurrect missing bytes — an \
|
||||
over-counted blob simply becomes eligible for the next \
|
||||
dedup_gc sweep.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Definitive count. `storage.blobs` PK scan is index-only;
|
||||
/// even at millions of rows it's sub-second on modern PG.
|
||||
async fn count_total(&self) -> Option<u64> {
|
||||
@@ -249,78 +212,12 @@ impl RecoverableJobHandler for BlobsConsistencyCheck {
|
||||
args: &JobRunArgs,
|
||||
resume_cursor: Option<Vec<u8>>,
|
||||
) -> RunOutcome {
|
||||
// Resolve the backend to probe. Two paths, mirroring the
|
||||
// Fresh/Resumed split the backend_migration handler uses:
|
||||
// No backend is resolved here, and `?storage=<name>` is inert:
|
||||
// this tenant reads nothing but the database. Everything physical
|
||||
// — existence, orphan bytes, bit-rot — belongs to
|
||||
// `backend_consistency`, which finds it in one enumeration pass
|
||||
// instead of one probe per row.
|
||||
//
|
||||
// * Fresh + args.storage=Some — probe that named entry
|
||||
// instead of the live backend. Stamp probed_storage in
|
||||
// params so a mid-audit restart resumes against the same
|
||||
// entry without re-input.
|
||||
// * Fresh + args.storage=None — probe the live backend
|
||||
// (today's default; audit of what the app is actually
|
||||
// using).
|
||||
// * Resumed — read probed_storage from params; None means
|
||||
// the original run was against the live backend.
|
||||
let is_fresh = resume_cursor.is_none();
|
||||
let probed_storage: Option<String> = if is_fresh {
|
||||
let name = args.storage.clone();
|
||||
if let Some(n) = &name
|
||||
&& let Err(e) = store.set_string_param(PROBED_STORAGE_PARAM, n).await
|
||||
{
|
||||
return RunOutcome::Failed {
|
||||
message: format!("persist {PROBED_STORAGE_PARAM} to params: {e}"),
|
||||
};
|
||||
}
|
||||
name
|
||||
} else {
|
||||
match store.get_string_param(PROBED_STORAGE_PARAM).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("read {PROBED_STORAGE_PARAM} from params: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
let backend: Arc<dyn BlobStorageBackend> = match &probed_storage {
|
||||
None => self.backend.clone(),
|
||||
Some(name) => match self.storage_entries.iter().find(|e| &e.name == name) {
|
||||
Some(entry) => build_entry_backend(entry, &self.storage_path_fallback),
|
||||
None => {
|
||||
let available = if self.storage_entries.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
self.storage_entries
|
||||
.iter()
|
||||
.map(|e| e.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
return RunOutcome::Failed {
|
||||
message: format!(
|
||||
"storage entry `{name}` not found in OXICLOUD_STORAGE_ENTRIES. \
|
||||
Available: [{available}]"
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Err(e) = backend.initialize().await {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("probed backend init: {e}"),
|
||||
};
|
||||
}
|
||||
if let Some(name) = &probed_storage {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "blobs_consistency.probe_scoped",
|
||||
run_id = %store.run_id(),
|
||||
probed_storage = %name,
|
||||
"blobs_consistency probing entry `{name}` (via ?storage=<name>) instead of \
|
||||
live backend"
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot "is this a Fresh run?" BEFORE the resume_cursor
|
||||
// match consumes it — otherwise the `is_none()` check later
|
||||
// borrows a partially-moved value. Fresh = no cursor bytes
|
||||
@@ -352,60 +249,14 @@ impl RecoverableJobHandler for BlobsConsistencyCheck {
|
||||
// `extra_stats` so operators see "found N, fixed M" in one line.
|
||||
let mut repaired_count = 0u64;
|
||||
|
||||
// Deep mode is a per-run flag with two consumers:
|
||||
// 1. This handler — decides whether to re-hash bytes.
|
||||
// 2. The admin panel — needs to display whether the run
|
||||
// was deep so operators know what the scan actually
|
||||
// verified.
|
||||
//
|
||||
// On a Fresh run we take it from `deep` (the trigger
|
||||
// endpoint stamps `?deep=true` onto the args) and stash it
|
||||
// in `params.deep` so:
|
||||
// * Resume picks up the same mode (would previously become
|
||||
// non-deep on Resume — a Paused deep scan silently lost
|
||||
// its `deep` intent).
|
||||
// * The admin panel run-detail view can render
|
||||
// `params.deep = "true"` alongside `target_name`,
|
||||
// `progress_kind`, etc.
|
||||
//
|
||||
// Persist BEFORE the walk so a mid-fresh-batch crash still
|
||||
// leaves a Paused row with the right mode marker.
|
||||
let deep = if is_fresh {
|
||||
let deep = args.deep;
|
||||
let v = if deep { "true" } else { "false" };
|
||||
if let Err(e) = store.set_string_param("deep", v).await {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("failed to persist deep flag to params: {e}"),
|
||||
};
|
||||
}
|
||||
deep
|
||||
} else {
|
||||
// Resumed run — read the persisted flag. Default to
|
||||
// false (fast mode) if the row is a pre-K3.5 Paused
|
||||
// scan without the param stashed.
|
||||
match store.get_string_param("deep").await {
|
||||
Ok(Some(v)) => v == "true",
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("read `deep` from params: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
// `?deep=true` is not handled here. Re-reading and re-hashing
|
||||
// bytes is backend work end to end, so it moved to
|
||||
// `backend_consistency`, where the merge-join already holds the
|
||||
// matched key pairs worth verifying. A deep flag on this tenant
|
||||
// would be a flag with nothing to do.
|
||||
|
||||
if deep {
|
||||
tracing::info!(
|
||||
target: "oxicloud::consistency",
|
||||
event = "blobs_consistency.deep_mode_active",
|
||||
run_id = %store.run_id(),
|
||||
"deep mode: re-reading + re-hashing every blob (bit-rot detection)"
|
||||
);
|
||||
}
|
||||
|
||||
// Repair mode: same shape as `deep` above so the admin run-
|
||||
// detail view can display `params.repair = "true"` alongside
|
||||
// `params.deep`. Fresh persists what the trigger asked for;
|
||||
// Repair mode persisted to `params.repair` so the admin run-detail
|
||||
// view can display it. Fresh persists what the trigger asked for;
|
||||
// Resume reads back so a paused repair scan stays a repair
|
||||
// scan (a mid-scan crash mustn't silently downgrade to
|
||||
// discovery-only for the remaining rows).
|
||||
@@ -489,7 +340,6 @@ impl RecoverableJobHandler for BlobsConsistencyCheck {
|
||||
finding_count = finding_count,
|
||||
repaired_count = repaired_count,
|
||||
repair_requested = repair,
|
||||
deep = deep,
|
||||
"blobs_consistency completed with {} finding(s), {} repaired",
|
||||
finding_count,
|
||||
repaired_count
|
||||
@@ -500,12 +350,12 @@ impl RecoverableJobHandler for BlobsConsistencyCheck {
|
||||
}));
|
||||
}
|
||||
|
||||
let grace_cutoff = Utc::now() - CREATE_GRACE;
|
||||
|
||||
// No grace window here any more. It existed to keep the
|
||||
// physical probe from flagging a blob whose bytes had landed
|
||||
// but whose row hadn't — a write-path race this tenant no
|
||||
// longer looks at. The refcount comparison reads one
|
||||
// consistent DB snapshot, so there is nothing to wait for.
|
||||
for row in &rows {
|
||||
// (1) refcount_mismatch — content-safe check, cheap,
|
||||
// always runs. Emitted BEFORE the physical probe so
|
||||
// a broken-and-miscounted blob shows both findings.
|
||||
if row.ref_count as i64 != row.actual_ref_count {
|
||||
finding_count += 1;
|
||||
let affected = affected_files(self.pool.as_ref(), &row.hash).await;
|
||||
@@ -587,135 +437,6 @@ impl RecoverableJobHandler for BlobsConsistencyCheck {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip physical probes for rows within the write
|
||||
// grace window — writes-in-flight would false-positive.
|
||||
if row.created_at > grace_cutoff {
|
||||
continue;
|
||||
}
|
||||
|
||||
// (2) blob_missing_from_backend — normal mode
|
||||
// physical existence probe. Fails-open on backend
|
||||
// error (log + skip): a transient S3 network blip
|
||||
// shouldn't produce a flood of false data_loss
|
||||
// findings.
|
||||
let exists = match backend.blob_exists(&row.hash).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::consistency",
|
||||
event = "blobs_consistency.blob_exists_error",
|
||||
run_id = %store.run_id(),
|
||||
hash = %row.hash,
|
||||
error = %e,
|
||||
"blob_exists probe failed; skipping this row"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !exists {
|
||||
finding_count += 1;
|
||||
let affected = affected_files(self.pool.as_ref(), &row.hash).await;
|
||||
record_or_log(
|
||||
store,
|
||||
BLOBS_CONSISTENCY_JOB_NAME,
|
||||
"blob_missing_from_backend",
|
||||
"data_loss",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"hash": row.hash,
|
||||
"size": row.size,
|
||||
"ref_count": row.ref_count,
|
||||
"affected_files": affected,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// No point re-hashing bytes that aren't there.
|
||||
continue;
|
||||
}
|
||||
|
||||
// (3) blob_corrupted — DEEP MODE only. Read the
|
||||
// whole blob, recompute BLAKE3, compare to the hash
|
||||
// it's indexed under. Any mismatch = silent bit-rot.
|
||||
//
|
||||
// Finding fields:
|
||||
// * `hash` — expected hash (the key the blob is
|
||||
// indexed under in `storage.blobs`).
|
||||
// * `computed_hash` — what BLAKE3 of the current
|
||||
// bytes actually produces. Diagnostic: a
|
||||
// one-bit flip vs a truncation vs a whole-file
|
||||
// swap all leave distinctive signatures.
|
||||
// `expected_hash` was NOT reused as a name to
|
||||
// avoid mistaking it for "the hash we expect to
|
||||
// see on disk (i.e. what will fix this)".
|
||||
if deep {
|
||||
match recompute_hash(backend.as_ref(), &row.hash).await {
|
||||
Ok(computed_hash) if computed_hash == row.hash => {}
|
||||
Ok(computed_hash) => {
|
||||
finding_count += 1;
|
||||
let affected = affected_files(self.pool.as_ref(), &row.hash).await;
|
||||
record_or_log(
|
||||
store,
|
||||
BLOBS_CONSISTENCY_JOB_NAME,
|
||||
"blob_corrupted",
|
||||
"data_loss",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"hash": row.hash,
|
||||
"computed_hash": computed_hash,
|
||||
"size": row.size,
|
||||
"ref_count": row.ref_count,
|
||||
"affected_files": affected,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
// Blob can't be read at all — record as
|
||||
// `blob_unreadable`. Distinct from
|
||||
// `blob_corrupted` (hash mismatch = we
|
||||
// can read but content differs): here
|
||||
// we can't get bytes out to hash. Common
|
||||
// causes: decrypt failure (missing key),
|
||||
// network glitch on S3/Azure, missing
|
||||
// file on Local, permission error.
|
||||
//
|
||||
// Recorded as `data_loss` because from
|
||||
// the file's perspective the outcome is
|
||||
// the same as corruption: content is
|
||||
// inaccessible. Admins triage the error
|
||||
// string to distinguish transient
|
||||
// (retry-safe) from permanent (needs
|
||||
// key recovery or blob replacement).
|
||||
finding_count += 1;
|
||||
let affected = affected_files(self.pool.as_ref(), &row.hash).await;
|
||||
record_or_log(
|
||||
store,
|
||||
BLOBS_CONSISTENCY_JOB_NAME,
|
||||
"blob_unreadable",
|
||||
"data_loss",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"hash": row.hash,
|
||||
"size": row.size,
|
||||
"ref_count": row.ref_count,
|
||||
"affected_files": affected,
|
||||
"error": e.to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
tracing::warn!(
|
||||
target: "oxicloud::consistency",
|
||||
event = "blobs_consistency.blob_unreadable",
|
||||
run_id = %store.run_id(),
|
||||
hash = %row.hash,
|
||||
error = %e,
|
||||
"🚨 blob unreadable in deep mode — recorded finding, continuing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance cursor + checkpoint.
|
||||
@@ -736,7 +457,6 @@ impl RecoverableJobHandler for BlobsConsistencyCheck {
|
||||
finding_count = finding_count,
|
||||
repaired_count = repaired_count,
|
||||
repair_requested = repair,
|
||||
deep = deep,
|
||||
"blobs_consistency completed with {} finding(s), {} repaired",
|
||||
finding_count,
|
||||
repaired_count
|
||||
@@ -750,75 +470,9 @@ impl RecoverableJobHandler for BlobsConsistencyCheck {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample of file names that reference this blob — either directly
|
||||
/// (`files.blob_hash = $hash`, legacy pre-CDC) or transitively via a
|
||||
/// manifest (`chunk_hashes @> ARRAY[$hash]`, post-CDC dominant path).
|
||||
/// Capped so a chunk shared by 10 000 files doesn't blow up the
|
||||
/// finding detail JSON. Order is arbitrary — sampling for
|
||||
/// diagnosis, not enumeration.
|
||||
async fn affected_files(pool: &PgPool, hash: &str) -> Vec<String> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT DISTINCT f.name
|
||||
FROM storage.files f
|
||||
WHERE f.blob_hash = $1
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM storage.chunk_manifests m
|
||||
WHERE m.file_hash = f.blob_hash
|
||||
AND $1 = ANY(m.chunk_hashes)
|
||||
)
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(AFFECTED_FILES_SAMPLE)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
rows.into_iter().map(|(n,)| n).collect()
|
||||
}
|
||||
|
||||
/// Deep-mode helper — read the blob from the backend and recompute
|
||||
/// its BLAKE3 hash. Returns `Ok(true)` when the recomputed hash
|
||||
/// matches `expected_hash` (byte for byte), `Ok(false)` on mismatch
|
||||
/// (bit-rot), `Err(_)` on any backend-side error (network blip,
|
||||
/// permission issue) — callers log-and-skip errors since a transient
|
||||
/// failure isn't a corruption signal.
|
||||
/// Deep-mode helper — read the blob from the backend and recompute
|
||||
/// its BLAKE3 hash. Returns the recomputed hex string; callers
|
||||
/// compare against the expected hash themselves. Returning the
|
||||
/// actual hash (not just a bool) lets the finding surface WHAT the
|
||||
/// bytes now hash to, which is diagnostic gold: a specific one-bit
|
||||
/// flip has a very different signature from a chunk-boundary
|
||||
/// corruption or a truncated read. `Err(_)` on backend-side error
|
||||
/// (network blip, permission issue) — callers log-and-skip since
|
||||
/// transient failure isn't a corruption signal.
|
||||
async fn recompute_hash(
|
||||
backend: &dyn BlobStorageBackend,
|
||||
expected_hash: &str,
|
||||
) -> Result<String, crate::common::errors::DomainError> {
|
||||
use crate::common::errors::DomainError;
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut stream = backend.get_blob_stream(expected_hash).await?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let bytes = chunk.map_err(|e| {
|
||||
DomainError::internal_error("BlobsConsistency", format!("stream read: {e}"))
|
||||
})?;
|
||||
hasher.update(&bytes);
|
||||
}
|
||||
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::infrastructure::repositories::pg::blob_reference_sources::{
|
||||
ChunksReferenceSource, FilesReferenceSource,
|
||||
};
|
||||
|
||||
fn default_registry() -> BlobReferenceRegistry {
|
||||
let pool = Arc::new(
|
||||
@@ -826,10 +480,7 @@ mod tests {
|
||||
.connect_lazy("postgres://invalid/invalid")
|
||||
.expect("lazy pool never connects"),
|
||||
);
|
||||
let mut registry = BlobReferenceRegistry::new();
|
||||
registry.register(Arc::new(FilesReferenceSource::new(pool.clone())));
|
||||
registry.register(Arc::new(ChunksReferenceSource::new(pool)));
|
||||
registry
|
||||
crate::infrastructure::repositories::pg::blob_reference_sources::built_in_registry(pool)
|
||||
}
|
||||
|
||||
/// Golden test for the chunk-level recompute. Pins the statement
|
||||
@@ -848,7 +499,6 @@ mod tests {
|
||||
b.hash AS hash,
|
||||
b.size AS size,
|
||||
b.ref_count AS ref_count,
|
||||
b.created_at AS created_at,
|
||||
((SELECT COUNT(*) FROM storage.files cnt_f
|
||||
WHERE cnt_f.blob_hash = b.hash
|
||||
AND NOT EXISTS (
|
||||
|
||||
@@ -59,7 +59,7 @@ use std::sync::{Arc, Weak};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs, Mutates};
|
||||
|
||||
pub const CONSISTENCY_BATCH_JOB_NAME: &str = "consistency_batch";
|
||||
|
||||
@@ -90,6 +90,28 @@ impl JobHandler for ConsistencyBatch {
|
||||
CONSISTENCY_BATCH_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Runs every registered consistency check in sequence — one click \
|
||||
for 'check everything'. New tenants are picked up automatically \
|
||||
by name, so nothing needs updating here when one is added. Flags \
|
||||
are forwarded to each sub-job."
|
||||
}
|
||||
|
||||
/// Read-only on a plain run because every tenant it dispatches is, but
|
||||
/// `?repair=true` reaches whichever of them act on it — so the batch
|
||||
/// inherits the strongest mode any sub-job can be put into.
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::OnRepairOnly
|
||||
}
|
||||
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
Some(
|
||||
"Forwards ?repair=true to every sub-check, so the ones that \
|
||||
support it fix what they find (today: refcount drift on blobs \
|
||||
and manifests) instead of only reporting it.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn run(&self, args: &JobRunArgs) -> JobOutcome {
|
||||
// Upgrade the Weak. Only fails if the registry has been
|
||||
// dropped — which can only happen during process shutdown,
|
||||
|
||||
@@ -523,18 +523,17 @@ impl DedupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two sources that were implicit before the registry existed.
|
||||
/// Keeping this as the default means every construction path — including
|
||||
/// tests — has a manifest-level source, so the reap predicate can never
|
||||
/// degenerate to "nothing references anything".
|
||||
/// Every built-in blob-reference source, in one place.
|
||||
///
|
||||
/// This is THE definition of "what references a blob" — DI does not
|
||||
/// assemble its own, it reads this one back via
|
||||
/// [`Self::reference_registry`] and hands it to the consistency jobs, so
|
||||
/// GC and the sweeps cannot disagree. Keeping it as the construction
|
||||
/// default also means every path — including tests — has a
|
||||
/// manifest-level source, so the reap predicate can never degenerate to
|
||||
/// "nothing references anything".
|
||||
fn default_reference_registry(pool: Arc<PgPool>) -> BlobReferenceRegistry {
|
||||
use crate::infrastructure::repositories::pg::blob_reference_sources::{
|
||||
ChunksReferenceSource, FilesReferenceSource,
|
||||
};
|
||||
let mut registry = BlobReferenceRegistry::new();
|
||||
registry.register(Arc::new(FilesReferenceSource::new(pool.clone())));
|
||||
registry.register(Arc::new(ChunksReferenceSource::new(pool)));
|
||||
registry
|
||||
crate::infrastructure::repositories::pg::blob_reference_sources::built_in_registry(pool)
|
||||
}
|
||||
|
||||
/// See the `manifest_cache` field docs. Weight ≈ real heap bytes of one
|
||||
@@ -558,6 +557,246 @@ impl DedupService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Store a server-derived artifact and record the mapping from the
|
||||
/// content it was derived from.
|
||||
///
|
||||
/// One call does the whole contract, so no caller has to remember the
|
||||
/// accounting:
|
||||
///
|
||||
/// 1. writes the bytes through the normal CDC path — derived blobs get
|
||||
/// the same backend, encryption, migration and rotation as any other
|
||||
/// content, and `store_from_stream` takes exactly one reference;
|
||||
/// 2. records `(source_hash, kind, variant) -> blob_hash`;
|
||||
/// 3. **releases that reference if the mapping already existed**, because
|
||||
/// the row that would justify it is not ours — two instances racing
|
||||
/// to render the same thumbnail must leave `ref_count` at 1, not 2.
|
||||
///
|
||||
/// `bytes` is expected to be small (a thumbnail is 3-90 KB, below
|
||||
/// `CDC_MIN_CHUNK`, so this is a single chunk). See
|
||||
/// `docs/plan/derived-blobs.md`.
|
||||
///
|
||||
/// Returns the derived blob hash.
|
||||
/// Attach user-supplied bytes to a FILE — the file-keyed twin of
|
||||
/// [`Self::store_derived_blob`].
|
||||
///
|
||||
/// Same storage path (the bytes are still content-addressed and still
|
||||
/// deduplicated), different mapping: the row is keyed by `file_id`, so
|
||||
/// two files holding identical attached bytes get two rows and two
|
||||
/// references. Sharing the mapping is what must not happen — a
|
||||
/// content-keyed client preview would let one user's upload be served
|
||||
/// for another user's file.
|
||||
///
|
||||
/// `ON CONFLICT … DO UPDATE`, unlike the derived twin: re-uploading a
|
||||
/// preview for the same `(file_id, kind, variant)` is a deliberate
|
||||
/// replacement, whereas a re-derived thumbnail is the same bytes again.
|
||||
/// The reference held by the row being replaced is released.
|
||||
pub async fn store_attached_blob(
|
||||
&self,
|
||||
file_id: &str,
|
||||
kind: &str,
|
||||
variant: &str,
|
||||
content_type: &str,
|
||||
bytes: Bytes,
|
||||
uploaded_by: uuid::Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
let stored = self
|
||||
.store_from_stream(
|
||||
stream::once(async move { Ok::<Bytes, std::io::Error>(bytes) }),
|
||||
Some(content_type.to_string()),
|
||||
)
|
||||
.await?;
|
||||
let attached_hash = stored.hash().to_string();
|
||||
|
||||
// Read the hash being superseded BEFORE upserting.
|
||||
//
|
||||
// It cannot come from `RETURNING`: PostgreSQL only permits `EXCLUDED`
|
||||
// in the `SET` and `WHERE` of `DO UPDATE`, so a RETURNING clause
|
||||
// comparing old against new is a syntax error — and one that surfaces
|
||||
// only at runtime, where this method's best-effort caller swallows it
|
||||
// into a warning while the sidecar keeps the feature looking healthy.
|
||||
//
|
||||
// The gap between this SELECT and the upsert is benign: losing the
|
||||
// race leaves one stale reference, which the manifest recompute
|
||||
// reports rather than anything being lost or served wrongly.
|
||||
let previous: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT blob_hash FROM storage.file_attached_blobs
|
||||
WHERE file_id = $1::uuid AND kind = $2 AND variant = $3",
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(kind)
|
||||
.bind(variant)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Dedup", format!("read attached blob: {e}")))?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.file_attached_blobs
|
||||
(file_id, kind, variant, blob_hash, content_type, uploaded_by)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (file_id, kind, variant) DO UPDATE
|
||||
SET blob_hash = EXCLUDED.blob_hash,
|
||||
content_type = EXCLUDED.content_type,
|
||||
uploaded_by = EXCLUDED.uploaded_by,
|
||||
created_at = now()",
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(kind)
|
||||
.bind(variant)
|
||||
.bind(&attached_hash)
|
||||
.bind(content_type)
|
||||
.bind(uploaded_by)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Dedup", format!("record attached blob: {e}")))?;
|
||||
|
||||
// A replaced row's old blob loses its only reference from here. Not
|
||||
// releasing it would pin those bytes forever — nothing else points at
|
||||
// a superseded preview.
|
||||
if let Some((old_hash,)) = previous
|
||||
&& old_hash != attached_hash
|
||||
&& let Err(e) = self.remove_reference(&old_hash).await
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "oxicloud::dedup",
|
||||
error = %e,
|
||||
"failed to release replaced attached-blob reference for {}",
|
||||
&old_hash[..old_hash.len().min(12)],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(attached_hash)
|
||||
}
|
||||
|
||||
/// Look up bytes attached to a file. File-keyed counterpart of
|
||||
/// [`Self::find_derived_blob`].
|
||||
pub async fn find_attached_blob(
|
||||
&self,
|
||||
file_id: &str,
|
||||
kind: &str,
|
||||
variant: &str,
|
||||
) -> Option<crate::application::ports::dedup_ports::DerivedBlobRef> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT blob_hash, content_type FROM storage.file_attached_blobs
|
||||
WHERE file_id = $1::uuid AND kind = $2 AND variant = $3",
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(kind)
|
||||
.bind(variant)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(blob_hash, content_type)| {
|
||||
crate::application::ports::dedup_ports::DerivedBlobRef {
|
||||
blob_hash,
|
||||
content_type,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn store_derived_blob(
|
||||
&self,
|
||||
source_hash: &str,
|
||||
kind: &str,
|
||||
variant: &str,
|
||||
content_type: &str,
|
||||
bytes: Bytes,
|
||||
) -> Result<String, DomainError> {
|
||||
let stored = self
|
||||
.store_from_stream(
|
||||
stream::once(async move { Ok::<Bytes, std::io::Error>(bytes) }),
|
||||
Some(content_type.to_string()),
|
||||
)
|
||||
.await?;
|
||||
let derived_hash = stored.hash().to_string();
|
||||
|
||||
let inserted = sqlx::query(
|
||||
// The source must still EXIST, or this row can never be cleaned
|
||||
// up. `purge_derived_blobs` runs from the source's reap, so a
|
||||
// mapping written after that reap is unreachable forever: nothing
|
||||
// will reap that hash a second time, and the orphaned row holds
|
||||
// its derived blob's ref_count at 1, which GC is then correct to
|
||||
// refuse. Permanent leak, three rows per image.
|
||||
//
|
||||
// It is not hypothetical. Background thumbnail generation is
|
||||
// spawned and unawaited, so an upload deleted promptly — which a
|
||||
// test suite does constantly, and users do occasionally — has its
|
||||
// render finish AFTER the blob was reaped and then record a
|
||||
// mapping to a corpse.
|
||||
//
|
||||
// Checking both tables because `source_hash` names a Blob:
|
||||
// a manifest for CDC content, a bare blob row for legacy
|
||||
// whole-file content.
|
||||
//
|
||||
// Zero rows here is indistinguishable from the ON CONFLICT case,
|
||||
// and both want the same handling — release the reference the
|
||||
// blob write just took — which the caller already does.
|
||||
"INSERT INTO storage.content_derived_blobs
|
||||
(source_hash, kind, variant, blob_hash, content_type)
|
||||
SELECT $1, $2, $3, $4, $5
|
||||
WHERE EXISTS (SELECT 1 FROM storage.chunk_manifests WHERE file_hash = $1)
|
||||
OR EXISTS (SELECT 1 FROM storage.blobs WHERE hash = $1)
|
||||
ON CONFLICT (source_hash, kind, variant) DO NOTHING",
|
||||
)
|
||||
.bind(source_hash)
|
||||
.bind(kind)
|
||||
.bind(variant)
|
||||
.bind(&derived_hash)
|
||||
.bind(content_type)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Dedup", format!("record derived blob: {e}")))?
|
||||
.rows_affected();
|
||||
|
||||
if inserted == 0 {
|
||||
// Two causes, one correct response.
|
||||
//
|
||||
// Either someone else already mapped this variant (ON CONFLICT),
|
||||
// or the source Blob no longer exists so the WHERE EXISTS above
|
||||
// refused the row. Both leave our blob write with no mapping
|
||||
// behind it, and in both cases keeping the reference would pin
|
||||
// the blob forever — inflating ref_count on every re-render in
|
||||
// the first case, stranding an unreachable blob in the second.
|
||||
if let Err(e) = self.remove_reference(&derived_hash).await {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::dedup",
|
||||
error = %e,
|
||||
"failed to release duplicate derived-blob reference for {}",
|
||||
&derived_hash[..derived_hash.len().min(12)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(derived_hash)
|
||||
}
|
||||
|
||||
/// Look up a derived artifact by its source content. Read counterpart of
|
||||
/// [`Self::store_derived_blob`].
|
||||
pub async fn find_derived_blob(
|
||||
&self,
|
||||
source_hash: &str,
|
||||
kind: &str,
|
||||
variant: &str,
|
||||
) -> Option<crate::application::ports::dedup_ports::DerivedBlobRef> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT blob_hash, content_type FROM storage.content_derived_blobs
|
||||
WHERE source_hash = $1 AND kind = $2 AND variant = $3",
|
||||
)
|
||||
.bind(source_hash)
|
||||
.bind(kind)
|
||||
.bind(variant)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(blob_hash, content_type)| {
|
||||
crate::application::ports::dedup_ports::DerivedBlobRef {
|
||||
blob_hash,
|
||||
content_type,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The registry backing the reap predicate.
|
||||
///
|
||||
/// Exposed so `blobs_consistency` recomputes refcounts from the *same*
|
||||
@@ -580,6 +819,89 @@ impl DedupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that must happen when a blob is permanently reaped:
|
||||
/// drop the artifacts derived FROM it, then notify the lifecycle hooks.
|
||||
///
|
||||
/// Boxed because it is mutually recursive with `remove_reference`:
|
||||
/// releasing a thumbnail's reference can reap the thumbnail's own blob,
|
||||
/// which comes back through here. It terminates after one level —
|
||||
/// nothing is derived from a thumbnail, so the inner purge finds no rows.
|
||||
fn reap_blob<'a>(
|
||||
&'a self,
|
||||
hash: &'a str,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
self.purge_derived_blobs(hash).await;
|
||||
self.fire_blob_hooks(hash);
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete every artifact derived from `source_hash` and release the
|
||||
/// manifest references those rows held.
|
||||
///
|
||||
/// The delete counterpart of [`Self::store_derived_blob`]. Without it a
|
||||
/// thumbnail pins its own blob forever: the mapping row keeps
|
||||
/// `chunk_manifests.ref_count` at 1 with no file behind it, so GC never
|
||||
/// reclaims the bytes and a full delete leaves orphans on disk.
|
||||
async fn purge_derived_blobs(&self, source_hash: &str) {
|
||||
let derived: Vec<(String,)> = match sqlx::query_as(
|
||||
"DELETE FROM storage.content_derived_blobs
|
||||
WHERE source_hash = $1
|
||||
RETURNING blob_hash",
|
||||
)
|
||||
.bind(source_hash)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::dedup",
|
||||
error = %e,
|
||||
"failed to purge derived blobs for {}",
|
||||
&source_hash[..source_hash.len().min(12)],
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Silent on success until now, which made three distinct outcomes
|
||||
// indistinguishable from the outside: never called, called and found
|
||||
// nothing, or found rows whose release then failed. Chasing an
|
||||
// orphaned-derived-row leak cost several full suite runs for exactly
|
||||
// that reason, so the call announces itself.
|
||||
//
|
||||
// `info` when it actually deleted something — that is rare (only when
|
||||
// a source Blob dies) and it is the line that proves the reap path
|
||||
// reached here. `debug` for the common no-op.
|
||||
if derived.is_empty() {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::dedup",
|
||||
"purge_derived_blobs: no rows for {}",
|
||||
&source_hash[..source_hash.len().min(12)],
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target: "oxicloud::dedup",
|
||||
rows = derived.len(),
|
||||
"purge_derived_blobs: releasing {} derived row(s) for {}",
|
||||
derived.len(),
|
||||
&source_hash[..source_hash.len().min(12)],
|
||||
);
|
||||
}
|
||||
|
||||
for (blob_hash,) in derived {
|
||||
if let Err(e) = self.remove_reference(&blob_hash).await {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::dedup",
|
||||
error = %e,
|
||||
"failed to release derived blob {}",
|
||||
&blob_hash[..blob_hash.len().min(12)],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fire_blob_hooks(&self, hash: &str) {
|
||||
if let Some(lc) = &self.blob_lifecycle {
|
||||
lc.on_blob_deleted(hash);
|
||||
@@ -1843,7 +2165,7 @@ impl DedupService {
|
||||
self.manifest_cache.invalidate(file_hash).await;
|
||||
|
||||
// File content is gone — drop its blob-keyed thumbnails now.
|
||||
self.fire_blob_hooks(file_hash);
|
||||
self.reap_blob(file_hash).await;
|
||||
|
||||
tracing::info!(
|
||||
"MANIFEST DELETED: {} ({} chunks dereferenced; orphans reclaimed by GC)",
|
||||
@@ -1922,7 +2244,7 @@ impl DedupService {
|
||||
}
|
||||
|
||||
// Bug 3 fix: notify hooks — e.g. thumbnail cleanup keyed by hash
|
||||
self.fire_blob_hooks(hash);
|
||||
self.reap_blob(hash).await;
|
||||
|
||||
tracing::info!("BLOB DELETED: {} (no more references)", &hash[..12]);
|
||||
Ok(true)
|
||||
@@ -2011,7 +2333,7 @@ impl DedupService {
|
||||
if let Err(e) = self.backend.delete_blob(hash).await {
|
||||
tracing::warn!("cleanup_if_orphaned: disk delete failed for {short}: {e}");
|
||||
}
|
||||
self.fire_blob_hooks(hash);
|
||||
self.reap_blob(hash).await;
|
||||
tracing::info!("cleanup_if_orphaned: removed orphaned legacy blob {short}");
|
||||
}
|
||||
}
|
||||
@@ -2701,6 +3023,28 @@ impl DedupService {
|
||||
// and accounting remain below and run only after refcounts succeed.
|
||||
for (file_hash, _, _) in &batch {
|
||||
self.manifest_cache.invalidate(file_hash).await;
|
||||
|
||||
// Drop everything derived FROM this Blob, exactly as
|
||||
// `reap_blob` does for the single-blob path.
|
||||
//
|
||||
// Without this, bulk manifest reaping orphans the rows: the
|
||||
// reap predicate protects a manifest that IS a derived
|
||||
// artifact (`content_derived_blobs.blob_hash`), but
|
||||
// deliberately not one that is the SOURCE of them — counting
|
||||
// `source_hash` as a reference would pin every original for
|
||||
// as long as a thumbnail existed. So the source is reaped
|
||||
// correctly, and the purge has to follow it.
|
||||
//
|
||||
// It did not, and the leak is permanent rather than cosmetic:
|
||||
// the orphaned row holds `chunk_manifests.ref_count` at 1 on
|
||||
// the thumbnail's own blob, so GC is thereafter *correct* to
|
||||
// refuse it and those bytes are never reclaimed. Every
|
||||
// deleted image left three of them behind — one per size.
|
||||
//
|
||||
// Found by storage_cleanup_check.sh: three leftover blobs,
|
||||
// all `derived=1`, all naming one `src` whose manifest, blob
|
||||
// row and files were already gone.
|
||||
self.purge_derived_blobs(file_hash).await;
|
||||
}
|
||||
|
||||
if batch.len() == 1 {
|
||||
@@ -2760,7 +3104,7 @@ impl DedupService {
|
||||
// chunk-keyed hook never finds them. Symptom: orphan webp
|
||||
// under `.thumbnails/{icon,preview,large}/<file_hash>.webp`
|
||||
// after a user-cascade-delete of a video upload.
|
||||
self.fire_blob_hooks(file_hash);
|
||||
self.reap_blob(file_hash).await;
|
||||
|
||||
total_bytes += *size as u64;
|
||||
tracing::debug!(
|
||||
@@ -2843,7 +3187,7 @@ impl DedupService {
|
||||
.await;
|
||||
|
||||
for (hash, size) in &deleted {
|
||||
self.fire_blob_hooks(hash);
|
||||
self.reap_blob(hash).await;
|
||||
total_bytes += *size as u64;
|
||||
}
|
||||
total_deleted += n as u64;
|
||||
@@ -3228,6 +3572,15 @@ impl DedupPort for DedupService {
|
||||
self.blob_exists(hash).await
|
||||
}
|
||||
|
||||
async fn find_derived_blob(
|
||||
&self,
|
||||
source_hash: &str,
|
||||
kind: &str,
|
||||
variant: &str,
|
||||
) -> Option<crate::application::ports::dedup_ports::DerivedBlobRef> {
|
||||
self.find_derived_blob(source_hash, kind, variant).await
|
||||
}
|
||||
|
||||
async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadataDto> {
|
||||
self.get_blob_metadata(hash).await
|
||||
}
|
||||
@@ -3322,6 +3675,20 @@ impl crate::infrastructure::scheduler::JobHandler for DedupService {
|
||||
DEDUP_GC_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Reclaims blobs and chunk manifests that no file, thumbnail or \
|
||||
preview references any more, once they are past the orphan grace \
|
||||
window. Trash cleanup already runs this as its tail step; \
|
||||
triggering it here is for reclaiming immediately rather than at \
|
||||
the next tick. Add ?force=true to skip the grace window."
|
||||
}
|
||||
|
||||
/// Deletes bytes. `force` is its accelerator, not a repair flag —
|
||||
/// there is nothing this job reports without also acting on it.
|
||||
fn mutates(&self) -> crate::infrastructure::scheduler::Mutates {
|
||||
crate::infrastructure::scheduler::Mutates::Always
|
||||
}
|
||||
|
||||
/// Runs one `garbage_collect` sweep — the same reclamation that
|
||||
/// `TrashCleanupService` invokes inline as its tail step, exposed
|
||||
/// through the scheduler so operators can trigger it uniformly via
|
||||
@@ -3388,7 +3755,9 @@ mod tests {
|
||||
SELECT ctid
|
||||
FROM storage.chunk_manifests m
|
||||
WHERE m.ref_count <= 0
|
||||
OR NOT (EXISTS (SELECT 1 FROM storage.files cnt_f WHERE cnt_f.blob_hash = m.file_hash))
|
||||
OR NOT (EXISTS (SELECT 1 FROM storage.files cnt_f WHERE cnt_f.blob_hash = m.file_hash)
|
||||
OR EXISTS (SELECT 1 FROM storage.content_derived_blobs cnt_d WHERE cnt_d.blob_hash = m.file_hash)
|
||||
OR EXISTS (SELECT 1 FROM storage.file_attached_blobs cnt_a WHERE cnt_a.blob_hash = m.file_hash))
|
||||
LIMIT $1
|
||||
)
|
||||
RETURNING file_hash, chunk_hashes, total_size"#;
|
||||
|
||||
@@ -72,6 +72,13 @@ impl RecoverableJobHandler for DrivesConsistencyCheck {
|
||||
DRIVES_CONSISTENCY_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Compares each drive's cached used_bytes against the actual sum of \
|
||||
its file sizes and reports the drift. Read-only — usage_reconcile \
|
||||
is what corrects the counter; this surfaces WHEN it drifts so the \
|
||||
cause can be traced (missed delta, silent failure, race)."
|
||||
}
|
||||
|
||||
/// Definitive count — one row per drive, table is tiny (dozens per
|
||||
/// install), COUNT(*) is trivially fast. Enables progress bar on
|
||||
/// the admin UI.
|
||||
|
||||
@@ -148,6 +148,13 @@ impl RecoverableJobHandler for FilesConsistencyCheck {
|
||||
FILES_CONSISTENCY_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Walks storage.files and reports rows whose parent-folder state, \
|
||||
blob reference or denormalised size has drifted from what the \
|
||||
join with folders and blobs says is true. Read-only — the fixes \
|
||||
live in other jobs (trash cascade, dedup_gc, blob resurrection)."
|
||||
}
|
||||
|
||||
/// Definitive count — one row per file. This is the largest table
|
||||
/// of the trio (millions on big installs); COUNT(*) is still an
|
||||
/// index-only scan but can take ~seconds. The tradeoff is worth
|
||||
|
||||
@@ -120,6 +120,14 @@ impl RecoverableJobHandler for FoldersConsistencyCheck {
|
||||
FOLDERS_CONSISTENCY_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Walks storage.folders and reports rows whose materialised path \
|
||||
and lpath have drifted from what walking the parent_id chain \
|
||||
produces. Any write path that bypasses the ltree cascade trigger \
|
||||
can leave these wrong, which silently breaks subtree queries. \
|
||||
Read-only."
|
||||
}
|
||||
|
||||
/// Definitive count — one row per folder. Larger table than drives
|
||||
/// but the COUNT(*) is still index-only on PG. On multi-million-row
|
||||
/// deployments this is ~100ms at run start; acceptable given the
|
||||
|
||||
@@ -23,7 +23,7 @@ use tracing::{error, info};
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs, Mutates};
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -122,6 +122,18 @@ impl JobHandler for GrantCleanupService {
|
||||
GRANT_CLEANUP_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Deletes expired role grants once they are past the retention \
|
||||
window. Expired grants never leak permission — every AuthZ check \
|
||||
filters on expires_at — they just accumulate. The window keeps \
|
||||
'what happened to my access?' answerable for a few weeks after \
|
||||
expiry."
|
||||
}
|
||||
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
/// Runs one purge. `count` on the returned `JobOutcome::Ok` is
|
||||
/// the number of `role_grants` rows physically deleted;
|
||||
/// `extra.grace_days` records which grace was applied so admin
|
||||
|
||||
@@ -778,12 +778,30 @@ impl BlobStorageBackend for LocalBlobBackend {
|
||||
|
||||
let blob_root = self.blob_root.clone();
|
||||
Box::pin(async move {
|
||||
// Cursor is the last hash returned (see the port contract). The
|
||||
// shard is derivable from it — the shard name IS the hash's first
|
||||
// two chars — so no composite is needed.
|
||||
//
|
||||
// Both legacy forms still resume correctly, so a consistency run
|
||||
// paused across this deploy is not stranded:
|
||||
// * "<shard>/<hash>" — what this backend used to emit; the
|
||||
// hash half is taken and the shard re-derived from it.
|
||||
// * "<shard>" — a bare 2-char shard. It flows through the same
|
||||
// path: "3f" sorts BEFORE every 64-char hash beginning "3f",
|
||||
// so using it as start_after skips nothing.
|
||||
let (start_shard, start_after_hash): (String, Option<String>) = match cursor {
|
||||
None => (String::from("00"), None),
|
||||
Some(c) => match c.split_once('/') {
|
||||
Some((sh, h)) => (sh.to_string(), Some(h.to_string())),
|
||||
None => (c, None),
|
||||
},
|
||||
Some(c) => {
|
||||
let hash = c.split_once('/').map(|(_, h)| h).unwrap_or(c.as_str());
|
||||
if hash.len() >= 2 {
|
||||
(hash[..2].to_string(), Some(hash.to_string()))
|
||||
} else {
|
||||
// Under 2 chars — not a hash and not a shard. Should
|
||||
// be unreachable; start from the beginning rather
|
||||
// than index out of bounds.
|
||||
(String::from("00"), None)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut blobs: Vec<BackendBlobEntry> = Vec::with_capacity(limit);
|
||||
@@ -879,11 +897,8 @@ impl BlobStorageBackend for LocalBlobBackend {
|
||||
continue;
|
||||
}
|
||||
if blobs.len() >= limit {
|
||||
next_cursor = Some(format!(
|
||||
"{}/{}",
|
||||
prefix,
|
||||
blobs.last().map(|e| e.hash.as_str()).unwrap_or("")
|
||||
));
|
||||
// Just the hash — the shard is recoverable from it.
|
||||
next_cursor = blobs.last().map(|e| e.hash.clone());
|
||||
return Ok(BlobListPage {
|
||||
blobs,
|
||||
unknowns,
|
||||
@@ -1008,4 +1023,66 @@ mod tests {
|
||||
);
|
||||
assert_eq!(hash_prefix_slot("gg"), None);
|
||||
}
|
||||
|
||||
/// The port contract now REQUIRES ascending hash order and a cursor that
|
||||
/// is the last hash returned. `backend_consistency`'s merge-join depends
|
||||
/// on both: an out-of-order page would make it emit bogus
|
||||
/// `blob_missing_from_backend` findings at `data_loss` severity, and a
|
||||
/// non-hash cursor would stop a caller resuming from its own checkpoint.
|
||||
///
|
||||
/// Nothing covered enumeration before this, so both properties were
|
||||
/// accidental.
|
||||
#[tokio::test]
|
||||
async fn list_blob_hashes_is_ordered_and_hash_cursor_resumes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let backend = LocalBlobBackend::new(dir.path());
|
||||
backend.initialize().await.unwrap();
|
||||
|
||||
// Deliberately inserted out of order and across several shards, so a
|
||||
// passing result cannot come from insertion order.
|
||||
let mut written: Vec<String> = ["f0", "0a", "9c", "0b", "ff", "12"]
|
||||
.iter()
|
||||
.map(|p| fake_hash(p))
|
||||
.collect();
|
||||
for h in &written {
|
||||
backend
|
||||
.put_blob_from_bytes(h, Bytes::from_static(b"x"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
written.sort();
|
||||
|
||||
// Page with limit 2 so the cursor is exercised repeatedly.
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
let mut cursor: Option<String> = None;
|
||||
for _ in 0..20 {
|
||||
let page = backend.list_blob_hashes(cursor.clone(), 2).await.unwrap();
|
||||
seen.extend(page.blobs.iter().map(|e| e.hash.clone()));
|
||||
match page.next_cursor {
|
||||
Some(c) => cursor = Some(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(seen, written, "enumeration must be complete and ascending");
|
||||
|
||||
// A cursor the CALLER synthesises from a hash it already holds must
|
||||
// work — that is the property the merge-join resume relies on, and
|
||||
// what an opaque backend token could not provide.
|
||||
let midpoint = &written[2];
|
||||
let resumed = backend
|
||||
.list_blob_hashes(Some(midpoint.clone()), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected: Vec<String> = written[3..].to_vec();
|
||||
assert_eq!(
|
||||
resumed
|
||||
.blobs
|
||||
.iter()
|
||||
.map(|e| e.hash.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
expected,
|
||||
"resume must start STRICTLY after the given hash"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ use sqlx::PgPool;
|
||||
|
||||
use crate::application::ports::blob_reference_ports::{BlobReferenceRegistry, RefLevel};
|
||||
use crate::infrastructure::scheduler::{
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, RecoverableJobHandler, RunOutcome,
|
||||
RunStatus, record_or_log,
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, Mutates, RecoverableJobHandler,
|
||||
RunOutcome, RunStatus, record_or_log,
|
||||
};
|
||||
|
||||
pub const MANIFESTS_CONSISTENCY_JOB_NAME: &str = "manifests_consistency";
|
||||
@@ -139,6 +139,26 @@ impl RecoverableJobHandler for ManifestsConsistencyCheck {
|
||||
MANIFESTS_CONSISTENCY_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Reconciles storage.chunk_manifests.ref_count against its actual \
|
||||
referrers. There are two reference counters — a chunk reference \
|
||||
lands on storage.blobs.ref_count, a whole-Blob reference on the \
|
||||
manifest — and only the first was ever verified; this covers the \
|
||||
other half."
|
||||
}
|
||||
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::OnRepairOnly
|
||||
}
|
||||
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
Some(
|
||||
"Rewrites drifted manifest ref_count values to the recomputed \
|
||||
truth. Nothing is deleted here — a corrected count only makes \
|
||||
the manifest eligible for a later dedup_gc sweep.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn count_total(&self) -> Option<u64> {
|
||||
let row: Result<(i64,), sqlx::Error> =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM storage.chunk_manifests")
|
||||
@@ -404,9 +424,6 @@ impl RecoverableJobHandler for ManifestsConsistencyCheck {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::infrastructure::repositories::pg::blob_reference_sources::{
|
||||
ChunksReferenceSource, FilesReferenceSource,
|
||||
};
|
||||
|
||||
fn default_registry() -> BlobReferenceRegistry {
|
||||
let pool = Arc::new(
|
||||
@@ -414,10 +431,7 @@ mod tests {
|
||||
.connect_lazy("postgres://invalid/invalid")
|
||||
.expect("lazy pool never connects"),
|
||||
);
|
||||
let mut registry = BlobReferenceRegistry::new();
|
||||
registry.register(Arc::new(FilesReferenceSource::new(pool.clone())));
|
||||
registry.register(Arc::new(ChunksReferenceSource::new(pool)));
|
||||
registry
|
||||
crate::infrastructure::repositories::pg::blob_reference_sources::built_in_registry(pool)
|
||||
}
|
||||
|
||||
/// Golden test — the statement is assembled from the registry, so pin it
|
||||
@@ -437,7 +451,9 @@ mod tests {
|
||||
m.total_size AS total_size,
|
||||
m.chunk_count AS chunk_count,
|
||||
((SELECT COUNT(*) FROM storage.files cnt_f
|
||||
WHERE cnt_f.blob_hash = m.file_hash))::bigint AS actual_ref_count
|
||||
WHERE cnt_f.blob_hash = m.file_hash)
|
||||
+ (SELECT COUNT(*) FROM storage.content_derived_blobs cnt_d WHERE cnt_d.blob_hash = m.file_hash)
|
||||
+ (SELECT COUNT(*) FROM storage.file_attached_blobs cnt_a WHERE cnt_a.blob_hash = m.file_hash))::bigint AS actual_ref_count
|
||||
FROM storage.chunk_manifests m
|
||||
WHERE ($1::text IS NULL OR m.file_hash > $1)
|
||||
ORDER BY m.file_hash
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod azure_blob_backend;
|
||||
pub mod backend_consistency_service;
|
||||
pub mod backend_migration_service;
|
||||
pub mod backend_rotate_service;
|
||||
pub mod blob_diagnostics;
|
||||
pub mod blobs_consistency_service;
|
||||
pub mod cached_blob_backend;
|
||||
pub mod chunked_upload_service;
|
||||
@@ -51,12 +52,15 @@ pub mod plugins;
|
||||
pub mod recent_recording_hook;
|
||||
pub mod retry_blob_backend;
|
||||
pub mod s3_blob_backend;
|
||||
pub mod satellites_consistency_service;
|
||||
pub mod search_index;
|
||||
pub mod session_cleanup_service;
|
||||
pub mod session_liveness_gauges;
|
||||
pub mod share_unlock_cookie;
|
||||
pub mod smtp_email_sender;
|
||||
pub mod swappable_blob_backend;
|
||||
pub mod thumb_attached_import_service;
|
||||
pub mod thumb_derived_import_service;
|
||||
pub mod thumbnail_service;
|
||||
#[cfg(test)]
|
||||
mod thumbnail_service_test;
|
||||
|
||||
@@ -65,6 +65,32 @@ impl S3BlobBackend {
|
||||
let prefix = &hash[0..2];
|
||||
format!("{}/{}.blob", prefix, hash)
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::object_key`] — the hash a key names, or `None`
|
||||
/// when the key is not one we wrote.
|
||||
///
|
||||
/// Deliberately strict, and paired with `object_key` so the round-trip
|
||||
/// stays honest. Enumeration passes no prefix to S3, so this filter is
|
||||
/// the *only* thing separating our namespace from everything else in
|
||||
/// the bucket; a lenient match would feed a non-hash into
|
||||
/// `object_key`, which slices `[0..2]` and would produce a nonsense
|
||||
/// resume position.
|
||||
fn hash_from_object_key(key: &str) -> Option<String> {
|
||||
let (prefix, rest) = key.split_once('/')?;
|
||||
if prefix.len() != 2 || !prefix.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let stem = rest.strip_suffix(".blob")?;
|
||||
if stem.len() != 64 || !stem.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
// The shard must be the hash's own first two characters, or
|
||||
// `object_key(hash)` would not reproduce this key.
|
||||
if !stem.starts_with(prefix) {
|
||||
return None;
|
||||
}
|
||||
Some(stem.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl BlobStorageBackend for S3BlobBackend {
|
||||
@@ -464,14 +490,21 @@ impl BlobStorageBackend for S3BlobBackend {
|
||||
None // Remote backend — no local path
|
||||
}
|
||||
|
||||
/// Enumerate blobs via S3 `ListObjectsV2`. Cursor is the S3
|
||||
/// continuation token verbatim (opaque). Filter: keys must
|
||||
/// match `<xx>/<64-hex>.blob` — matches how `blob_key` writes
|
||||
/// them — so any future non-blob namespace living in the same
|
||||
/// bucket (e.g. `thumbnails/<hash>.jpg`) is skipped
|
||||
/// automatically. No prefix passed to S3 so we get everything
|
||||
/// in one paginated scan; the client-side filter enforces
|
||||
/// correctness.
|
||||
/// Enumerate blobs via S3 `ListObjectsV2`, in ascending hash order.
|
||||
///
|
||||
/// The cursor is a **hash**, per the port contract — resumed via
|
||||
/// `StartAfter`, not a continuation token. That is what lets a caller
|
||||
/// resume the backend side of a merge-join from a checkpoint it
|
||||
/// already holds; a continuation token would force re-enumeration
|
||||
/// from the start on every resume.
|
||||
///
|
||||
/// No prefix is passed to S3, so the scan covers the whole bucket and
|
||||
/// [`Self::hash_from_object_key`] does the filtering. Keys that are
|
||||
/// not ours come back as `unknowns` rather than being dropped, so an
|
||||
/// operator can see what is sharing the bucket. **On a bucket shared
|
||||
/// with other workloads that means every foreign object is reported
|
||||
/// as an unknown on every sweep** — give OxiCloud its own bucket, or
|
||||
/// expect the noise.
|
||||
fn list_blob_hashes(
|
||||
&self,
|
||||
cursor: Option<String>,
|
||||
@@ -492,63 +525,110 @@ impl BlobStorageBackend for S3BlobBackend {
|
||||
};
|
||||
|
||||
Box::pin(async move {
|
||||
let mut req = self
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(&self.bucket)
|
||||
.max_keys(limit.min(1000) as i32);
|
||||
if let Some(c) = cursor {
|
||||
req = req.continuation_token(c);
|
||||
}
|
||||
// A page's cursor can only be the last blob hash on it, because
|
||||
// the contract says the cursor IS a hash and `StartAfter` needs
|
||||
// `object_key()` applied to it. A page holding only foreign keys
|
||||
// therefore yields no cursor — and returning `None` there would
|
||||
// end enumeration while the bucket still has objects, making an
|
||||
// audit job under-report. That is the worst failure shape for a
|
||||
// check whose entire purpose is finding missing data.
|
||||
//
|
||||
// So keep listing until the accumulated page holds at least one
|
||||
// blob, or the bucket is exhausted. The continuation token is
|
||||
// used only INSIDE this call and never escapes as a cursor.
|
||||
// Bounded on foreign keys accumulated rather than on requests
|
||||
// made: the request count scales with the caller's `limit`, so a
|
||||
// request cap would fire on a healthy bucket merely because the
|
||||
// caller paged finely.
|
||||
const MAX_UNKNOWNS: usize = 10_000;
|
||||
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Blob",
|
||||
format!("S3 ListObjectsV2 failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let objects = resp.contents.unwrap_or_default();
|
||||
let mut blobs: Vec<BackendBlobEntry> = Vec::with_capacity(objects.len());
|
||||
let mut blobs: Vec<BackendBlobEntry> = Vec::new();
|
||||
let mut unknowns: Vec<BackendUnknownEntry> = Vec::new();
|
||||
let mut continuation: Option<String> = None;
|
||||
let mut requests = 0usize;
|
||||
// Assigned on every path through the loop body before any exit.
|
||||
let mut truncated;
|
||||
|
||||
for obj in objects {
|
||||
let Some(key) = obj.key else { continue };
|
||||
let mtime = obj.last_modified.and_then(|ts| {
|
||||
let secs = ts.secs();
|
||||
let nsecs = ts.subsec_nanos();
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nsecs)
|
||||
});
|
||||
loop {
|
||||
let mut req = self
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(&self.bucket)
|
||||
.max_keys(limit.min(1000) as i32);
|
||||
match (&continuation, &cursor) {
|
||||
// Mid-loop: continue exactly where the last inner
|
||||
// request stopped.
|
||||
(Some(token), _) => req = req.continuation_token(token),
|
||||
// First request: resume after the caller's hash.
|
||||
(None, Some(c)) => req = req.start_after(Self::object_key(c)),
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
// Canonical S3 key shape: `<xx>/<64-hex>.blob`.
|
||||
// Anything else is a sidecar or foreign namespace
|
||||
// (e.g. future `thumbnails/<hash>.jpg` if Ed adds
|
||||
// that) — surface as an unknown so operators know
|
||||
// it's there. Recovery framework can decide per-
|
||||
// pattern how to act.
|
||||
let is_canonical = key.split_once('/').and_then(|(prefix, rest)| {
|
||||
if prefix.len() != 2 || !prefix.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Blob",
|
||||
format!("S3 ListObjectsV2 failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
requests += 1;
|
||||
truncated = resp.is_truncated.unwrap_or(false);
|
||||
continuation = resp.next_continuation_token;
|
||||
|
||||
for obj in resp.contents.unwrap_or_default() {
|
||||
let Some(key) = obj.key else { continue };
|
||||
let mtime = obj.last_modified.and_then(|ts| {
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(
|
||||
ts.secs(),
|
||||
ts.subsec_nanos(),
|
||||
)
|
||||
});
|
||||
|
||||
match Self::hash_from_object_key(&key) {
|
||||
Some(hash) => blobs.push(BackendBlobEntry { hash, mtime }),
|
||||
// Not ours: a spool file, a sidecar, or another
|
||||
// workload sharing the bucket. Surfaced rather than
|
||||
// dropped so operators can see it; the recovery
|
||||
// framework decides per pattern how to act.
|
||||
None => unknowns.push(BackendUnknownEntry { path: key, mtime }),
|
||||
}
|
||||
rest.strip_suffix(".blob")
|
||||
.filter(|stem| {
|
||||
stem.len() == 64 && stem.chars().all(|c| c.is_ascii_hexdigit())
|
||||
})
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
}
|
||||
|
||||
match is_canonical {
|
||||
Some(hash) => blobs.push(BackendBlobEntry { hash, mtime }),
|
||||
None => unknowns.push(BackendUnknownEntry { path: key, mtime }),
|
||||
if !blobs.is_empty() || !truncated {
|
||||
break;
|
||||
}
|
||||
|
||||
// `is_truncated` with no token is a protocol violation, and a
|
||||
// huge run of foreign keys means we would buffer the bucket to
|
||||
// find one blob. Neither can produce a valid cursor, so fail
|
||||
// loudly: a visible job failure beats a sweep that silently
|
||||
// reports "no missing blobs" having read a fraction of them.
|
||||
if continuation.is_none() || unknowns.len() >= MAX_UNKNOWNS {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Blob",
|
||||
format!(
|
||||
"S3 enumeration stalled after {requests} request(s) and {} \
|
||||
non-blob key(s) without reaching a blob, so no resume cursor \
|
||||
can be produced. Bucket '{}' likely holds a large foreign \
|
||||
namespace — give OxiCloud a dedicated bucket.",
|
||||
unknowns.len(),
|
||||
self.bucket,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let next_cursor = if resp.is_truncated.unwrap_or(false) {
|
||||
resp.next_continuation_token
|
||||
// Always a real hash: the loop above only exits with an empty
|
||||
// `blobs` when the listing is exhausted, and then there is
|
||||
// nothing to resume from.
|
||||
let next_cursor = if truncated {
|
||||
blobs.last().map(|entry| entry.hash.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(BlobListPage {
|
||||
blobs,
|
||||
unknowns,
|
||||
@@ -623,3 +703,46 @@ where
|
||||
_ => format!("unknown SDK error: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const H: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9";
|
||||
|
||||
/// The enumeration cursor is fed straight back into `object_key`, so a
|
||||
/// key that does not round-trip would resume at the wrong position.
|
||||
#[test]
|
||||
fn object_key_round_trips_through_hash_from_object_key() {
|
||||
let key = S3BlobBackend::object_key(H);
|
||||
assert_eq!(key, format!("0a/{H}.blob"));
|
||||
assert_eq!(
|
||||
S3BlobBackend::hash_from_object_key(&key).as_deref(),
|
||||
Some(H)
|
||||
);
|
||||
}
|
||||
|
||||
/// Each of these previously risked being treated as a hash and sliced
|
||||
/// `[0..2]` to build a resume position.
|
||||
#[test]
|
||||
fn non_canonical_keys_are_rejected() {
|
||||
let cases = [
|
||||
"0a/junk.tmp".to_string(), // spool file
|
||||
"junk.tmp".to_string(), // no shard
|
||||
"0a/junk".to_string(), // no suffix
|
||||
"thumbnails/abc.jpg".to_string(), // foreign namespace
|
||||
format!("0a/{H}.blob.corrupt"), // sidecar
|
||||
format!("0a/{H}"), // suffix missing
|
||||
format!("zz/{H}.blob"), // non-hex shard
|
||||
format!("ff/{H}.blob"), // shard != hash prefix
|
||||
format!("0a/{}.blob", &H[..63]), // wrong length
|
||||
];
|
||||
for key in &cases {
|
||||
assert_eq!(
|
||||
S3BlobBackend::hash_from_object_key(key),
|
||||
None,
|
||||
"must not be read as a blob: {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
//! `satellites_consistency` — the last unbuilt row of the coverage matrix.
|
||||
//!
|
||||
//! Walks both satellite tables and reports mappings pointing at Blobs that no
|
||||
//! longer exist. One job rather than two, because the tables are one concept
|
||||
//! — the content-keyed and file-keyed halves of "things attached to a Blob" —
|
||||
//! and the vocabulary already exists in `storage.copy_file_satellites`.
|
||||
//!
|
||||
//! ### Why nothing else finds these
|
||||
//!
|
||||
//! Every other job reasons from a Blob outwards: `blobs_consistency` and
|
||||
//! `manifests_consistency` recompute refcounts for rows that exist,
|
||||
//! `backend_consistency` merge-joins the registry against the backend. A
|
||||
//! satellite row whose SOURCE is gone breaks none of those invariants — the
|
||||
//! row holds a valid reference to a real artifact, the refcount is exactly
|
||||
//! right, and the bytes are present on the backend. Every check agrees the
|
||||
//! system is healthy.
|
||||
//!
|
||||
//! It is only wrong one level up: nothing will ever reap that source again,
|
||||
//! so `purge_derived_blobs` can never fire, so the mapping is unreachable and
|
||||
//! its artifact is pinned forever. A leak that looks like correctness, which
|
||||
//! is why it survived four full suite runs before being named.
|
||||
//!
|
||||
//! That is not hypothetical — it shipped. Background thumbnail generation is
|
||||
//! spawned and unawaited, so an upload deleted promptly had its render
|
||||
//! complete after GC reaped the blob and then record three mappings to a
|
||||
//! corpse. Fixed at the write side in `store_derived_blob`, which now refuses
|
||||
//! a mapping whose source is gone; this job finds the ones already on disk,
|
||||
//! which that fix cannot reach.
|
||||
//!
|
||||
//! ### Per-row checks
|
||||
//!
|
||||
//! * `derived_orphan_mapping` (`inconsistent`) — a `content_derived_blobs`
|
||||
//! row whose `source_hash` has neither a manifest nor a blob row. Storage
|
||||
//! that grows and never reclaims.
|
||||
//! * `derived_dangling_blob` (`data_loss`) — its `blob_hash` has no Blob.
|
||||
//! The mapping promises an artifact that is gone, so a read finds the row
|
||||
//! and then fails. Recoverable in practice: a derived artifact is a pure
|
||||
//! function of its source, so re-rendering restores it.
|
||||
//! * `attached_dangling_blob` (`data_loss`) — the same for
|
||||
//! `file_attached_blobs`, and **the one that cannot be recovered**. These
|
||||
//! bytes are user-supplied — a client-generated PDF preview has no
|
||||
//! server-side render path — so there is nothing to regenerate from. Same
|
||||
//! finding shape as the derived case, materially higher stakes.
|
||||
//!
|
||||
//! There is deliberately no orphan-mapping check for the attached table:
|
||||
//! `file_id` is `REFERENCES storage.files(id) ON DELETE CASCADE`, so a row
|
||||
//! cannot outlive its file. The database enforces what the derived table
|
||||
//! cannot, since a content hash has no row to point a foreign key at — which
|
||||
//! is precisely why only that half could rot.
|
||||
//!
|
||||
//! Read-only, per the house default. Findings name a row rather than a range,
|
||||
//! so recovery can act on them individually.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::infrastructure::scheduler::{
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, RecoverableJobHandler, RunOutcome,
|
||||
RunStatus, record_or_log,
|
||||
};
|
||||
|
||||
pub const SATELLITES_CONSISTENCY_JOB_NAME: &str = "satellites_consistency";
|
||||
|
||||
/// Rows per page. Existence probes fold into the page query, so a page costs
|
||||
/// one round-trip rather than `2 × rows`.
|
||||
const BATCH_SIZE: i64 = 500;
|
||||
|
||||
/// "Does this hash name a Blob?" — either table, because a Blob is a manifest
|
||||
/// for CDC content and a bare `storage.blobs` row for legacy whole-file
|
||||
/// content. Checking one would report every legacy blob as missing.
|
||||
macro_rules! blob_exists {
|
||||
($col:literal) => {
|
||||
concat!(
|
||||
"(EXISTS (SELECT 1 FROM storage.chunk_manifests m WHERE m.file_hash = ",
|
||||
$col,
|
||||
") OR EXISTS (SELECT 1 FROM storage.blobs b WHERE b.hash = ",
|
||||
$col,
|
||||
"))"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
pub struct SatellitesConsistencyCheck {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DerivedRow {
|
||||
source_hash: String,
|
||||
kind: String,
|
||||
variant: String,
|
||||
blob_hash: String,
|
||||
source_exists: bool,
|
||||
artifact_exists: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct AttachedRow {
|
||||
file_id: Uuid,
|
||||
kind: String,
|
||||
variant: String,
|
||||
blob_hash: String,
|
||||
uploaded_by: Uuid,
|
||||
artifact_exists: bool,
|
||||
}
|
||||
|
||||
impl SatellitesConsistencyCheck {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn register_recoverable_job(
|
||||
self: Arc<Self>,
|
||||
registry: &JobRegistry,
|
||||
provider: &Arc<dyn JobStoreProvider>,
|
||||
) -> Arc<Self> {
|
||||
registry
|
||||
.register_recoverable_job(self.clone(), provider.clone(), None)
|
||||
.await;
|
||||
self
|
||||
}
|
||||
|
||||
/// Both page queries key on the full primary key with a row-value
|
||||
/// comparison, not on the first column: a source (or file) has several
|
||||
/// variants, so a page boundary can fall inside one and advancing by the
|
||||
/// first column alone would skip the rest. The tuple form also matches
|
||||
/// the primary key's own ordering, so it stays index-friendly.
|
||||
const DERIVED_PAGE_SQL: &'static str = concat!(
|
||||
"SELECT d.source_hash, d.kind, d.variant, d.blob_hash, ",
|
||||
blob_exists!("d.source_hash"),
|
||||
" AS source_exists, ",
|
||||
blob_exists!("d.blob_hash"),
|
||||
" AS artifact_exists
|
||||
FROM storage.content_derived_blobs d
|
||||
WHERE ($1::text IS NULL
|
||||
OR (d.source_hash, d.kind, d.variant) > ($1::text, $2::text, $3::text))
|
||||
ORDER BY d.source_hash, d.kind, d.variant
|
||||
LIMIT $4"
|
||||
);
|
||||
|
||||
const ATTACHED_PAGE_SQL: &'static str = concat!(
|
||||
"SELECT a.file_id, a.kind, a.variant, a.blob_hash, a.uploaded_by, ",
|
||||
blob_exists!("a.blob_hash"),
|
||||
" AS artifact_exists
|
||||
FROM storage.file_attached_blobs a
|
||||
WHERE ($1::uuid IS NULL
|
||||
OR (a.file_id, a.kind, a.variant) > ($1::uuid, $2::text, $3::text))
|
||||
ORDER BY a.file_id, a.kind, a.variant
|
||||
LIMIT $4"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cursor is `{phase}\n{a}\n{b}\n{c}`.
|
||||
///
|
||||
/// The phase is what lets one job walk two tables and still resume exactly:
|
||||
/// without it, a cursor from the attached pass would be replayed against the
|
||||
/// derived table and silently re-scan or skip. Newline is a safe delimiter —
|
||||
/// hashes are hex, uuids are uuids, `kind` comes from a CHECK constraint, and
|
||||
/// `variant` is a size/format token.
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
enum Phase {
|
||||
Derived,
|
||||
Attached,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Phase::Derived => "derived",
|
||||
Phase::Attached => "attached",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cursor(phase: Phase, a: &str, b: &str, c: &str) -> Vec<u8> {
|
||||
format!("{}\n{a}\n{b}\n{c}", phase.as_str()).into_bytes()
|
||||
}
|
||||
|
||||
type Cursor = Option<(Phase, String, String, String)>;
|
||||
|
||||
fn decode_cursor(bytes: Vec<u8>) -> Result<Cursor, String> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let s = String::from_utf8(bytes).map_err(|e| format!("not valid UTF-8: {e}"))?;
|
||||
let mut parts = s.splitn(4, '\n');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some("derived"), Some(a), Some(b), Some(c)) => {
|
||||
Ok(Some((Phase::Derived, a.into(), b.into(), c.into())))
|
||||
}
|
||||
(Some("attached"), Some(a), Some(b), Some(c)) => {
|
||||
Ok(Some((Phase::Attached, a.into(), b.into(), c.into())))
|
||||
}
|
||||
_ => Err(format!("malformed cursor: {s:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecoverableJobHandler for SatellitesConsistencyCheck {
|
||||
fn name(&self) -> &str {
|
||||
SATELLITES_CONSISTENCY_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Walks both satellite tables — content_derived_blobs (thumbnails \
|
||||
keyed by source content) and file_attached_blobs (previews keyed \
|
||||
by file) — and reports mappings whose source or target no longer \
|
||||
exists. Nothing else finds these: every other job reasons from a \
|
||||
Blob outwards, and a satellite row pointing at a deleted source \
|
||||
breaks none of their invariants. Read-only."
|
||||
}
|
||||
|
||||
async fn count_total(&self) -> Option<u64> {
|
||||
sqlx::query_as::<_, (i64,)>(
|
||||
"SELECT (SELECT COUNT(*) FROM storage.content_derived_blobs)
|
||||
+ (SELECT COUNT(*) FROM storage.file_attached_blobs)",
|
||||
)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.ok()
|
||||
.map(|(n,)| n.max(0) as u64)
|
||||
}
|
||||
|
||||
async fn run_resumable(
|
||||
&self,
|
||||
store: &dyn JobStore,
|
||||
_args: &JobRunArgs,
|
||||
resume_cursor: Option<Vec<u8>>,
|
||||
) -> RunOutcome {
|
||||
let start = match resume_cursor.map(decode_cursor).transpose() {
|
||||
Ok(c) => c.flatten(),
|
||||
Err(message) => return RunOutcome::Failed { message },
|
||||
};
|
||||
|
||||
let mut finding_count = 0u64;
|
||||
|
||||
// ── Phase 1: content-keyed ───────────────────────────────────────
|
||||
// Skipped entirely when resuming mid-attached, since that phase runs
|
||||
// strictly after this one.
|
||||
let mut derived_cursor = match &start {
|
||||
Some((Phase::Attached, ..)) => None,
|
||||
Some((Phase::Derived, a, b, c)) => Some((a.clone(), b.clone(), c.clone())),
|
||||
None => None,
|
||||
};
|
||||
let skip_derived = matches!(&start, Some((Phase::Attached, ..)));
|
||||
|
||||
if !skip_derived {
|
||||
loop {
|
||||
if let Some(outcome) = poll_cancel(
|
||||
store,
|
||||
derived_cursor
|
||||
.as_ref()
|
||||
.map(|(a, b, c)| encode_cursor(Phase::Derived, a, b, c)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let (ch, ck, cv) = match &derived_cursor {
|
||||
Some((a, b, c)) => (Some(a.as_str()), Some(b.as_str()), Some(c.as_str())),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
let rows: Vec<DerivedRow> = match sqlx::query_as(Self::DERIVED_PAGE_SQL)
|
||||
.bind(ch)
|
||||
.bind(ck)
|
||||
.bind(cv)
|
||||
.bind(BATCH_SIZE)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("derived page: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
if rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for row in &rows {
|
||||
if !row.source_exists {
|
||||
finding_count += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
SATELLITES_CONSISTENCY_JOB_NAME,
|
||||
"derived_orphan_mapping",
|
||||
"inconsistent",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"source_hash": row.source_hash,
|
||||
"kind": row.kind,
|
||||
"variant": row.variant,
|
||||
"blob_hash": row.blob_hash,
|
||||
"note": "source Blob is gone, so purge_derived_blobs can never \
|
||||
fire; this row pins its artifact forever",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !row.artifact_exists {
|
||||
finding_count += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
SATELLITES_CONSISTENCY_JOB_NAME,
|
||||
"derived_dangling_blob",
|
||||
"data_loss",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"source_hash": row.source_hash,
|
||||
"kind": row.kind,
|
||||
"variant": row.variant,
|
||||
"blob_hash": row.blob_hash,
|
||||
"recoverable": true,
|
||||
"note": "artifact missing; derived content is a pure function of \
|
||||
its source, so re-rendering restores it",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let scanned = rows.len() as u64;
|
||||
let last = rows.last().unwrap();
|
||||
derived_cursor = Some((
|
||||
last.source_hash.clone(),
|
||||
last.kind.clone(),
|
||||
last.variant.clone(),
|
||||
));
|
||||
if let Err(e) = store
|
||||
.checkpoint(
|
||||
encode_cursor(Phase::Derived, &last.source_hash, &last.kind, &last.variant),
|
||||
scanned,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return RunOutcome::Failed {
|
||||
message: format!("checkpoint: {e}"),
|
||||
};
|
||||
}
|
||||
if scanned < BATCH_SIZE as u64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: file-keyed ──────────────────────────────────────────
|
||||
// No orphan-mapping check here: `file_id` is ON DELETE CASCADE, so a
|
||||
// row cannot outlive its file. Only the artifact side can rot.
|
||||
let mut attached_cursor: Option<(Uuid, String, String)> = match &start {
|
||||
Some((Phase::Attached, a, b, c)) => match Uuid::parse_str(a) {
|
||||
Ok(id) => Some((id, b.clone(), c.clone())),
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("attached cursor is not a uuid: {e}"),
|
||||
};
|
||||
}
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
|
||||
loop {
|
||||
if let Some(outcome) = poll_cancel(
|
||||
store,
|
||||
attached_cursor
|
||||
.as_ref()
|
||||
.map(|(a, b, c)| encode_cursor(Phase::Attached, &a.to_string(), b, c)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let (ch, ck, cv) = match &attached_cursor {
|
||||
Some((a, b, c)) => (Some(*a), Some(b.as_str()), Some(c.as_str())),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
let rows: Vec<AttachedRow> = match sqlx::query_as(Self::ATTACHED_PAGE_SQL)
|
||||
.bind(ch)
|
||||
.bind(ck)
|
||||
.bind(cv)
|
||||
.bind(BATCH_SIZE)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("attached page: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
if rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for row in &rows {
|
||||
if !row.artifact_exists {
|
||||
finding_count += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
SATELLITES_CONSISTENCY_JOB_NAME,
|
||||
"attached_dangling_blob",
|
||||
"data_loss",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"file_id": row.file_id,
|
||||
"kind": row.kind,
|
||||
"variant": row.variant,
|
||||
"blob_hash": row.blob_hash,
|
||||
"uploaded_by": row.uploaded_by,
|
||||
"recoverable": false,
|
||||
"note": "UNRECOVERABLE: these bytes were user-supplied and have no \
|
||||
server-side render path, so nothing can regenerate them",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let scanned = rows.len() as u64;
|
||||
let last = rows.last().unwrap();
|
||||
attached_cursor = Some((last.file_id, last.kind.clone(), last.variant.clone()));
|
||||
if let Err(e) = store
|
||||
.checkpoint(
|
||||
encode_cursor(
|
||||
Phase::Attached,
|
||||
&last.file_id.to_string(),
|
||||
&last.kind,
|
||||
&last.variant,
|
||||
),
|
||||
scanned,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return RunOutcome::Failed {
|
||||
message: format!("checkpoint: {e}"),
|
||||
};
|
||||
}
|
||||
if scanned < BATCH_SIZE as u64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "oxicloud::consistency",
|
||||
event = "satellites_consistency.completed",
|
||||
run_id = %store.run_id(),
|
||||
finding_count = finding_count,
|
||||
"satellites_consistency completed with {} finding(s)",
|
||||
finding_count
|
||||
);
|
||||
|
||||
RunOutcome::completed()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cooperative cancel, shared by both phases so neither can forget it.
|
||||
async fn poll_cancel(store: &dyn JobStore, cursor: Option<Vec<u8>>) -> Option<RunOutcome> {
|
||||
match store.status().await {
|
||||
Ok(RunStatus::CancelRequested) => Some(RunOutcome::Paused {
|
||||
cursor: cursor.unwrap_or_default(),
|
||||
}),
|
||||
Ok(_) => None,
|
||||
Err(e) => Some(RunOutcome::Failed {
|
||||
message: format!("status poll: {e}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The phase is what lets one job walk two tables and resume exactly.
|
||||
/// Without it an attached cursor would be replayed against the derived
|
||||
/// table, silently re-scanning or skipping — an audit job under-reporting
|
||||
/// is the worst failure available to it.
|
||||
#[test]
|
||||
fn cursor_round_trips_and_keeps_its_phase() {
|
||||
for phase in [Phase::Derived, Phase::Attached] {
|
||||
let encoded = encode_cursor(phase, "0a1b", "thumbnail", "preview.webp");
|
||||
assert_eq!(
|
||||
decode_cursor(encoded).unwrap(),
|
||||
Some((
|
||||
phase,
|
||||
"0a1b".to_string(),
|
||||
"thumbnail".to_string(),
|
||||
"preview.webp".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_cursor_starts_from_the_beginning() {
|
||||
assert_eq!(decode_cursor(Vec::new()).unwrap(), None);
|
||||
}
|
||||
|
||||
/// Loudly, rather than silently restarting: a corrupt checkpoint that
|
||||
/// reads as "start over" gives a job that never finishes and never says
|
||||
/// why.
|
||||
#[test]
|
||||
fn malformed_cursor_is_an_error() {
|
||||
assert!(decode_cursor(b"only-one-field".to_vec()).is_err());
|
||||
assert!(decode_cursor(b"bogus\na\nb\nc".to_vec()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ use tracing::{error, info};
|
||||
|
||||
use crate::domain::repositories::session_repository::SessionRepository;
|
||||
use crate::infrastructure::repositories::SessionPgRepository;
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs, Mutates};
|
||||
|
||||
/// How long a session row survives past its `expires_at` before this
|
||||
/// janitor deletes it. Enough time for a security review of a
|
||||
@@ -84,6 +84,17 @@ impl JobHandler for SessionCleanupService {
|
||||
Self::JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Deletes session rows long past their expiry. They cannot \
|
||||
authenticate — expiry is checked at every auth path — but the row \
|
||||
keeps a forensic trail (which user, from which IP, minted how) \
|
||||
for a retention window after expiry, then becomes dead weight."
|
||||
}
|
||||
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
/// Runs one bulk-delete of long-expired session rows. `count` on
|
||||
/// the returned `JobOutcome::Ok` is the number of rows dropped
|
||||
/// this tick; `extra` records the retention window operators can
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
//! `thumb_attached_import` — backfill `storage.file_attached_blobs` from the
|
||||
//! `ext-{file_id}.jpg` sidecars that predate it.
|
||||
//!
|
||||
//! Second half of step 10's migration, and the twin of
|
||||
//! `thumb_derived_import`. These are the thumbnails a *user* supplied — the
|
||||
//! SPA's client-side generator, notably for PDFs, which have no server-side
|
||||
//! render path at all. They live only as
|
||||
//! `{thumbnails_root}/{size}/ext-{file_id}.jpg` on local disk.
|
||||
//!
|
||||
//! Until a row exists, a **copy of the file loses the preview**: the sidecar
|
||||
//! is keyed by `file_id`, no copy path duplicates it, and the server silently
|
||||
//! falls back to rendering from the source (or to nothing, for a PDF). That
|
||||
//! is the bug `file_attached_blobs` closed for new uploads; this job closes
|
||||
//! it for everything already on disk.
|
||||
//!
|
||||
//! ### File-keyed, and that is the whole point
|
||||
//!
|
||||
//! These bytes are **not** derivable from the file's content, so they must
|
||||
//! never be content-keyed. Sharing one user's uploaded preview across every
|
||||
//! file with identical content is the poisoning vector the table split
|
||||
//! exists to prevent — see `docs/plan/derived-blobs.md`. `thumb_derived_import`
|
||||
//! deliberately rejects `ext-` names for the same reason, and the two jobs
|
||||
//! are separate so neither can drift into the other's keying.
|
||||
//!
|
||||
//! ### Idempotence needs care here
|
||||
//!
|
||||
//! Unlike the derived twin, `store_attached_blob` is `ON CONFLICT DO UPDATE`:
|
||||
//! calling it for a row that already exists releases the previous reference
|
||||
//! and takes a new one. Harmless once, but a job that did it on every run
|
||||
//! would churn refcounts. So each file is skipped when a row is already
|
||||
//! present, and the store is only reached on a genuine insert.
|
||||
//!
|
||||
//! ### Multi-instance caveat
|
||||
//!
|
||||
//! Sidecars are local, so this migrates only the instance it runs on. Phase 3
|
||||
//! must be gated on every instance reporting an empty tail.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use sqlx::PgPool;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::thumbnail_ports::ThumbnailSize;
|
||||
use crate::infrastructure::scheduler::{
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, Mutates, RecoverableJobHandler,
|
||||
RunOutcome, RunStatus, record_or_log,
|
||||
};
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
// The readback-then-unlink rule is shared, not copied: two versions of it
|
||||
// would be two chances to weaken one, and this is the check standing between
|
||||
// a migration and permanent loss.
|
||||
use crate::infrastructure::services::thumb_derived_import_service::ThumbDerivedImport;
|
||||
|
||||
pub const THUMB_ATTACHED_IMPORT_JOB_NAME: &str = "thumb_attached_import";
|
||||
|
||||
/// Files handled between checkpoints — a read plus at most a blob write each.
|
||||
const BATCH_SIZE: usize = 100;
|
||||
|
||||
/// `uploaded_by` for imported rows.
|
||||
///
|
||||
/// Disk records no uploader, and the column is deliberately `NOT NULL` with no
|
||||
/// FK so provenance survives a user deletion. A sentinel says "imported, real
|
||||
/// uploader unknown" honestly; inventing an owner — the file's `created_by`,
|
||||
/// say — would fabricate provenance that could later be read as evidence an
|
||||
/// Editor replaced someone's preview.
|
||||
const IMPORTED_UPLOADER: Uuid = Uuid::nil();
|
||||
|
||||
pub struct ThumbAttachedImport {
|
||||
thumbnails_root: PathBuf,
|
||||
dedup: Arc<DedupService>,
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl ThumbAttachedImport {
|
||||
pub fn new(thumbnails_root: PathBuf, dedup: Arc<DedupService>, pool: Arc<PgPool>) -> Self {
|
||||
Self {
|
||||
thumbnails_root,
|
||||
dedup,
|
||||
pool,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_recoverable_job(
|
||||
self: Arc<Self>,
|
||||
registry: &JobRegistry,
|
||||
provider: &Arc<dyn JobStoreProvider>,
|
||||
) -> Arc<Self> {
|
||||
// On-demand, matching `thumb_derived_import` — the boot run in repair
|
||||
// mode is the migration, and a tick could not finish it anyway
|
||||
// because ticks never pass `repair`. See that job for the reasoning.
|
||||
registry
|
||||
.register_recoverable_job(self.clone(), provider.clone(), None)
|
||||
.await;
|
||||
self
|
||||
}
|
||||
|
||||
/// The file id an external sidecar names, or `None` when the file is not
|
||||
/// one of ours.
|
||||
///
|
||||
/// Requires a parseable UUID: the name is about to be used as a foreign
|
||||
/// key, and a malformed one should be reported rather than fed to the
|
||||
/// database.
|
||||
fn file_id_from_sidecar_name(name: &str) -> Option<Uuid> {
|
||||
let stem = name.strip_prefix("ext-")?.strip_suffix(".jpg")?;
|
||||
Uuid::parse_str(stem).ok()
|
||||
}
|
||||
|
||||
/// Sorted external-sidecar filenames for one size directory.
|
||||
///
|
||||
/// Sorted because the cursor resumes by skipping everything at or before
|
||||
/// it, which only works over a stable order.
|
||||
///
|
||||
/// Takes the root rather than reading `self`, so the walk — the half that
|
||||
/// decides which files this job claims, and therefore which keying they
|
||||
/// get — is testable against a temp directory with no database in sight.
|
||||
async fn sidecar_names(root: &std::path::Path, size: ThumbnailSize) -> Vec<String> {
|
||||
let dir = root.join(size.dir_name());
|
||||
let Ok(mut entries) = fs::read_dir(&dir).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if let Some(name) = entry.file_name().to_str()
|
||||
&& Self::file_id_from_sidecar_name(name).is_some()
|
||||
{
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Does the file still exist? Checked explicitly rather than letting the
|
||||
/// foreign key reject the insert, so an orphaned sidecar is *counted* as
|
||||
/// an orphan instead of surfacing as an opaque constraint error.
|
||||
/// `SELECT EXISTS(...)`, deliberately, rather than `SELECT 1 … LIMIT 1`.
|
||||
///
|
||||
/// PostgreSQL types a bare `1` as `int4`, so decoding it as `i64` fails —
|
||||
/// and because a decode error is indistinguishable from "no row" once
|
||||
/// swallowed, every sidecar would be misreported as an orphan and nothing
|
||||
/// would import. `EXISTS` yields a real `bool` and always returns exactly
|
||||
/// one row, so absence means absence.
|
||||
///
|
||||
/// A query error still degrades to `false`, which is the safe direction:
|
||||
/// the file is reported as an orphan and left on disk for the operator,
|
||||
/// rather than imported against a row that may not exist.
|
||||
async fn file_exists(&self, file_id: Uuid) -> bool {
|
||||
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM storage.files WHERE id = $1)")
|
||||
.bind(file_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecoverableJobHandler for ThumbAttachedImport {
|
||||
fn name(&self) -> &str {
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Migrates USER-UPLOADED previews (ext-{file_id}.jpg) into \
|
||||
file-keyed blob storage. Until a row exists, copying a file loses \
|
||||
its preview: the sidecar is keyed by file id and no copy path \
|
||||
duplicates it. These bytes have no server-side render path, so \
|
||||
unlike rendered thumbnails they cannot be regenerated."
|
||||
}
|
||||
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
Some(
|
||||
"Also DELETES each sidecar once its replacement has been read \
|
||||
back. Previews whose file no longer exists are deleted without \
|
||||
a readback — nothing can reference them again. Irreversible, \
|
||||
and these bytes cannot be regenerated, so the readback is the \
|
||||
only safeguard.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn count_total(&self) -> Option<u64> {
|
||||
let mut total = 0u64;
|
||||
for size in ThumbnailSize::all() {
|
||||
total += Self::sidecar_names(&self.thumbnails_root, *size)
|
||||
.await
|
||||
.len() as u64;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
async fn run_resumable(
|
||||
&self,
|
||||
store: &dyn JobStore,
|
||||
args: &JobRunArgs,
|
||||
resume_cursor: Option<Vec<u8>>,
|
||||
) -> RunOutcome {
|
||||
// Cursor is `{size_dir}/{filename}`, matching thumb_derived_import:
|
||||
// sizes walk in `ThumbnailSize::all()` order and names are sorted
|
||||
// within each, so the pair totally orders the traversal.
|
||||
let cursor: Option<String> = match resume_cursor {
|
||||
None => None,
|
||||
Some(b) if b.is_empty() => None,
|
||||
Some(b) => match String::from_utf8(b) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("invalid cursor: not valid UTF-8: {e}"),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut imported = 0u64;
|
||||
let mut already = 0u64;
|
||||
let mut orphaned = 0u64;
|
||||
let mut deleted = 0u64;
|
||||
let mut unverified = 0u64;
|
||||
// Same opt-in as thumb_derived_import: `?repair=true`.
|
||||
//
|
||||
// The readback before unlinking matters more here than there. These
|
||||
// sidecars are the ones that CANNOT be regenerated — a client-uploaded
|
||||
// PDF preview has no server-side render path — so it is not
|
||||
// belt-and-braces, it is the only thing between a migration and
|
||||
// permanent loss.
|
||||
let delete_imported = args.repair;
|
||||
let mut failed = 0u64;
|
||||
let mut since_checkpoint = 0usize;
|
||||
|
||||
for size in ThumbnailSize::all() {
|
||||
let dir_name = size.dir_name().to_string();
|
||||
for name in Self::sidecar_names(&self.thumbnails_root, *size).await {
|
||||
let position = format!("{dir_name}/{name}");
|
||||
|
||||
if let Some(c) = &cursor
|
||||
&& position.as_str() <= c.as_str()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
match store.status().await {
|
||||
Ok(RunStatus::CancelRequested) => {
|
||||
return RunOutcome::Paused {
|
||||
cursor: position.into_bytes(),
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("status poll: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let Some(file_id) = Self::file_id_from_sidecar_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
let file_id_str = file_id.to_string();
|
||||
|
||||
// Already mapped. Checked BEFORE storing, because
|
||||
// `store_attached_blob` is ON CONFLICT DO UPDATE and would
|
||||
// release then retake the reference on every run.
|
||||
if let Some(existing) = self
|
||||
.dedup
|
||||
.find_attached_blob(&file_id_str, "preview", &dir_name)
|
||||
.await
|
||||
{
|
||||
already += 1;
|
||||
// Drains on a later run too: importing first and enabling
|
||||
// deletion afterwards is the expected operator sequence,
|
||||
// so reaching here is the common path rather than an edge
|
||||
// case.
|
||||
if delete_imported {
|
||||
let path = self.thumbnails_root.join(&dir_name).join(&name);
|
||||
if ThumbDerivedImport::verify_and_unlink(
|
||||
&self.dedup,
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
&file_id_str,
|
||||
&existing.blob_hash,
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
deleted += 1;
|
||||
} else {
|
||||
unverified += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
"sidecar_delete_unverified",
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"file_id": file_id_str,
|
||||
"note": "attached blob did not read back; sidecar kept",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else if !self.file_exists(file_id).await {
|
||||
// The file is gone, so this sidecar is unimportable: the
|
||||
// FK on `file_id` would reject the row. Mirrors the
|
||||
// dead-source case in thumb_derived_import.
|
||||
//
|
||||
// Reported by default — a destructive default on a
|
||||
// migration is what no-silent-auto-repair forbids — and
|
||||
// deleted under `repair`, because otherwise it is
|
||||
// rediscovered on every run, the tail never empties, and
|
||||
// step 10e's gate never opens.
|
||||
//
|
||||
// Safe to delete despite these being the non-regenerable
|
||||
// bytes: the preview is keyed to a `file_id` that no
|
||||
// longer exists, so nothing can ever reference it again.
|
||||
// Unrecoverable and unreachable are different things, and
|
||||
// this is both.
|
||||
//
|
||||
// No readback before unlinking, unlike the imported path:
|
||||
// there is no row and no blob to read back, and nothing to
|
||||
// regenerate from either.
|
||||
orphaned += 1;
|
||||
let mut removed = false;
|
||||
if delete_imported {
|
||||
let path = self.thumbnails_root.join(&dir_name).join(&name);
|
||||
if fs::remove_file(&path).await.is_ok() {
|
||||
deleted += 1;
|
||||
removed = true;
|
||||
// Explicit: nothing to verify against, so this
|
||||
// bypasses verify_and_unlink. Worth auditing
|
||||
// loudest of all — these bytes were
|
||||
// user-supplied and cannot be regenerated, even
|
||||
// though the file that owned them is gone.
|
||||
crate::infrastructure::services::thumb_derived_import_service::audit_sidecar_deleted(
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
"orphaned",
|
||||
&file_id_str,
|
||||
"-",
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Recorded in BOTH modes — see the twin in
|
||||
// thumb_derived_import. Deleting a non-regenerable
|
||||
// user-uploaded preview and reporting nothing is the
|
||||
// worst version of this: the one outcome an operator
|
||||
// needs in the run drawer was the one it withheld.
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
"attached_sidecar_orphan",
|
||||
// `anomaly` renders as "notices"; `detail.deleted`
|
||||
// is what says whether the run acted. See the
|
||||
// derived twin.
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"file_id": file_id_str,
|
||||
"deleted": removed,
|
||||
"note": if removed {
|
||||
"no storage.files row; sidecar was unimportable and has been \
|
||||
deleted — nothing can reference it again"
|
||||
} else {
|
||||
"no storage.files row; unimportable, and deleted on a repair \
|
||||
run since nothing can reference it again"
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let path = self.thumbnails_root.join(&dir_name).join(&name);
|
||||
match fs::read(&path).await {
|
||||
Ok(data) => {
|
||||
match self
|
||||
.dedup
|
||||
.store_attached_blob(
|
||||
&file_id_str,
|
||||
"preview",
|
||||
&dir_name,
|
||||
// store_external_thumbnail re-encodes to
|
||||
// JPEG before writing, so the extension
|
||||
// is authoritative here.
|
||||
"image/jpeg",
|
||||
Bytes::from(data),
|
||||
IMPORTED_UPLOADER,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(attached_hash) => {
|
||||
imported += 1;
|
||||
if delete_imported {
|
||||
if ThumbDerivedImport::verify_and_unlink(
|
||||
&self.dedup,
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
&file_id_str,
|
||||
&attached_hash,
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
deleted += 1;
|
||||
} else {
|
||||
unverified += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
"sidecar_delete_unverified",
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"file_id": file_id_str,
|
||||
"note": "attached blob did not read back; sidecar kept",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
"attached_import_failed",
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"file_id": file_id_str,
|
||||
"error": format!("{e}"),
|
||||
"note": "sidecar left in place; safe to re-run",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
"attached_sidecar_unreadable",
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"error": format!("{e}"),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
since_checkpoint += 1;
|
||||
if since_checkpoint >= BATCH_SIZE {
|
||||
if let Err(e) = store
|
||||
.checkpoint(position.clone().into_bytes(), since_checkpoint as u64)
|
||||
.await
|
||||
{
|
||||
return RunOutcome::Failed {
|
||||
message: format!("checkpoint: {e}"),
|
||||
};
|
||||
}
|
||||
since_checkpoint = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both jobs attempt the teardown, and it no-ops unless the tree is
|
||||
// drained of files EITHER of them claims. Without this, whichever
|
||||
// job runs last leaves an empty `.thumbnails/` behind until the
|
||||
// next boot; with it, the tree disappears in the same run that
|
||||
// empties it, whatever order the two ran in.
|
||||
if delete_imported {
|
||||
crate::infrastructure::services::thumb_derived_import_service::teardown_if_drained(
|
||||
&self.thumbnails_root,
|
||||
THUMB_ATTACHED_IMPORT_JOB_NAME,
|
||||
&store.run_id().to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "oxicloud::dedup",
|
||||
event = "thumb_attached_import.completed",
|
||||
run_id = %store.run_id(),
|
||||
imported = imported,
|
||||
already_present = already,
|
||||
orphaned = orphaned,
|
||||
failed = failed,
|
||||
deleted = deleted,
|
||||
unverified = unverified,
|
||||
"thumb_attached_import: {imported} imported, {already} already present, \
|
||||
{orphaned} orphaned, {failed} failed, {deleted} sidecar(s) deleted, \
|
||||
{unverified} kept unverified"
|
||||
);
|
||||
|
||||
// Same reasoning as the derived twin: what the run did belongs on
|
||||
// the run row, not only in the process log.
|
||||
RunOutcome::completed_with(serde_json::json!({
|
||||
"imported": imported,
|
||||
"already_present": already,
|
||||
"deleted": deleted,
|
||||
"unverified": unverified,
|
||||
"orphaned": orphaned,
|
||||
"failed": failed,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const UUID: &str = "3f2b1c00-1111-2222-3333-444455556666";
|
||||
const HASH: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9";
|
||||
|
||||
#[test]
|
||||
fn accepts_an_external_sidecar_name() {
|
||||
assert_eq!(
|
||||
ThumbAttachedImport::file_id_from_sidecar_name(&format!("ext-{UUID}.jpg")),
|
||||
Some(Uuid::parse_str(UUID).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the partition. Reuses the same legacy tree as
|
||||
/// `thumb_derived_import`'s test on purpose: the two jobs run over one
|
||||
/// directory, so the property that matters is that together they claim
|
||||
/// every real sidecar exactly once, and neither takes the other's.
|
||||
#[tokio::test]
|
||||
async fn walk_claims_only_uploaded_previews() {
|
||||
use crate::infrastructure::services::thumb_derived_import_service::ThumbDerivedImport;
|
||||
|
||||
let tmp =
|
||||
crate::infrastructure::services::thumb_derived_import_service::tests::legacy_tree()
|
||||
.await;
|
||||
|
||||
let attached = ThumbAttachedImport::sidecar_names(tmp.path(), ThumbnailSize::Preview).await;
|
||||
let derived = ThumbDerivedImport::sidecar_names(tmp.path(), ThumbnailSize::Preview).await;
|
||||
|
||||
assert_eq!(
|
||||
attached,
|
||||
vec!["ext-3f2b1c00-1111-2222-3333-444455556666.jpg".to_string()],
|
||||
"must claim the uploaded preview and nothing else"
|
||||
);
|
||||
|
||||
// Disjoint: no file is imported under both keyings, which would take
|
||||
// two references and — worse — content-key user-supplied bytes.
|
||||
for a in &attached {
|
||||
assert!(
|
||||
!derived.contains(a),
|
||||
"both jobs claimed {a}; keying would be ambiguous"
|
||||
);
|
||||
}
|
||||
// And nothing real is dropped: README.txt is the only unclaimed file.
|
||||
// Two content-keyed .webp, one content-keyed .jpg, one ext- upload.
|
||||
// The .jpg pair is the interesting one: same extension, opposite
|
||||
// keying, and only the `ext-` prefix separates them.
|
||||
assert_eq!(
|
||||
attached.len() + derived.len(),
|
||||
4,
|
||||
"every real sidecar must be claimed exactly once between the two jobs"
|
||||
);
|
||||
}
|
||||
|
||||
/// The content-keyed sidecars belong to `thumb_derived_import`. Importing
|
||||
/// one here would file-key bytes that are shared across every file with
|
||||
/// the same content, so each such file would take its own reference to
|
||||
/// content it does not own.
|
||||
#[test]
|
||||
fn rejects_content_keyed_and_malformed_names() {
|
||||
for name in [
|
||||
format!("{HASH}.webp"),
|
||||
format!("{HASH}.jpg"),
|
||||
format!("ext-{UUID}.webp"),
|
||||
format!("ext-{UUID}"),
|
||||
"ext-not-a-uuid.jpg".to_string(),
|
||||
format!("{UUID}.jpg"),
|
||||
] {
|
||||
assert_eq!(
|
||||
ThumbAttachedImport::file_id_from_sidecar_name(&name),
|
||||
None,
|
||||
"must not be imported as an attached preview: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentinel must be stable: rows carrying it are how an operator
|
||||
/// tells an imported preview from one with real provenance.
|
||||
#[test]
|
||||
fn imported_uploader_is_the_nil_sentinel() {
|
||||
assert_eq!(
|
||||
IMPORTED_UPLOADER.to_string(),
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
//! `thumb_derived_import` — backfill `storage.content_derived_blobs` from the
|
||||
//! on-disk thumbnail sidecars that predate it.
|
||||
//!
|
||||
//! Step 10 of `docs/plan/derived-blobs.md`. Every server-rendered thumbnail
|
||||
//! written before `content_derived_blobs` existed lives only as
|
||||
//! `{thumbnails_root}/{size}/{hash}.webp`. That is local-disk state: another
|
||||
//! instance cannot see it, a backend migration does not carry it, and no
|
||||
//! consistency job covers it. This job moves those bytes into the blob store
|
||||
//! and records the mapping, after which the derived tier can become
|
||||
//! authoritative and the sidecar can be deleted.
|
||||
//!
|
||||
//! **Thumbnails only.** The table also holds `kind = 'transcode'`, and those
|
||||
//! need their own import — `ImageTranscodeService` already exists and caches
|
||||
//! to `.transcoded/{ext}/{file_id}.{ext}`, a different tree with a different
|
||||
//! key. Importing them means **re-keying** file→content, which is legitimate
|
||||
//! only because a transcode is derivable from the source bytes. Separate job;
|
||||
//! this one will not grow a transcode arm.
|
||||
//!
|
||||
//! ### Idempotent by construction
|
||||
//!
|
||||
//! Each file is skipped when a row already exists for its
|
||||
//! `(source_hash, 'thumbnail', variant)`, and `store_derived_blob` is
|
||||
//! `ON CONFLICT DO NOTHING` with a release-on-conflict underneath, so a
|
||||
//! re-run cannot inflate refcounts. Re-running is the expected operator
|
||||
//! behaviour — Phase 3 (deleting the sidecars) is gated on a run reporting
|
||||
//! zero imported.
|
||||
//!
|
||||
//! ### Multi-instance caveat
|
||||
//!
|
||||
//! Sidecars are local. Running this on one instance migrates only that
|
||||
//! instance's files, so Phase 3 must be gated on *every* instance reporting
|
||||
//! an empty tail. The run history does not aggregate across instances; that
|
||||
//! remains an operator responsibility.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailSize};
|
||||
use crate::infrastructure::scheduler::{
|
||||
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, Mutates, RecoverableJobHandler,
|
||||
RunOutcome, RunStatus, record_or_log,
|
||||
};
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
|
||||
pub const THUMB_DERIVED_IMPORT_JOB_NAME: &str = "thumb_derived_import";
|
||||
|
||||
/// Where the legacy tree is moved when it cannot be deleted.
|
||||
///
|
||||
/// Deletion is always attempted first — this is the fallback for the one
|
||||
/// case `remove_dir` refuses: a file that is not a sidecar sitting in the
|
||||
/// directory (Finder's `.DS_Store`, most often). What matters to the read
|
||||
/// path is that `.thumbnails` stops existing, so moving the tree aside
|
||||
/// achieves the same thing while preserving whatever the stray file was.
|
||||
pub(crate) const PARKED_DIR_NAME: &str = ".thumbnails.migrated";
|
||||
|
||||
/// Record a sidecar deletion on the audit channel.
|
||||
///
|
||||
/// Both import jobs delete user-visible files during a one-way migration, so
|
||||
/// the trail has to survive the run history: findings are per-run and get
|
||||
/// purged, whereas `target: "audit"` is separable and retained. If a preview
|
||||
/// later turns out to be missing, this is the only record that says the
|
||||
/// migration removed it, when, and on whose behalf.
|
||||
///
|
||||
/// `owner` is the id the file belonged to — a `source_hash` for content-keyed
|
||||
/// sidecars, a `file_id` for uploaded ones. That is the field an
|
||||
/// investigation starts from, and the raw `NEW BLOB` logs cannot supply it:
|
||||
/// they name the hash of the stored bytes, which is a different value from
|
||||
/// the sidecar's own name.
|
||||
///
|
||||
/// `reason` is a stable machine-readable key, per the convention: `imported`
|
||||
/// (replaced by a verified blob), `source_gone`, `orphaned`.
|
||||
pub(crate) fn audit_sidecar_deleted(
|
||||
job: &str,
|
||||
reason: &str,
|
||||
owner: &str,
|
||||
blob_hash: &str,
|
||||
path: &std::path::Path,
|
||||
) {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "thumbnail.sidecar_deleted",
|
||||
reason = reason,
|
||||
job = job,
|
||||
owner = owner,
|
||||
blob_hash = blob_hash,
|
||||
path = %path.display(),
|
||||
"👮🏻♂️ migration deleted a thumbnail sidecar ({reason})",
|
||||
);
|
||||
}
|
||||
|
||||
/// Files handled between checkpoints. Each one is a read plus (at most) a
|
||||
/// blob write, so this is deliberately smaller than a pure-DB sweep's page.
|
||||
const BATCH_SIZE: usize = 100;
|
||||
|
||||
/// Remove `.thumbnails/` — but only once BOTH import jobs have drained it.
|
||||
///
|
||||
/// The directory is shared and each job owns half of it: hash-named
|
||||
/// sidecars belong to `thumb_derived_import`, `ext-{file_id}.jpg` to
|
||||
/// `thumb_attached_import`. Whichever runs first therefore finds the
|
||||
/// other's files still present.
|
||||
///
|
||||
/// The first version let the derived job tear down unilaterally. It ran
|
||||
/// first, deleted its own sidecars, found `remove_dir` refused because the
|
||||
/// `ext-*` previews were still there, and fell back to renaming the tree
|
||||
/// to `.thumbnails.migrated`. The attached job then looked in
|
||||
/// `.thumbnails/`, found nothing, and reported zeros — stranding the
|
||||
/// user-uploaded previews, which are the one class of file here that
|
||||
/// cannot be regenerated. The rename fired for exactly the wrong reason:
|
||||
/// it exists for files NEITHER job claims, and it fired for the sibling's
|
||||
/// work-in-progress.
|
||||
///
|
||||
/// So the rule is: if anything remains that either job would claim, do
|
||||
/// nothing at all and let the sibling finish. Whichever job runs last then
|
||||
/// finds a genuinely empty tree and removes it, in the same boot.
|
||||
///
|
||||
/// The rename survives for its original purpose only — a file no job
|
||||
/// claims (Finder's `.DS_Store`) blocking `remove_dir` forever, which
|
||||
/// would keep the read fallback alive on every developer machine.
|
||||
pub(crate) async fn teardown_if_drained(root: &std::path::Path, job: &str, run_id: &str) {
|
||||
// Already gone — an earlier run drained it. This is the END STATE, not a
|
||||
// failure, and it is what every boot after the migration looks like.
|
||||
// Falling through would `remove_dir` a missing directory and report
|
||||
// ENOENT as "could not be removed", warning about success forever.
|
||||
if fs::metadata(root).await.is_err() {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::dedup",
|
||||
event = "thumbnail.teardown_noop",
|
||||
job = job,
|
||||
run_id = run_id,
|
||||
"no legacy sidecar directory — nothing to tear down"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut claimed_remaining = 0usize;
|
||||
let mut foreign_remaining = 0usize;
|
||||
|
||||
for size in ThumbnailSize::all() {
|
||||
let dir = root.join(size.dir_name());
|
||||
let Ok(mut entries) = fs::read_dir(&dir).await else {
|
||||
continue; // already gone
|
||||
};
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
match entry.file_name().to_str() {
|
||||
// Either job's file. `hash_from_sidecar_name` covers the
|
||||
// content-keyed sidecars, the `ext-` prefix the file-keyed
|
||||
// previews; between them that is everything a migration
|
||||
// still has to move.
|
||||
Some(name)
|
||||
if ThumbDerivedImport::hash_from_sidecar_name(name).is_some()
|
||||
|| name.starts_with("ext-") =>
|
||||
{
|
||||
claimed_remaining += 1;
|
||||
}
|
||||
_ => foreign_remaining += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if claimed_remaining > 0 {
|
||||
tracing::info!(
|
||||
target: "oxicloud::dedup",
|
||||
event = "thumbnail.teardown_deferred",
|
||||
job = job,
|
||||
run_id = run_id,
|
||||
remaining = claimed_remaining,
|
||||
"legacy sidecar directory left in place — {claimed_remaining} file(s) still \
|
||||
belong to the sibling import job, which has not finished draining them"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for size in ThumbnailSize::all() {
|
||||
let _ = fs::remove_dir(root.join(size.dir_name())).await;
|
||||
}
|
||||
|
||||
match fs::remove_dir(root).await {
|
||||
Ok(()) => tracing::info!(
|
||||
target: "oxicloud::dedup",
|
||||
event = "thumbnail.root_removed",
|
||||
job = job,
|
||||
run_id = run_id,
|
||||
path = %root.display(),
|
||||
"🧹 legacy sidecar directory removed — the fallback read path is inert \
|
||||
from the next restart"
|
||||
),
|
||||
Err(e) if foreign_remaining > 0 => {
|
||||
// `with_file_name`, NOT `with_extension`: `.thumbnails` is all
|
||||
// stem to `Path`, so `with_extension` would have produced
|
||||
// `.thumbnails.thumbnails.migrated`.
|
||||
let parked = root.with_file_name(PARKED_DIR_NAME);
|
||||
match fs::rename(root, &parked).await {
|
||||
Ok(()) => tracing::info!(
|
||||
target: "oxicloud::dedup",
|
||||
event = "thumbnail.root_parked",
|
||||
job = job,
|
||||
run_id = run_id,
|
||||
to = %parked.display(),
|
||||
foreign = foreign_remaining,
|
||||
"🧹 legacy sidecar directory holds {foreign_remaining} file(s) no import \
|
||||
job claims — moved aside instead of deleted, so nothing of anyone \
|
||||
else's is destroyed. Safe to remove by hand."
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
target: "oxicloud::dedup",
|
||||
event = "thumbnail.root_kept",
|
||||
job = job,
|
||||
run_id = run_id,
|
||||
reason = %e,
|
||||
"legacy sidecar directory neither removed nor moved aside — the \
|
||||
fallback read path stays live"
|
||||
),
|
||||
}
|
||||
let _ = e;
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
target: "oxicloud::dedup",
|
||||
event = "thumbnail.root_kept",
|
||||
job = job,
|
||||
run_id = run_id,
|
||||
reason = %e,
|
||||
"legacy sidecar directory could not be removed"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ThumbDerivedImport {
|
||||
thumbnails_root: PathBuf,
|
||||
dedup: Arc<DedupService>,
|
||||
}
|
||||
|
||||
impl ThumbDerivedImport {
|
||||
pub fn new(thumbnails_root: PathBuf, dedup: Arc<DedupService>) -> Self {
|
||||
Self {
|
||||
thumbnails_root,
|
||||
dedup,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_recoverable_job(
|
||||
self: Arc<Self>,
|
||||
registry: &JobRegistry,
|
||||
provider: &Arc<dyn JobStoreProvider>,
|
||||
) -> Arc<Self> {
|
||||
// Daily tick rather than manual-only. Ops cannot be relied on to
|
||||
// remember a migration, and boot-time would delay readiness for a
|
||||
// filesystem walk — whereas this is idempotent and resumable, so
|
||||
// periodic is safe and it drains on its own.
|
||||
//
|
||||
// The tick does NOT delete: `repair` defaults false, so scheduled
|
||||
// runs import and stop. Deletion stays a deliberate operator action,
|
||||
// per no-silent-auto-repair. Once drained, a run is a `read_dir` over
|
||||
// three directories that returns nothing — and after the directory is
|
||||
// removed, not even that.
|
||||
// On-demand, NOT periodic.
|
||||
//
|
||||
// `OXICLOUD_STARTUP_JOBS` runs this at boot in repair mode, and that
|
||||
// is the whole migration: nothing has written a sidecar since step
|
||||
// 10d2, so the tail cannot grow after startup. A daily tick could
|
||||
// only ever redo work the boot run already did — and it would do it
|
||||
// WITHOUT repair, so it could not even finish the job. Once drained
|
||||
// it is a `read_dir` returning nothing, every day, forever.
|
||||
//
|
||||
// The admin trigger remains for operators who want to re-run it by
|
||||
// hand, which is the case registration exists for.
|
||||
registry
|
||||
.register_recoverable_job(self.clone(), provider.clone(), None)
|
||||
.await;
|
||||
self
|
||||
}
|
||||
|
||||
/// The hash and format a sidecar filename names, or `None` when the file
|
||||
/// is not one of ours.
|
||||
///
|
||||
/// Strict, and deliberately rejects `ext-{file_id}.jpg`: those are
|
||||
/// user-supplied, file-keyed bytes. Importing them here would content-key
|
||||
/// them and share one user's uploaded preview onto every file with
|
||||
/// identical content — the poisoning `file_attached_blobs` exists to
|
||||
/// prevent. They belong to `thumb_attached_import`. That rejection
|
||||
/// carries the weight now that `.jpg` is otherwise claimed, since the two
|
||||
/// jobs would otherwise both want it.
|
||||
///
|
||||
/// Returns the format too, because the row
|
||||
/// key needs both since migration `20261022000000`.
|
||||
///
|
||||
/// Both codecs are claimed. `persist_rendered` writes
|
||||
/// `{hash}.{format.ext()}`, so any client that does not advertise WebP
|
||||
/// leaves `{hash}.jpg` on disk. While the derived tier was WebP-only
|
||||
/// those were unmigratable by design; now that `variant` carries the
|
||||
/// format they are ordinary content, and skipping them would leave
|
||||
/// `.thumbnails/` permanently non-empty — which is the signal step 10e
|
||||
/// gates the fallback removal on.
|
||||
fn hash_from_sidecar_name(name: &str) -> Option<(&str, ThumbnailFormat)> {
|
||||
let (stem, format) = ThumbnailFormat::ALL
|
||||
.iter()
|
||||
.find_map(|f| name.strip_suffix(&format!(".{}", f.ext())).map(|s| (s, *f)))?;
|
||||
if stem.len() != 64 || !stem.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
Some((stem, format))
|
||||
}
|
||||
|
||||
/// Delete a sidecar, but only after proving the blob that replaced it can
|
||||
/// actually be read back.
|
||||
///
|
||||
/// The verification is the whole point. `store_derived_blob` reporting
|
||||
/// success is not proof the bytes are retrievable — a backend that
|
||||
/// accepted a write it cannot serve would otherwise have the last copy
|
||||
/// deleted on top of it. This is a migration, and the difference between
|
||||
/// a migration and a data-loss bug is exactly this read.
|
||||
///
|
||||
/// Length is compared rather than full bytes: it catches the realistic
|
||||
/// failures (absent, empty, truncated) without a second full read of the
|
||||
/// sidecar, which the already-imported path would otherwise need.
|
||||
///
|
||||
/// Returns whether the file was removed. A failed verification leaves the
|
||||
/// sidecar in place — the run reports it and the next one retries, which
|
||||
/// is the safe direction.
|
||||
/// Shared with `thumb_attached_import` rather than copied into it: both
|
||||
/// jobs delete a sidecar only after proving its replacement is readable,
|
||||
/// and two copies of that rule would be two chances to weaken one.
|
||||
pub(crate) async fn verify_and_unlink(
|
||||
dedup: &DedupService,
|
||||
job: &str,
|
||||
owner: &str,
|
||||
stored_hash: &str,
|
||||
path: &std::path::Path,
|
||||
) -> bool {
|
||||
// Compare CONTENT, not length.
|
||||
//
|
||||
// This is the only thing standing between a storage bug and
|
||||
// permanent loss — `thumb_attached_import` deletes user-uploaded
|
||||
// previews that have no render path to rebuild them, and with the
|
||||
// startup-job default it does so on first boot after an upgrade,
|
||||
// in every deployment at once. A guard that load-bearing should
|
||||
// prove the bytes are the bytes.
|
||||
//
|
||||
// Length alone did not. A blob of the right size and the wrong
|
||||
// content passed: a key-mapping bug handing back another file's
|
||||
// preview at the same length would have deleted the original and
|
||||
// kept the impostor, and thumbnails cluster tightly enough in size
|
||||
// for that to be a real coincidence rather than a theoretical one.
|
||||
//
|
||||
// Re-reading the sidecar costs a few KB of I/O, once per file ever
|
||||
// migrated. The import path already has these bytes in hand, but
|
||||
// taking them as an argument would leave the already-imported path
|
||||
// (which has no bytes, only a file) on a weaker check — one code
|
||||
// path, one guarantee.
|
||||
let Ok(sidecar) = fs::read(path).await else {
|
||||
return false;
|
||||
};
|
||||
// `read_blob_bytes` streams from the backend, reassembling chunks
|
||||
// if the blob is chunked — no cache sits in front of it, so this
|
||||
// proves durability and not merely that a write was acknowledged.
|
||||
let Ok(stored) = dedup.read_blob_bytes(stored_hash).await else {
|
||||
return false;
|
||||
};
|
||||
if stored.is_empty() || stored.as_ref() != sidecar.as_slice() {
|
||||
return false;
|
||||
}
|
||||
if fs::remove_file(path).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
audit_sidecar_deleted(job, "imported", owner, stored_hash, path);
|
||||
true
|
||||
}
|
||||
|
||||
/// Sorted sidecar filenames for one size directory.
|
||||
///
|
||||
/// Sorted so the cursor is meaningful: resume skips everything at or
|
||||
/// before it, which only works over a stable order.
|
||||
///
|
||||
/// Takes the root rather than reading `self`, so the walk — the half that
|
||||
/// decides which files this job claims, and therefore which keying they
|
||||
/// get — is testable against a temp directory with no database in sight.
|
||||
pub(crate) async fn sidecar_names(root: &std::path::Path, size: ThumbnailSize) -> Vec<String> {
|
||||
let dir = root.join(size.dir_name());
|
||||
let Ok(mut entries) = fs::read_dir(&dir).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if let Some(name) = entry.file_name().to_str()
|
||||
&& Self::hash_from_sidecar_name(name).is_some()
|
||||
{
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecoverableJobHandler for ThumbDerivedImport {
|
||||
fn name(&self) -> &str {
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Migrates server-rendered thumbnails from the legacy .thumbnails/ \
|
||||
directory into content-addressed blob storage. Local-disk sidecars \
|
||||
are invisible to other instances and are not carried by a backend \
|
||||
migration; importing them is what lets that directory be deleted."
|
||||
}
|
||||
|
||||
/// `Always`: a plain run inserts rows and writes blobs. Repair-capable on
|
||||
/// top of that, which is why the two are independent.
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
fn repair_description(&self) -> Option<&'static str> {
|
||||
Some(
|
||||
"Also DELETES each sidecar once its replacement has been read \
|
||||
back from blob storage, and removes the directory when empty. \
|
||||
Files whose source no longer exists are deleted without a \
|
||||
readback — they cannot be imported and nothing can reference \
|
||||
them. Irreversible.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn count_total(&self) -> Option<u64> {
|
||||
let mut total = 0u64;
|
||||
for size in ThumbnailSize::all() {
|
||||
total += Self::sidecar_names(&self.thumbnails_root, *size)
|
||||
.await
|
||||
.len() as u64;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
async fn run_resumable(
|
||||
&self,
|
||||
store: &dyn JobStore,
|
||||
args: &JobRunArgs,
|
||||
resume_cursor: Option<Vec<u8>>,
|
||||
) -> RunOutcome {
|
||||
// `?repair=true` opts into deleting each sidecar once it has been
|
||||
// imported AND read back. Off by default, matching the house rule
|
||||
// that a job does not mutate on its default setting — early runs
|
||||
// import only, so an operator can inspect before committing.
|
||||
//
|
||||
// Deleting from the job rather than from a later release is what
|
||||
// makes the migration self-draining: sidecars are LOCAL disk, so no
|
||||
// release can know whether every instance has finished, whereas each
|
||||
// instance draining itself needs no coordination at all.
|
||||
let delete_imported = args.repair;
|
||||
// Cursor is `{size_dir}/{filename}` — the last file completed. Sizes
|
||||
// are walked in `ThumbnailSize::all()` order, and names are sorted
|
||||
// within each, so the pair totally orders the walk.
|
||||
let cursor: Option<String> = match resume_cursor {
|
||||
None => None,
|
||||
Some(b) if b.is_empty() => None,
|
||||
Some(b) => match String::from_utf8(b) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("invalid cursor: not valid UTF-8: {e}"),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut imported = 0u64;
|
||||
let mut already = 0u64;
|
||||
let mut failed = 0u64;
|
||||
let mut deleted = 0u64;
|
||||
let mut unverified = 0u64;
|
||||
let mut dead_source = 0u64;
|
||||
let mut since_checkpoint = 0usize;
|
||||
// The DIRECTORY is `{size}` on disk; the VARIANT is `{size}.{ext}`
|
||||
// since migration `20261022000000`. Conflating them is a real trap:
|
||||
// using the variant as a path yields `.thumbnails/preview.webp/…`,
|
||||
// which does not exist, so every file reads as unreadable and nothing
|
||||
// imports. The variant is therefore built per FILE, from the format
|
||||
// its extension names, not once per size.
|
||||
for size in ThumbnailSize::all() {
|
||||
let dir_name = size.dir_name(); // on-disk directory
|
||||
for name in Self::sidecar_names(&self.thumbnails_root, *size).await {
|
||||
// Cursor position uses the DIRECTORY, so a run paused before
|
||||
// this change resumes at the same place.
|
||||
let position = format!("{dir_name}/{name}");
|
||||
|
||||
// Resume: everything at or before the cursor is done.
|
||||
if let Some(c) = &cursor
|
||||
&& position.as_str() <= c.as_str()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
match store.status().await {
|
||||
Ok(RunStatus::CancelRequested) => {
|
||||
return RunOutcome::Paused {
|
||||
cursor: position.into_bytes(),
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return RunOutcome::Failed {
|
||||
message: format!("status poll: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let Some((hash, format)) = Self::hash_from_sidecar_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
// Both derived from the file's OWN extension, so a `.jpg`
|
||||
// sidecar becomes a JPEG row rather than being mislabelled
|
||||
// WebP — which would serve the wrong codec to anyone the read
|
||||
// path then matched it for.
|
||||
let variant = format!("{dir_name}.{}", format.ext());
|
||||
let content_type = format.mime();
|
||||
|
||||
// Already mapped — the common case on a re-run, and the
|
||||
// reason this job is safe to trigger repeatedly.
|
||||
//
|
||||
// Deletion applies here too, not just to fresh imports: a run
|
||||
// without `repair` leaves the sidecar behind, and a later run
|
||||
// with it would otherwise classify the file as "already
|
||||
// imported" and never drain it. Import-then-enable-deletion
|
||||
// is the expected operator sequence, so this is the common
|
||||
// path, not an edge case.
|
||||
if let Some(existing) = self
|
||||
.dedup
|
||||
.find_derived_blob(hash, "thumbnail", &variant)
|
||||
.await
|
||||
{
|
||||
already += 1;
|
||||
if delete_imported {
|
||||
let path = self.thumbnails_root.join(dir_name).join(&name);
|
||||
if Self::verify_and_unlink(
|
||||
&self.dedup,
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
hash,
|
||||
&existing.blob_hash,
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
deleted += 1;
|
||||
} else {
|
||||
unverified += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
"sidecar_delete_unverified",
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"hash": hash,
|
||||
"note": "derived blob did not read back; sidecar kept",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else if !self.dedup.blob_exists(hash).await {
|
||||
// The source is gone, so this sidecar cannot be imported:
|
||||
// a mapping to a dead source is precisely the orphan row
|
||||
// `store_derived_blob` now refuses, because nothing would
|
||||
// ever reap that hash again and the row would pin its
|
||||
// artifact forever.
|
||||
//
|
||||
// Checked BEFORE the read and the blob write, not after.
|
||||
// Without this the refusal still happens, but only once
|
||||
// the bytes have been stored — so every run writes a blob
|
||||
// and immediately deletes its manifest again, per dead
|
||||
// sidecar, forever. On a real install where `.thumbnails/`
|
||||
// has outlived years of deleted files, that is most of
|
||||
// them.
|
||||
//
|
||||
// It also matters for the tail: these files are
|
||||
// unimportable by definition, so a run that keeps
|
||||
// rediscovering them never reports zero and step 10e's
|
||||
// gate never opens. Under `repair` they are deleted —
|
||||
// safe, and the only unlink here that needs no readback,
|
||||
// since there is nothing to read back and nothing to
|
||||
// regenerate from.
|
||||
dead_source += 1;
|
||||
let mut removed = false;
|
||||
if delete_imported {
|
||||
let path = self.thumbnails_root.join(dir_name).join(&name);
|
||||
if fs::remove_file(&path).await.is_ok() {
|
||||
deleted += 1;
|
||||
removed = true;
|
||||
// Audited explicitly: this unlink bypasses
|
||||
// verify_and_unlink, which has nothing to verify
|
||||
// against here.
|
||||
audit_sidecar_deleted(
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
"source_gone",
|
||||
hash,
|
||||
"-",
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Recorded in BOTH modes. The finding used to be the
|
||||
// `else` of the deletion, so a repair run unlinked files
|
||||
// and reported a clean sweep — the audit stream held the
|
||||
// only trace, and the run drawer an operator actually
|
||||
// looks at said zero. A deletion is the outcome most
|
||||
// worth a finding, not least.
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
"sidecar_source_gone",
|
||||
// `anomaly` in both modes — it is what the panel
|
||||
// renders as "notices", and `detail.deleted` carries
|
||||
// whether the run left the sidecar alone or removed
|
||||
// it. A separate severity for the deleted case would
|
||||
// render identically and split one badge across two
|
||||
// values.
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"source_hash": hash,
|
||||
"deleted": removed,
|
||||
"note": if removed {
|
||||
"source Blob no longer exists; sidecar was unimportable and \
|
||||
has been deleted"
|
||||
} else {
|
||||
"source Blob no longer exists; the thumbnail is unimportable \
|
||||
and is deleted on a repair run"
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let path = self.thumbnails_root.join(dir_name).join(&name);
|
||||
match fs::read(&path).await {
|
||||
Ok(data) => {
|
||||
match self
|
||||
.dedup
|
||||
.store_derived_blob(
|
||||
hash,
|
||||
"thumbnail",
|
||||
&variant,
|
||||
content_type,
|
||||
Bytes::from(data),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(derived_hash) => {
|
||||
imported += 1;
|
||||
if delete_imported {
|
||||
if Self::verify_and_unlink(
|
||||
&self.dedup,
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
hash,
|
||||
&derived_hash,
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
deleted += 1;
|
||||
} else {
|
||||
unverified += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
"sidecar_delete_unverified",
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"hash": hash,
|
||||
"note": "derived blob did not read back; sidecar kept",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
"thumbnail_import_failed",
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"hash": hash,
|
||||
"error": format!("{e}"),
|
||||
"note": "sidecar left in place; safe to re-run",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Unreadable, or removed between listing and read
|
||||
// (a concurrent GC unlink). Neither is fatal.
|
||||
failed += 1;
|
||||
record_or_log(
|
||||
store,
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
"thumbnail_unreadable",
|
||||
"anomaly",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"path": position,
|
||||
"error": format!("{e}"),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
since_checkpoint += 1;
|
||||
if since_checkpoint >= BATCH_SIZE {
|
||||
if let Err(e) = store
|
||||
.checkpoint(position.clone().into_bytes(), since_checkpoint as u64)
|
||||
.await
|
||||
{
|
||||
return RunOutcome::Failed {
|
||||
message: format!("checkpoint: {e}"),
|
||||
};
|
||||
}
|
||||
since_checkpoint = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the size directories once genuinely empty, because ABSENCE
|
||||
// is what step 10e gates the fallback removal on — not emptiness.
|
||||
// Empty is momentary: an on-demand render can repopulate it the next
|
||||
// second. Absence is one-way, and far cheaper to test besides — one
|
||||
// `stat` versus an opendir/readdir/closedir.
|
||||
//
|
||||
// `remove_dir` refuses a non-empty directory, so this needs no
|
||||
// emptiness check of its own and cannot race a concurrent write into
|
||||
// deleting live files.
|
||||
if delete_imported {
|
||||
teardown_if_drained(
|
||||
&self.thumbnails_root,
|
||||
THUMB_DERIVED_IMPORT_JOB_NAME,
|
||||
&store.run_id().to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "oxicloud::dedup",
|
||||
event = "thumb_derived_import.completed",
|
||||
run_id = %store.run_id(),
|
||||
imported = imported,
|
||||
already_present = already,
|
||||
failed = failed,
|
||||
deleted = deleted,
|
||||
unverified = unverified,
|
||||
dead_source = dead_source,
|
||||
"thumb_derived_import: {imported} imported, {already} already present, \
|
||||
{failed} failed, {deleted} sidecar(s) deleted, {unverified} kept unverified, \
|
||||
{dead_source} skipped (source gone)"
|
||||
);
|
||||
|
||||
// Surfaced on the run row, not just in the process log. A repair run
|
||||
// that unlinks hundreds of files while reporting only a finding
|
||||
// total tells an operator nothing about what it did with them.
|
||||
RunOutcome::completed_with(serde_json::json!({
|
||||
"imported": imported,
|
||||
"already_present": already,
|
||||
"deleted": deleted,
|
||||
"unverified": unverified,
|
||||
"dead_source": dead_source,
|
||||
"failed": failed,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// `pub(crate)` so the attached import's test can reuse `legacy_tree`. Both
|
||||
// jobs walk ONE directory, so the property worth asserting spans them — that
|
||||
// together they claim every sidecar exactly once — and that needs a shared
|
||||
// fixture rather than two that can drift apart.
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
const H: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9";
|
||||
/// A second hash, for the JPEG sidecar in `legacy_tree`.
|
||||
const H2: &str = "c222222222222222222222222222222222222222222222222222222222222222";
|
||||
|
||||
/// The park path must be a SIBLING of `.thumbnails`, not a suffixed
|
||||
/// child of its name.
|
||||
///
|
||||
/// `Path::with_extension` looks right and is wrong here: a leading-dot
|
||||
/// name has no extension as far as `Path` is concerned — `.thumbnails`
|
||||
/// is entirely stem — so `with_extension("thumbnails.migrated")`
|
||||
/// yields `.thumbnails.thumbnails.migrated`. The rename would still
|
||||
/// have "worked", leaving a directory nobody documented and an
|
||||
/// operator hunting for the name the runbook promised.
|
||||
#[test]
|
||||
fn parked_directory_is_a_sibling_named_thumbnails_migrated() {
|
||||
let root = std::path::Path::new("/srv/storage/.thumbnails");
|
||||
assert_eq!(
|
||||
root.with_file_name(PARKED_DIR_NAME),
|
||||
std::path::Path::new("/srv/storage/.thumbnails.migrated"),
|
||||
);
|
||||
}
|
||||
|
||||
/// BOTH codecs are claimed, and the format comes from the extension.
|
||||
///
|
||||
/// `.jpg` was previously rejected here, which was correct only while the
|
||||
/// derived tier was WebP-only. Once `variant` carried the format
|
||||
/// (migration `20261022000000`) a JPEG sidecar became ordinary content,
|
||||
/// and leaving it unclaimed would keep `.thumbnails/` permanently
|
||||
/// non-empty — the very signal step 10e gates on.
|
||||
#[test]
|
||||
fn accepts_both_codecs_and_reports_the_format() {
|
||||
assert_eq!(
|
||||
ThumbDerivedImport::hash_from_sidecar_name(&format!("{H}.webp")),
|
||||
Some((H, ThumbnailFormat::Webp))
|
||||
);
|
||||
assert_eq!(
|
||||
ThumbDerivedImport::hash_from_sidecar_name(&format!("{H}.jpg")),
|
||||
Some((H, ThumbnailFormat::Jpeg)),
|
||||
"a JPEG sidecar must import, and as JPEG — labelling it WebP \
|
||||
would serve the wrong codec"
|
||||
);
|
||||
}
|
||||
|
||||
/// A legacy `.thumbnails` tree as it exists before the migration: both
|
||||
/// sidecar shapes side by side in the same size directory, which is
|
||||
/// exactly how they are written today.
|
||||
///
|
||||
/// Returns the temp dir — the caller must hold it, or the directory is
|
||||
/// removed while the test is still reading it.
|
||||
pub(crate) async fn legacy_tree() -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().expect("create temp dir");
|
||||
for size in ThumbnailSize::all() {
|
||||
let dir = tmp.path().join(size.dir_name());
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
// Server-rendered, content-keyed. `b` sorts after `0a…`, so the
|
||||
// pair also proves the listing is ordered rather than incidental.
|
||||
tokio::fs::write(dir.join(format!("{H}.webp")), b"webp")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
dir.join("b111111111111111111111111111111111111111111111111111111111111111.webp"),
|
||||
b"webp2",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// User-uploaded, file-keyed.
|
||||
tokio::fs::write(
|
||||
dir.join("ext-3f2b1c00-1111-2222-3333-444455556666.jpg"),
|
||||
b"jpeg",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Neither: a stray file that must be claimed by no one.
|
||||
// Server-rendered JPEG: what a client not advertising WebP
|
||||
// leaves behind. Claimed by the derived import, and must not be
|
||||
// confused with the `ext-` upload above despite sharing an
|
||||
// extension.
|
||||
tokio::fs::write(dir.join(format!("{H2}.jpg")), b"jpeg")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(dir.join("README.txt"), b"nope")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
tmp
|
||||
}
|
||||
|
||||
/// The migration's core invariant: this job claims the content-keyed
|
||||
/// sidecars and *only* those, leaving the uploaded previews for
|
||||
/// `thumb_attached_import`. Getting this wrong content-keys user-supplied
|
||||
/// bytes, which shares one user's preview onto every file with identical
|
||||
/// content.
|
||||
#[tokio::test]
|
||||
async fn walk_claims_only_content_keyed_sidecars_in_sorted_order() {
|
||||
let tmp = legacy_tree().await;
|
||||
let names = ThumbDerivedImport::sidecar_names(tmp.path(), ThumbnailSize::Preview).await;
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
format!("{H}.webp"),
|
||||
"b111111111111111111111111111111111111111111111111111111111111111.webp".to_string(),
|
||||
format!("{H2}.jpg"),
|
||||
],
|
||||
"must claim every content-keyed sidecar of EITHER codec, sorted, \
|
||||
and nothing else"
|
||||
);
|
||||
}
|
||||
|
||||
/// A missing size directory is normal on a fresh install and must not
|
||||
/// abort the walk — the job simply has nothing to import.
|
||||
#[tokio::test]
|
||||
async fn missing_size_directory_yields_no_work() {
|
||||
let tmp = tempfile::tempdir().expect("create temp dir");
|
||||
assert!(
|
||||
ThumbDerivedImport::sidecar_names(tmp.path(), ThumbnailSize::Icon)
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// `ext-` files are user-supplied and file-keyed. Importing one here
|
||||
/// would content-key it and share it across every file with identical
|
||||
/// content — the exact poisoning the table split prevents.
|
||||
#[test]
|
||||
fn rejects_external_and_malformed_names() {
|
||||
for name in [
|
||||
// `ext-` prefixed: user-supplied and file-keyed, whatever the
|
||||
// extension. Now that .jpg is otherwise claimed, this is the case
|
||||
// that keeps the two jobs disjoint.
|
||||
format!("ext-{H}.jpg"),
|
||||
"ext-3f2b1c00-0000-0000-0000-000000000000.jpg".to_string(),
|
||||
format!("{}.webp", &H[..63]),
|
||||
H.to_string(),
|
||||
"junk.webp".to_string(),
|
||||
"junk.jpg".to_string(),
|
||||
] {
|
||||
assert_eq!(
|
||||
ThumbDerivedImport::hash_from_sidecar_name(&name),
|
||||
None,
|
||||
"must not be imported as a derived thumbnail: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ use tracing::{debug, error, info, instrument};
|
||||
use crate::common::errors::Result;
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs, Mutates};
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -177,6 +177,17 @@ impl JobHandler for TrashCleanupService {
|
||||
Self::JOB_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Permanently deletes trashed items past the retention window, then \
|
||||
runs a dedup GC sweep as its tail step to reclaim blobs the \
|
||||
deletions dropped to zero references. This is the periodic tick \
|
||||
that keeps storage bounded."
|
||||
}
|
||||
|
||||
fn mutates(&self) -> Mutates {
|
||||
Mutates::Always
|
||||
}
|
||||
|
||||
/// Runs one bulk-delete-expired + GC sweep. `count` on the returned
|
||||
/// `JobOutcome::Ok` is the total number of rows this tick removed
|
||||
/// from the trash (files + folders); `extra` carries GC reclaim
|
||||
|
||||
@@ -2562,6 +2562,28 @@ pub async fn list_jobs(State(state): State<Arc<AppState>>) -> impl IntoResponse
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the jobs `OXICLOUD_STARTUP_JOBS` dispatches at boot. Without
|
||||
// this the panel is silently wrong about the most consequential thing
|
||||
// on the row: a job configured with `repair=true` deletes files on
|
||||
// every restart, and the row would suggest that only ever happens
|
||||
// when someone clicks Run.
|
||||
for job in summary.iter_mut() {
|
||||
if let Some(configured) = state
|
||||
.core
|
||||
.config
|
||||
.startup_jobs
|
||||
.iter()
|
||||
.find(|s| s.name == job.name)
|
||||
{
|
||||
job.startup = Some(crate::infrastructure::scheduler::StartupTrigger {
|
||||
force: configured.args.force,
|
||||
deep: configured.args.deep,
|
||||
repair: configured.args.repair,
|
||||
storage: configured.args.storage.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(summary)).into_response()
|
||||
}
|
||||
|
||||
|
||||
@@ -393,21 +393,57 @@ impl FileHandler {
|
||||
// THUMBNAILS
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Cache policy for every thumbnail response.
|
||||
///
|
||||
/// **`private`**, because a thumbnail is authorization-gated: the handler
|
||||
/// runs a `Permission::Read` check before serving it. `public` let any
|
||||
/// shared cache — a corporate proxy, a CDN — store one user's thumbnail
|
||||
/// and hand it to another. `Vary: Accept` did not help, because it does
|
||||
/// not vary on `Authorization`.
|
||||
///
|
||||
/// **`no-cache`**, not `immutable`, because this URL is keyed by file id
|
||||
/// and its bytes are mutable: uploading a preview, replacing the file's
|
||||
/// content, or removing an attachment all change what it serves.
|
||||
/// `immutable` promises the opposite, so a client that fetched once would
|
||||
/// not revalidate — for a year, under the previous `max-age` — and would
|
||||
/// never see a new preview. That also made the content-keyed ETag
|
||||
/// unobservable in a browser: a correct validator is worthless if nothing
|
||||
/// asks.
|
||||
///
|
||||
/// `no-cache` still stores the body; it only requires revalidation before
|
||||
/// reuse, which the ETag answers with a body-less 304.
|
||||
///
|
||||
/// The cost is a conditional request per thumbnail per page load. Buying
|
||||
/// that back needs a content-addressed URL, where `immutable` would be
|
||||
/// honest — but the hash would then be in the URL of an authorized
|
||||
/// resource, so it stays `private` regardless. Separate change; it
|
||||
/// touches the SPA and the file DTO.
|
||||
/// Shared with the NextCloud preview endpoint, which is gated the same
|
||||
/// way and must not drift from this policy.
|
||||
pub(crate) const THUMBNAIL_CACHE_CONTROL: &'static str = "private, no-cache";
|
||||
|
||||
/// Get a thumbnail for a file (image or video).
|
||||
///
|
||||
/// **Cache-first**: if the thumbnail already exists in the moka in-memory
|
||||
/// cache or on disk, serve it immediately — **zero DB queries**. The
|
||||
/// ownership check was already performed when the thumbnail was first
|
||||
/// generated (at upload) or uploaded (PUT by the owner). UUIDv4 file IDs
|
||||
/// have 122 bits of entropy, making enumeration infeasible.
|
||||
/// **Cache-first**: once past the hash lookup below, a thumbnail already
|
||||
/// in the moka in-memory cache or on disk is served without further DB
|
||||
/// work. The ownership check was already performed when the thumbnail
|
||||
/// was first generated (at upload) or uploaded (PUT by the owner).
|
||||
/// UUIDv4 file IDs have 122 bits of entropy, making enumeration
|
||||
/// infeasible.
|
||||
///
|
||||
/// **ETag / 304**: responses carry an immutable ETag. If the browser
|
||||
/// sends `If-None-Match` matching the ETag, we return 304 Not Modified
|
||||
/// without touching cache or DB — pure header round-trip.
|
||||
/// **ETag / 304**: the ETag names the **blob actually served** — an
|
||||
/// uploaded preview's hash, else a derived thumbnail's, else the
|
||||
/// source-keyed form (see `ThumbnailService::thumbnail_content_id`). So
|
||||
/// replacing content or uploading a preview invalidates correctly, and
|
||||
/// two files serving identical bytes share a validator. Costs one or two
|
||||
/// indexed lookups on the 304 path, which an id-keyed ETag avoided at the
|
||||
/// price of never invalidating. Cache policy is
|
||||
/// [`Self::THUMBNAIL_CACHE_CONTROL`] — `private, no-cache`, since this
|
||||
/// URL is authorization-gated and its bytes are mutable.
|
||||
///
|
||||
/// The DB path is only taken on a **cache miss for images** where the
|
||||
/// thumbnail hasn't been generated yet (first access after upload if
|
||||
/// background generation hasn't finished).
|
||||
/// Beyond that, the DB path is only taken on a **cache miss for images**
|
||||
/// where the thumbnail hasn't been generated yet (first access after
|
||||
/// upload if background generation hasn't finished).
|
||||
pub(super) async fn get_thumbnail_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
@@ -449,23 +485,52 @@ impl FileHandler {
|
||||
let format =
|
||||
ThumbnailFormat::from_accept(headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()));
|
||||
|
||||
// ── ETag short-circuit (Solution C) ──────────────────────────
|
||||
// Thumbnails are immutable — the ETag never changes for a given
|
||||
// (file_id, size, format) triple. If the browser already has it, return
|
||||
// 304 with zero I/O or DB work. Format is in the ETag so a client that
|
||||
// switched codecs doesn't get a stale 304.
|
||||
let etag = {
|
||||
let (s, f) = (thumb_size.as_str(), format.as_str());
|
||||
let mut e = String::with_capacity(9 + id.len() + s.len() + f.len());
|
||||
e.push_str("\"thumb-");
|
||||
e.push_str(&id);
|
||||
e.push('-');
|
||||
e.push_str(s);
|
||||
e.push('-');
|
||||
e.push_str(f);
|
||||
e.push('"');
|
||||
e
|
||||
// ── ETag short-circuit ───────────────────────────────────────
|
||||
// Keyed on the CONTENT served, not the file id.
|
||||
//
|
||||
// Keying on `file_id` was wrong in both directions. Replacing a
|
||||
// file's content preserves its id (`file_upload_service` rebuilds the
|
||||
// entity with `parts.id` and a new hash, then fires
|
||||
// `on_file_updated`, which regenerates the thumbnails), so the ETag
|
||||
// never changed — and the response was `immutable` with a one-year
|
||||
// max-age, so clients never revalidated and kept the old preview.
|
||||
// Conversely a copy, or any dedup twin, got a *different* id and so
|
||||
// refetched bytes it already held, even though the server serves both
|
||||
// from the same derived blob.
|
||||
//
|
||||
// Cost: one PK lookup, where the id-keyed version needed none. It
|
||||
// buys correct invalidation plus 304s shared across every file with
|
||||
// the same content. The lookup runs after the authz check above,
|
||||
// which has already hit the database.
|
||||
//
|
||||
// No new disclosure: `content_hash` is already on `FileDto` and
|
||||
// returned by `GET /api/files/{id}`, so any caller who reaches here
|
||||
// could read it anyway.
|
||||
let blob_hash = match state
|
||||
.repositories
|
||||
.file_read_repository
|
||||
.get_blob_hash(&id)
|
||||
.await
|
||||
{
|
||||
Ok(h) => h,
|
||||
Err(err) => return AppError::from(err).into_response(),
|
||||
};
|
||||
// The identity of the bytes about to be served, resolved through the
|
||||
// same tier precedence the read path uses — an uploaded preview's own
|
||||
// hash, else a derived thumbnail's own hash, else the source-keyed
|
||||
// form. See `ThumbnailService::thumbnail_content_id`.
|
||||
let etag = format!(
|
||||
"\"{}\"",
|
||||
thumbnail_service
|
||||
.thumbnail_content_id(
|
||||
&id,
|
||||
&blob_hash,
|
||||
thumb_size.into(),
|
||||
format,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
);
|
||||
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
|
||||
&& let Ok(val) = if_none_match.to_str()
|
||||
&& (val == etag || val == "*")
|
||||
@@ -474,7 +539,7 @@ impl FileHandler {
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(header::VARY, header::ACCEPT.as_str())
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::CACHE_CONTROL, Self::THUMBNAIL_CACHE_CONTROL)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
.into_response();
|
||||
@@ -484,7 +549,15 @@ impl FileHandler {
|
||||
// Try moka (RAM) → disk before touching the database.
|
||||
// If the thumbnail exists it was authorized at creation time.
|
||||
if let Some(data) = thumbnail_service
|
||||
.get_cached_thumbnail(&id, None, thumb_size.into(), format)
|
||||
.get_cached_thumbnail(
|
||||
&id,
|
||||
// Already resolved for the ETag above — hand it over rather
|
||||
// than let the service look it up a second time.
|
||||
Some(&blob_hash),
|
||||
thumb_size.into(),
|
||||
format,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Response::builder()
|
||||
@@ -494,7 +567,7 @@ impl FileHandler {
|
||||
crate::common::mime_detect::thumbnail_content_type(&data),
|
||||
)
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::CACHE_CONTROL, Self::THUMBNAIL_CACHE_CONTROL)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(header::VARY, header::ACCEPT.as_str())
|
||||
.body(Body::from(data))
|
||||
@@ -528,20 +601,15 @@ impl FileHandler {
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Resolve the blob hash (content-addressable storage).
|
||||
let blob_hash = match state
|
||||
.repositories
|
||||
.file_read_repository
|
||||
.get_blob_hash(&id)
|
||||
.await
|
||||
{
|
||||
Ok(hash) => hash,
|
||||
Err(_) => {
|
||||
return AppError::internal_error("File blob not found").into_response();
|
||||
}
|
||||
};
|
||||
// `blob_hash` was resolved above to build the ETag — no second lookup.
|
||||
if let Some(data) = thumbnail_service
|
||||
.get_cached_thumbnail(&id, Some(&blob_hash), thumb_size.into(), format)
|
||||
.get_cached_thumbnail(
|
||||
&id,
|
||||
Some(&blob_hash),
|
||||
thumb_size.into(),
|
||||
format,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Response::builder()
|
||||
@@ -551,7 +619,7 @@ impl FileHandler {
|
||||
crate::common::mime_detect::thumbnail_content_type(&data),
|
||||
)
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::CACHE_CONTROL, Self::THUMBNAIL_CACHE_CONTROL)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(header::VARY, header::ACCEPT.as_str())
|
||||
.body(Body::from(data))
|
||||
@@ -572,6 +640,7 @@ impl FileHandler {
|
||||
Some(&blob_hash),
|
||||
thumb_size.into(),
|
||||
ThumbnailFormat::Webp,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -582,7 +651,7 @@ impl FileHandler {
|
||||
crate::common::mime_detect::thumbnail_content_type(&data),
|
||||
)
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::CACHE_CONTROL, Self::THUMBNAIL_CACHE_CONTROL)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(header::VARY, header::ACCEPT.as_str())
|
||||
.body(Body::from(data))
|
||||
@@ -614,7 +683,7 @@ impl FileHandler {
|
||||
crate::common::mime_detect::thumbnail_content_type(&data),
|
||||
)
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::CACHE_CONTROL, Self::THUMBNAIL_CACHE_CONTROL)
|
||||
.header(header::ETAG, &etag)
|
||||
.header(header::VARY, header::ACCEPT.as_str())
|
||||
.body(Body::from(data))
|
||||
@@ -689,15 +758,71 @@ impl FileHandler {
|
||||
return AppError::from(err).into_response();
|
||||
}
|
||||
|
||||
// Validate, re-encode to WebP, and store
|
||||
match thumbnail_service
|
||||
// Validate, re-encode, and store the per-file sidecar.
|
||||
let stored = match thumbnail_service
|
||||
.store_external_thumbnail(&id, thumb_size.into(), body)
|
||||
.await
|
||||
{
|
||||
Ok(_) => StatusCode::CREATED.into_response(),
|
||||
Err(err) => AppError::internal_error(format!("Failed to store thumbnail: {}", err))
|
||||
.into_response(),
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
return AppError::internal_error(format!("Failed to store thumbnail: {}", err))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Also record it as a file-keyed attachment.
|
||||
//
|
||||
// The sidecar above is `ext-{file_id}.jpg` on local disk, which no
|
||||
// copy path duplicates and no other instance can see. Without this
|
||||
// row a copied file loses the preview its owner uploaded — falling
|
||||
// back to a rendered thumbnail, or to nothing at all for a PDF, which
|
||||
// has no server-side render path. `copy_file_satellites` duplicates
|
||||
// the row, so the copy inherits the bytes.
|
||||
//
|
||||
// File-keyed, never content-keyed: these bytes are the uploader's
|
||||
// claim about THIS file, and sharing them across files with identical
|
||||
// content is the poisoning vector `storage.file_attached_blobs`
|
||||
// exists to prevent.
|
||||
//
|
||||
// Best-effort: the sidecar already succeeded, so the user has their
|
||||
// thumbnail. Failing the request here would report an error for an
|
||||
// operation that visibly worked.
|
||||
if let Err(e) = state
|
||||
.core
|
||||
.dedup_service
|
||||
.store_attached_blob(
|
||||
&id,
|
||||
"preview",
|
||||
thumb_size.dir_name(),
|
||||
"image/jpeg",
|
||||
stored,
|
||||
auth_user.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// FATAL as of step 10d2, where it used to warn and return 201.
|
||||
//
|
||||
// That was safe only while `ext-{file_id}.jpg` existed as a
|
||||
// second copy. With the sidecar gone this is the ONLY durable
|
||||
// home for bytes that have no server-side render path — a
|
||||
// client-generated PDF preview cannot be recreated — so
|
||||
// succeeding here would lose a user's upload behind a success
|
||||
// response. Silent, and unrecoverable.
|
||||
//
|
||||
// The RAM entry is dropped too, or the cache would keep serving a
|
||||
// preview that was never persisted and vanishes on eviction,
|
||||
// contradicting the error the client just received.
|
||||
let _ = thumbnail_service.delete_thumbnails(&id).await;
|
||||
tracing::error!(
|
||||
target: "oxicloud::dedup",
|
||||
error = %e,
|
||||
file_id = %id,
|
||||
"failed to record attached thumbnail; upload rejected"
|
||||
);
|
||||
return AppError::internal_error("Failed to store thumbnail").into_response();
|
||||
}
|
||||
|
||||
StatusCode::CREATED.into_response()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -1437,7 +1562,7 @@ pub async fn list_files_query(
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/files/upload",
|
||||
request_body(content_type = "multipart/form-data", description = "File data + optional folder_id field"),
|
||||
request_body(content_type = "multipart/form-data", description = "File data + folder_id (required: it determines the file's owner and drive)"),
|
||||
responses(
|
||||
(status = 201, description = "File uploaded", body = FileDto),
|
||||
(status = 400, description = "Invalid request"),
|
||||
|
||||
@@ -17,6 +17,9 @@ use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize};
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
// One definition of the thumbnail cache policy, shared with the REST
|
||||
// endpoint: both are Permission::Read gated, so both must stay `private`.
|
||||
use crate::interfaces::api::handlers::file_handler::FileHandler;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -137,31 +140,60 @@ pub async fn handle_preview(
|
||||
}
|
||||
};
|
||||
|
||||
// Conditional revalidation — the ETag is derived from (object id, size)
|
||||
// only, so it is computable right here, BEFORE the blob-hash query and
|
||||
// the thumbnail cache/disk read. NC clients revalidate gallery previews
|
||||
// constantly; the REST thumbnail endpoint has honoured `If-None-Match`
|
||||
// since PHOTOS-ETAG — this endpoint set an immutable ETag but never
|
||||
// compared it, so every revalidation re-ran the whole pipeline and
|
||||
// re-shipped the body (ROUND10). Authz already passed above; a 304
|
||||
// must never skip the Read check.
|
||||
let etag = {
|
||||
let s = thumb_size.as_str();
|
||||
let mut e = String::with_capacity(9 + object_id.len() + s.len());
|
||||
e.push_str("\"thumb-");
|
||||
e.push_str(&object_id);
|
||||
e.push('-');
|
||||
e.push_str(s);
|
||||
e.push('"');
|
||||
e
|
||||
// Conditional revalidation. NC clients revalidate gallery previews
|
||||
// constantly; this endpoint set an immutable ETag but never compared it,
|
||||
// so every revalidation re-ran the whole pipeline and re-shipped the body
|
||||
// (ROUND10). Authz already passed above; a 304 must never skip the Read
|
||||
// check.
|
||||
//
|
||||
// Keyed on the CONTENT of the bytes served, matching the REST thumbnail
|
||||
// endpoint. Keying on the object id meant replacing a file's content —
|
||||
// which preserves the id — left the validator unchanged, and the response
|
||||
// was `immutable` with a one-year max-age, so clients never revalidated
|
||||
// and showed the old preview indefinitely. Both halves are fixed: the
|
||||
// ETag names what is served (see `thumbnail_content_id`) and the policy
|
||||
// is `private, no-cache` (see `FileHandler::THUMBNAIL_CACHE_CONTROL`).
|
||||
//
|
||||
// This moves the blob-hash query ahead of the 304 rather than adding one:
|
||||
// the same lookup used to sit just below, on the path that renders.
|
||||
let blob_hash = match state
|
||||
.repositories
|
||||
.file_read_repository
|
||||
.get_blob_hash(&object_id)
|
||||
.await
|
||||
{
|
||||
Ok(hash) => hash,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File blob not found"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
// Same tier-precedence resolution as the REST endpoint: an uploaded
|
||||
// preview's own hash, else a derived thumbnail's own hash, else the
|
||||
// source-keyed form. NC pins JPEG, so that is the format asked for.
|
||||
let etag = format!(
|
||||
"\"{}\"",
|
||||
state
|
||||
.core
|
||||
.thumbnail_service
|
||||
.thumbnail_content_id(
|
||||
&object_id,
|
||||
&blob_hash,
|
||||
thumb_size.into(),
|
||||
ThumbnailFormat::Jpeg,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
);
|
||||
if let Some(inm) = req.headers().get(header::IF_NONE_MATCH)
|
||||
&& let Ok(client_etag) = inm.to_str()
|
||||
&& (client_etag == etag || client_etag == "*")
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::CACHE_CONTROL, FileHandler::THUMBNAIL_CACHE_CONTROL)
|
||||
.header(header::ETAG, etag)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
@@ -179,21 +211,7 @@ pub async fn handle_preview(
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Resolve the blob hash (content-addressable storage)
|
||||
let blob_hash = match state
|
||||
.repositories
|
||||
.file_read_repository
|
||||
.get_blob_hash(&object_id)
|
||||
.await
|
||||
{
|
||||
Ok(hash) => hash,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File blob not found"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
// `blob_hash` was resolved above to build the ETag.
|
||||
if let Some(data) = state
|
||||
.core
|
||||
.thumbnail_service
|
||||
@@ -204,6 +222,7 @@ pub async fn handle_preview(
|
||||
Some(&blob_hash),
|
||||
thumb_size.into(),
|
||||
ThumbnailFormat::Jpeg,
|
||||
Some(&state.core.dedup_service),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -211,7 +230,7 @@ pub async fn handle_preview(
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::CACHE_CONTROL, FileHandler::THUMBNAIL_CACHE_CONTROL)
|
||||
.header(header::ETAG, etag)
|
||||
.body(Body::from(data))
|
||||
.unwrap();
|
||||
@@ -236,7 +255,7 @@ pub async fn handle_preview(
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.header(header::CACHE_CONTROL, FileHandler::THUMBNAIL_CACHE_CONTROL)
|
||||
.header(header::ETAG, etag)
|
||||
.body(Body::from(data))
|
||||
.unwrap(),
|
||||
|
||||
@@ -113,6 +113,40 @@ jsonpath "$[*].name" contains "consistency_batch"
|
||||
jsonpath "$[*].name" contains "backend_migration"
|
||||
jsonpath "$[*].name" contains "backend_rotate"
|
||||
|
||||
# Job metadata — `description` / `mutates` / `repair_description`.
|
||||
# The admin panel keys the read-only badge and the repair toggle off
|
||||
# these, so a handler that stops declaring them degrades the UI
|
||||
# silently: a mutating job renders as safe to click, and a repair-
|
||||
# capable one loses its toggle entirely. That second failure is the
|
||||
# bug this replaced — a name-based allowlist in the panel that never
|
||||
# grew past the two refcount tenants, leaving the thumbnail imports
|
||||
# unrunnable in repair mode from the UI.
|
||||
#
|
||||
# Per-job pins use scalar equality on a single-match filter — NOT
|
||||
# `count`, which trips Hurl's "filter matched one item → scalar, not
|
||||
# list" quirk. See memory `hurl-jsonpath-filter-empty-result`.
|
||||
#
|
||||
# `mutates` is a closed enum the UI switches on, so all three wire
|
||||
# spellings are pinned; a rename would break the panel silently.
|
||||
jsonpath "$..mutates" contains "never"
|
||||
jsonpath "$..mutates" contains "always"
|
||||
jsonpath "$..mutates" contains "on_repair_only"
|
||||
# Read-only tenant — safe to trigger, earns the read-only badge.
|
||||
jsonpath "$[?(@.name=='files_consistency')].mutates" == "never"
|
||||
# Repairs refcounts under ?repair=true, read-only otherwise.
|
||||
jsonpath "$[?(@.name=='blobs_consistency')].mutates" == "on_repair_only"
|
||||
# Destructive on a plain run AND repair-capable — the combination a
|
||||
# boolean could not express, and the reason `Mutates` has three values
|
||||
# rather than two.
|
||||
jsonpath "$[?(@.name=='thumb_derived_import')].mutates" == "always"
|
||||
# Floors, not totals: every job registered today declares a
|
||||
# description, and five declare a repair arm (both imports, both
|
||||
# refcount tenants, consistency_batch). New tenants only push these
|
||||
# up. Registration itself already rejects `on_repair_only` without a
|
||||
# repair_description, so the contradictory pairing can't reach here.
|
||||
jsonpath "$..description" count >= 10
|
||||
jsonpath "$..repair_description" count >= 5
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Trigger `trash_cleanup`. Envelope shape:
|
||||
@@ -225,13 +259,22 @@ jsonpath "$.outcome.count" exists
|
||||
# Step 4c — Trigger `consistency_batch`. Coordinator (plain
|
||||
# JobHandler) — snapshots the registry, filters names
|
||||
# ending `_consistency`, sequentially triggers each.
|
||||
# `outcome.count` = number of children dispatched (6 as
|
||||
# of the refcount_cascade fix: drives + folders +
|
||||
# files + blobs + manifests + backend). `extra.per_check`
|
||||
# carries a per-child outcome
|
||||
# map. Batch itself always returns ok — child failures
|
||||
# live inside per_check. `?deep=true` propagates as
|
||||
# `extra.deep`.
|
||||
# `extra.per_check` carries a per-child outcome map. Batch
|
||||
# itself always returns ok — child failures live inside
|
||||
# per_check. `?deep=true` propagates as `extra.deep`.
|
||||
#
|
||||
# NO assertion on `outcome.count`. The batch auto-discovers
|
||||
# tenants via `.ends_with("_consistency")`, so a hardcoded
|
||||
# total breaks every time one is added — it broke on
|
||||
# `manifests_consistency` and again on
|
||||
# `satellites_consistency`, each time asserting arithmetic
|
||||
# rather than behaviour. Per the house rule: `contains` per
|
||||
# item, never a total.
|
||||
#
|
||||
# What matters is that every child SUCCEEDED, which
|
||||
# `err == 0` states directly and without a magic number, plus
|
||||
# a named check per tenant below so a job silently dropping
|
||||
# out of the batch is still caught.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/jobs/consistency_batch/trigger?deep=true
|
||||
Authorization: Bearer {{admin_token}}
|
||||
@@ -240,9 +283,9 @@ HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.ok" == true
|
||||
jsonpath "$.outcome.outcome" == "ok"
|
||||
jsonpath "$.outcome.count" == 6
|
||||
jsonpath "$.outcome.extra.deep" == true
|
||||
jsonpath "$.outcome.extra.ok" == 6
|
||||
# Zero failures, whatever the tenant count happens to be. `ok` is not
|
||||
# asserted against a number for the same reason `count` is not.
|
||||
jsonpath "$.outcome.extra.err" == 0
|
||||
# per_check is keyed by child job name. `manifests_consistency` was added
|
||||
# by the refcount_cascade fix — see docs/plan/derived-blobs.md and
|
||||
@@ -255,6 +298,11 @@ jsonpath "$.outcome.extra.per_check.files_consistency.outcome" == "ok"
|
||||
jsonpath "$.outcome.extra.per_check.blobs_consistency.outcome" == "ok"
|
||||
jsonpath "$.outcome.extra.per_check.manifests_consistency.outcome" == "ok"
|
||||
jsonpath "$.outcome.extra.per_check.backend_consistency.outcome" == "ok"
|
||||
# Finds satellite mappings whose Blob is gone — the one class every
|
||||
# refcount-based check above reports as healthy, because the row holds a
|
||||
# valid reference with an exactly correct count while pinning an artifact
|
||||
# that can never be reclaimed.
|
||||
jsonpath "$.outcome.extra.per_check.satellites_consistency.outcome" == "ok"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# =============================================================
|
||||
# OxiCloud – An UPLOADED thumbnail survives both copy paths
|
||||
# =============================================================
|
||||
# A user-supplied preview is not derivable from the file's content, so
|
||||
# nothing can regenerate it. If a copy loses it, it is gone — and the loss
|
||||
# is silent, because the server quietly falls back to rendering one from
|
||||
# the source (or to 204 for a PDF, which has no render path at all).
|
||||
#
|
||||
# That was the behaviour before `storage.file_attached_blobs`: the PUT
|
||||
# wrote `ext-{file_id}.jpg`, keyed by file id, which no copy path
|
||||
# duplicates and no other instance can see.
|
||||
#
|
||||
# The test distinguishes "preserved" from "re-rendered" by making the two
|
||||
# visibly different: the FILE is red-image.png, the uploaded thumbnail is
|
||||
# derived from green-image.png. A server-side render of the file could
|
||||
# only ever produce the red one. So byte-equality with the post-upload
|
||||
# bytes proves the copy served the ATTACHMENT, not a fresh render.
|
||||
#
|
||||
# Step 4 is what makes that airtight — it captures the rendered thumbnail
|
||||
# BEFORE the upload and requires the upload to change it. Without that,
|
||||
# byte-equality across copies could be satisfied by three identical
|
||||
# renders.
|
||||
#
|
||||
# Prerequisites: setup.hurl must have run (admin user exists).
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 – Login
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{username}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 – Source and destination folders
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "hurl-attach-src"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
src_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "hurl-attach-dst"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
dst_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 – Upload the file (RED)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{src_folder_id}}
|
||||
file: file,fixtures/red-image.png; image/png
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
orig_file_id: jsonpath "$.id"
|
||||
orig_file_name: jsonpath "$.name"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 – The server-rendered thumbnail, before any upload.
|
||||
# Captured so the upload can be shown to have replaced it.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
rendered_thumb: bytes
|
||||
rendered_etag: header "ETag"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 – Upload a custom thumbnail (GREEN) for that file
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: image/png
|
||||
file,fixtures/green-image.png;
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# It must now serve the upload, not the render.
|
||||
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
uploaded_thumb: bytes
|
||||
uploaded_etag: header "ETag"
|
||||
[Asserts]
|
||||
bytes != {{rendered_thumb}}
|
||||
# The ETag must move with the bytes. It is keyed on the ATTACHED blob's own
|
||||
# hash, because uploading a preview leaves the file's content — and so a
|
||||
# source-keyed ETag — unchanged. With `immutable` set, an unchanged
|
||||
# validator means clients never revalidate and keep the old render for a
|
||||
# year.
|
||||
header "ETag" != "{{rendered_etag}}"
|
||||
|
||||
|
||||
# A client holding the pre-upload validator must be told to refetch.
|
||||
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
If-None-Match: {{rendered_etag}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "ETag" == "{{uploaded_etag}}"
|
||||
|
||||
|
||||
# ...and the new one revalidates.
|
||||
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
If-None-Match: {{uploaded_etag}}
|
||||
|
||||
HTTP 304
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 – Single-file copy → the attachment comes with it.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/batch/files/copy
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"file_ids": ["{{orig_file_id}}"],
|
||||
"target_folder_id": "{{dst_folder_id}}"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
file_copy_id: jsonpath "$.successful[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$.successful[0].id" != "{{orig_file_id}}"
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
bytes == {{uploaded_thumb}}
|
||||
bytes != {{rendered_thumb}}
|
||||
# Same bytes, so the same validator — the copy's attachment row points at
|
||||
# the same blob. This is also what stops the collision a source-keyed ETag
|
||||
# would allow: the copy inherits the source hash, so if either side later
|
||||
# gets a DIFFERENT preview the two would serve different bytes under one
|
||||
# ETag, and a shared cache could hand either to either.
|
||||
header "ETag" == "{{uploaded_etag}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 – Folder copy → same, through storage.copy_folder_tree.
|
||||
#
|
||||
# The other copy path. It reaches the attachment through the same
|
||||
# `copy_file_satellites` call, and this is the leg that would break if
|
||||
# the tree path ever grew its own fan-out again.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/batch/folders/copy
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"folder_ids": ["{{src_folder_id}}"],
|
||||
"target_folder_id": "{{dst_folder_id}}"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
tree_root_id: jsonpath "$.successful[0].new_root_folder_id"
|
||||
[Asserts]
|
||||
jsonpath "$.stats.failed" == 0
|
||||
|
||||
|
||||
GET {{base_url}}/api/files?folder_id={{tree_root_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
tree_copy_id: jsonpath "$[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].name" == "{{orig_file_name}}"
|
||||
jsonpath "$[0].id" != "{{orig_file_id}}"
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{tree_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
bytes == {{uploaded_thumb}}
|
||||
bytes != {{rendered_thumb}}
|
||||
header "ETag" == "{{uploaded_etag}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 – Delete the ORIGINAL, run GC, and require both copies to keep
|
||||
# serving the upload.
|
||||
#
|
||||
# Each copy holds its own reference on the attached blob — the rows are
|
||||
# duplicated, not shared, because the table is file-keyed. If the copy
|
||||
# had failed to take one, deleting the original would walk the count to
|
||||
# zero and GC would reap bytes that cannot be regenerated.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/files/{{orig_file_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/api/trash/resources
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
trash_orig_id: jsonpath "$.items[?(@.resource.id == '{{orig_file_id}}')].resource.id"
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/{{trash_orig_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
POST {{base_url}}/api/admin/jobs/dedup_gc/trigger
|
||||
Authorization: Bearer {{token}}
|
||||
[Options]
|
||||
delay: 500ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
bytes == {{uploaded_thumb}}
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{tree_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
bytes == {{uploaded_thumb}}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 – Teardown. Hurl files share one database within run.sh.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{src_folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{dst_folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/api/trash/resources
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
trash_src_id: jsonpath "$.items[?(@.resource.id == '{{src_folder_id}}')].resource.id"
|
||||
trash_dst_id: jsonpath "$.items[?(@.resource.id == '{{dst_folder_id}}')].resource.id"
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/{{trash_src_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/{{trash_dst_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
@@ -0,0 +1,418 @@
|
||||
# =============================================================
|
||||
# OxiCloud – Derived blobs survive a copy, and are SHARED not duplicated
|
||||
# =============================================================
|
||||
# Guards ONE property, the one that was actually broken:
|
||||
#
|
||||
# **A copy takes a real blob reference, via BOTH copy paths.**
|
||||
#
|
||||
# `storage.copy_file_satellites` (migration `20261019000000`) is the single
|
||||
# home for that, called by the single-file path and by
|
||||
# `storage.copy_folder_tree`. The tree path previously bumped
|
||||
# `storage.blobs` only — which matched nothing for a manifest-backed file,
|
||||
# so a folder copy took NO reference, and deleting the original reaped
|
||||
# bytes the copy still needed. Steps 6 and 9 assert the ref_count; step 11
|
||||
# purges the original, runs GC, and requires both copies to still serve.
|
||||
#
|
||||
# ── What this file does NOT prove, and why it cannot ─────────────────────
|
||||
#
|
||||
# It does not prove the copy SHARES the original's `content_derived_blobs`
|
||||
# row rather than getting its own. Two reasons, and neither is fixable by
|
||||
# adding assertions here:
|
||||
#
|
||||
# 1. Duplication is impossible by construction, so there is nothing to
|
||||
# catch. The PK is `(source_hash, kind, variant)` and a copy carries the
|
||||
# SAME `source_hash`, so a second INSERT conflicts — and
|
||||
# `store_derived_blob` is already `ON CONFLICT DO NOTHING`. The schema
|
||||
# enforces the property; no runtime behaviour can violate it.
|
||||
#
|
||||
# 2. Which tier served a thumbnail is invisible over HTTP. Stored derived
|
||||
# blob, moka RAM cache, and a fresh re-render all return identical bytes
|
||||
# with identical status — rendering is deterministic in the source bytes
|
||||
# and the variant. The copy is in fact a moka hit (that cache is keyed on
|
||||
# `(source_hash, size, format)`, which the copy shares), so it never
|
||||
# reaches the derived tier at all in this test.
|
||||
#
|
||||
# The `bytes ==` assertions below therefore establish that the pipeline is
|
||||
# deterministic and that the copies are readable — NOT that the derived
|
||||
# tier was consulted. Read-path tier selection is observable only from
|
||||
# inside the process, so it belongs in a Rust unit test over
|
||||
# `ThumbnailService::get_cached_thumbnail`, not here.
|
||||
#
|
||||
# By the same limitation, step 11 proves the SOURCE content survived GC. It
|
||||
# does not prove the derived blob survived: had GC reaped it, the server
|
||||
# would re-render from the still-alive source and still answer 200.
|
||||
#
|
||||
# Coverage note: `dedup-test.jpg` is single-chunk, so `file_hash` equals its
|
||||
# lone chunk's hash — the aliasing case whose `NOT EXISTS` guard stops one
|
||||
# reference being counted at both levels. The multi-chunk fan-out (where
|
||||
# file_hash names a manifest that is NOT a chunk) differs only in that the
|
||||
# hashes differ; it has no thumbnail-capable fixture at this size, so it is
|
||||
# covered at the SQL level rather than here.
|
||||
#
|
||||
# Prerequisites: setup.hurl must have run (admin user exists).
|
||||
#
|
||||
# Run:
|
||||
# hurl --variables-file tests/api/test.env --file-root tests \
|
||||
# --test tests/api/derived_blob_copy.hurl
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 – Login
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{username}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 – Source and destination folders
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "hurl-derived-src"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
src_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "hurl-derived-dst"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
dst_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 – Upload the source image
|
||||
#
|
||||
# `content_hash` is captured rather than hardcoded so the test does not
|
||||
# break if the fixture is ever regenerated.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{src_folder_id}}
|
||||
file: file,fixtures/dedup-test.jpg; image/jpeg
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
orig_file_id: jsonpath "$.id"
|
||||
orig_file_name: jsonpath "$.name"
|
||||
blob_hash: jsonpath "$.content_hash"
|
||||
[Asserts]
|
||||
jsonpath "$.content_hash" isString
|
||||
|
||||
|
||||
# One file holds the blob.
|
||||
GET {{base_url}}/api/dedup/check/{{blob_hash}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.exists" == true
|
||||
jsonpath "$.ref_count" == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 – Render the thumbnail. THIS is what creates the derived blob:
|
||||
# `content_derived_blobs(source_hash = blob_hash, 'thumbnail', …)`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
thumb_bytes: bytes
|
||||
# The JPEG validator, since every later request omits `Accept` and so
|
||||
# negotiates JPEG too. Captured here rather than after step 4b, or it
|
||||
# would belong to a different codec than the bytes beside it.
|
||||
thumb_etag: header "ETag"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4b – Codec negotiation: WebP and JPEG are separate artifacts.
|
||||
#
|
||||
# Every other request in the suite omits `Accept`, and curl defaults to
|
||||
# `*/*`, which `ThumbnailFormat::from_accept` maps to JPEG — so without
|
||||
# this case the WebP path is never exercised at all, despite being what
|
||||
# background generation writes and what the derived tier was built
|
||||
# around.
|
||||
#
|
||||
# The three assertions are one property each:
|
||||
#
|
||||
# Content-Type — negotiation actually happened (it is byte-sniffed
|
||||
# from the body, so it cannot be right by accident).
|
||||
# bytes — the two really are different artifacts.
|
||||
# ETag — the validators are distinct. `variant` carries the
|
||||
# format since migration 20261022000000; before that a
|
||||
# JPEG request could match the WebP row and be served
|
||||
# the wrong codec, and a shared validator is how a
|
||||
# cache would then hand either to either.
|
||||
#
|
||||
# Together they also cover the per-format variant keying that lets one
|
||||
# source hold both codecs — the prerequisite for JPEG clients ever
|
||||
# leaving the sidecar.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
Accept: image/webp
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
webp_bytes: bytes
|
||||
webp_etag: header "ETag"
|
||||
[Asserts]
|
||||
header "Content-Type" == "image/webp"
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
Accept: image/jpeg
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Type" == "image/jpeg"
|
||||
bytes != {{webp_bytes}}
|
||||
header "ETag" != "{{webp_etag}}"
|
||||
|
||||
|
||||
# Each codec revalidates against its OWN validator.
|
||||
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
Accept: image/webp
|
||||
If-None-Match: {{webp_etag}}
|
||||
|
||||
HTTP 304
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 – Single-file copy into the destination folder
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/batch/files/copy
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"file_ids": ["{{orig_file_id}}"],
|
||||
"target_folder_id": "{{dst_folder_id}}"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
file_copy_id: jsonpath "$.successful[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$.successful[0].id" != "{{orig_file_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 – The copy took a reference.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/dedup/check/{{blob_hash}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.exists" == true
|
||||
jsonpath "$.ref_count" == 2
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 – The copy is readable, renders the same bytes, and carries the
|
||||
# SAME ETag as the original.
|
||||
#
|
||||
# The ETag is keyed on the content hash, which the copy shares. Two
|
||||
# different files agreeing on an ETag is the one externally visible
|
||||
# consequence of content-keying — a file-id-keyed ETag could not produce
|
||||
# it. The 304 below is the payoff: a client that already holds the
|
||||
# original's thumbnail does not refetch it for the copy.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
bytes == {{thumb_bytes}}
|
||||
header "ETag" == "{{thumb_etag}}"
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
If-None-Match: {{thumb_etag}}
|
||||
|
||||
HTTP 304
|
||||
[Asserts]
|
||||
header "ETag" == "{{thumb_etag}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 – Folder copy — the OTHER copy path, through
|
||||
# `storage.copy_folder_tree` → `copy_file_satellites`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/batch/folders/copy
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"folder_ids": ["{{src_folder_id}}"],
|
||||
"target_folder_id": "{{dst_folder_id}}"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
tree_root_id: jsonpath "$.successful[0].new_root_folder_id"
|
||||
[Asserts]
|
||||
jsonpath "$.stats.failed" == 0
|
||||
|
||||
|
||||
GET {{base_url}}/api/files?folder_id={{tree_root_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
tree_copy_id: jsonpath "$[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].name" == "{{orig_file_name}}"
|
||||
jsonpath "$[0].id" != "{{orig_file_id}}"
|
||||
jsonpath "$[0].content_hash" == "{{blob_hash}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 – Three references now. Before `copy_file_satellites` the tree
|
||||
# path contributed nothing here and this stayed at 2.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/dedup/check/{{blob_hash}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.ref_count" == 3
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{tree_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
bytes == {{thumb_bytes}}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 – Permanently delete the ORIGINAL.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/files/{{orig_file_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/api/trash/resources
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
trash_orig_id: jsonpath "$.items[?(@.resource.id == '{{orig_file_id}}')].resource.id"
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/{{trash_orig_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# Two copies remain, so the content must too.
|
||||
GET {{base_url}}/api/dedup/check/{{blob_hash}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.exists" == true
|
||||
jsonpath "$.ref_count" == 2
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 – Run GC, then prove both copies still work.
|
||||
#
|
||||
# This is the assertion the whole file exists for. If either copy had
|
||||
# failed to take a reference, the original's deletion would have walked
|
||||
# the count to 0 and GC would have reaped the SOURCE CONTENT — leaving
|
||||
# these 5xx. That was a real, shipped bug on the folder-copy path.
|
||||
#
|
||||
# Scope: this proves the source content survived. It says nothing about
|
||||
# whether the derived blob survived, because a reaped derived blob is
|
||||
# re-rendered transparently from the live source. See the header.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/jobs/dedup_gc/trigger
|
||||
Authorization: Bearer {{token}}
|
||||
[Options]
|
||||
delay: 500ms
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
bytes == {{thumb_bytes}}
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{tree_copy_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
bytes == {{thumb_bytes}}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 – Teardown. Hurl files share one database within run.sh, so
|
||||
# everything created here must go, including from trash.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{src_folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{dst_folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/api/trash/resources
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
trash_src_id: jsonpath "$.items[?(@.resource.id == '{{src_folder_id}}')].resource.id"
|
||||
trash_dst_id: jsonpath "$.items[?(@.resource.id == '{{dst_folder_id}}')].resource.id"
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/{{trash_src_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/{{trash_dst_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
@@ -357,3 +357,25 @@ Authorization: Bearer {{token}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Type" startsWith "image/"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 23 – Upload without folder_id is a CLIENT error
|
||||
#
|
||||
# The destination folder determines the file's owner and drive, so the
|
||||
# field is required. It used to answer 500 / `error_type: Internal
|
||||
# Error`, which the SPA cannot distinguish from the server breaking — a
|
||||
# malformed request looked like an outage. The OpenAPI body description
|
||||
# called the field optional, which is how it came to be omitted.
|
||||
#
|
||||
# Asserts the status AND the error_type, because the contract the SPA
|
||||
# switches on is `error_type`, not the message.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{token}}
|
||||
[MultipartFormData]
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 400
|
||||
[Asserts]
|
||||
jsonpath "$.error_type" != "Internal Error"
|
||||
|
||||
@@ -166,6 +166,9 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/recent.hurl" \
|
||||
"$API_DIR/batch_folder_copy.hurl" \
|
||||
"$API_DIR/dedup_blob_cleanup.hurl" \
|
||||
"$API_DIR/derived_blob_copy.hurl" \
|
||||
"$API_DIR/thumbnail_etag_content_keyed.hurl" \
|
||||
"$API_DIR/attached_thumbnail_copy.hurl" \
|
||||
"$API_DIR/dedup_admin_gate.hurl" \
|
||||
"$API_DIR/admin_jobs.hurl" \
|
||||
"$API_DIR/recoverable_jobs.hurl" \
|
||||
@@ -240,6 +243,10 @@ if ! hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Migration check runs BEFORE the cleanup sweep, which deletes everything
|
||||
# it would otherwise need.
|
||||
bash "$API_DIR/thumb_import_check.sh"
|
||||
|
||||
bash "$API_DIR/storage_cleanup_check.sh"
|
||||
|
||||
# ── 5. OPAQUE crypto handshake — the parts Hurl can't drive ─────────────
|
||||
|
||||
@@ -18,6 +18,10 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
STORAGE_PATH="${OXICLOUD_STORAGE_PATH:-$REPO_ROOT/tests/api/storage}"
|
||||
# Needed by the leftover diagnosis below. Without it `docker compose -f ""`
|
||||
# fails and the `|| true` there swallows it, so the diagnosis silently prints
|
||||
# nothing and the failure looks exactly as uninformative as before.
|
||||
COMPOSE_FILE="$REPO_ROOT/tests/common/docker-compose.test.yml"
|
||||
|
||||
# shellcheck source=test.env
|
||||
source "$SCRIPT_DIR/test.env"
|
||||
@@ -61,8 +65,25 @@ HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -H "$AUTH" \
|
||||
log "Thumbnail fetched (HTTP 200)."
|
||||
|
||||
assert_local_blob_existsy "$FIXTURE" "$STORAGE_PATH" || fail "probe blob not found on disk"
|
||||
assert_preview_existsy "$FIXTURE" "$STORAGE_PATH" || fail "probe thumbnail not found on disk"
|
||||
log "Probe blob and thumbnail confirmed present on disk."
|
||||
|
||||
# The thumbnail must NOT be on disk — inverted at step 10d2, when the sidecar
|
||||
# write was removed.
|
||||
#
|
||||
# It used to assert the opposite, and that was right while `.thumbnails/` was
|
||||
# the durable store. Now the durable home is `content_derived_blobs` plus the
|
||||
# blob tier, and a sidecar reappearing here means a write path regressed to
|
||||
# the legacy shape — which would silently make `.thumbnails/` un-emptyable and
|
||||
# strand step 10e forever, since its gate is the directory being gone.
|
||||
#
|
||||
# The HTTP 200 above is what proves the thumbnail actually works; this proves
|
||||
# it got there the new way.
|
||||
# Both streams silenced: the helper reports absence loudly, with a red banner
|
||||
# and a `find` dump, because absence used to be the failure. Here it is the
|
||||
# expected result, so leaving that visible would cry wolf on every clean run.
|
||||
if assert_preview_existsy "$FIXTURE" "$STORAGE_PATH" >/dev/null 2>&1; then
|
||||
fail "a thumbnail sidecar was written — the legacy write path is back (step 10d2 removed it)"
|
||||
fi
|
||||
log "Probe blob on disk; thumbnail served from the derived tier, no sidecar written."
|
||||
|
||||
# ── 1c. Delete every non-admin user created by earlier Hurl tests ─────────────
|
||||
#
|
||||
@@ -287,11 +308,76 @@ curl -sf -X POST -H "$AUTH" "$base_url/api/admin/jobs/usage_reconcile/trigger" >
|
||||
|| fail "usage_reconcile trigger failed"
|
||||
log "Reconciliation sweep triggered."
|
||||
|
||||
GC_RESULT=$(curl -sf -X POST -H "$AUTH" "$base_url/api/admin/jobs/dedup_gc/trigger?force=true")
|
||||
[[ -z "$GC_RESULT" ]] && fail "trigger-gc returned an empty body"
|
||||
GC_BLOBS=$(echo "$GC_RESULT" | jq -r '.outcome.count')
|
||||
GC_BYTES=$(echo "$GC_RESULT" | jq -r '.outcome.extra.bytes_reclaimed')
|
||||
log "GC reaped $GC_BLOBS blob(s), $GC_BYTES byte(s) freed."
|
||||
# One GC pass is NOT enough, and this is by design rather than a bug.
|
||||
# Reaping a source blob releases the references its DERIVED artifacts hold
|
||||
# (thumbnails live in storage.content_derived_blobs and each row pins a
|
||||
# manifest). Those releases happen mid-sweep, so the derived chunks are
|
||||
# only stamped orphaned as the pass is already walking past them —
|
||||
# `remove_manifest_reference` deliberately does not unlink, to avoid racing
|
||||
# a concurrent upload re-referencing the same chunk. They become
|
||||
# collectible on the NEXT sweep.
|
||||
#
|
||||
# Loop until a pass reclaims nothing rather than hardcoding two passes.
|
||||
# Two is correct only while the derivation graph is one level deep — a
|
||||
# thumbnail is derived from a file and nothing is derived from a thumbnail.
|
||||
# That is a property of the data, not an invariant the code enforces, so a
|
||||
# fixed count would silently under-drain the day transcodes-of-thumbnails
|
||||
# or E2E-wrapped derivatives appear, and the failure would surface as a
|
||||
# confusing leftover-file assertion rather than as the design change it is.
|
||||
#
|
||||
# Production does NOT need this loop: derived chunks land inside the 1-hour
|
||||
# orphan grace, so a second immediate pass would collect nothing and the
|
||||
# next scheduled sweep picks them up. It is only `force=true` (grace 0)
|
||||
# that can drain a cascade in one go, which is exactly this test.
|
||||
GC_TOTAL_BLOBS=0
|
||||
GC_TOTAL_BYTES=0
|
||||
GC_DRAINED=0
|
||||
for gc_pass in 1 2 3 4; do
|
||||
GC_RESULT=$(curl -sf -X POST -H "$AUTH" "$base_url/api/admin/jobs/dedup_gc/trigger?force=true")
|
||||
[[ -z "$GC_RESULT" ]] && fail "trigger-gc returned an empty body (pass $gc_pass)"
|
||||
GC_BLOBS=$(echo "$GC_RESULT" | jq -r '.outcome.count // 0')
|
||||
GC_BYTES=$(echo "$GC_RESULT" | jq -r '.outcome.extra.bytes_reclaimed // 0')
|
||||
GC_TOTAL_BLOBS=$((GC_TOTAL_BLOBS + GC_BLOBS))
|
||||
GC_TOTAL_BYTES=$((GC_TOTAL_BYTES + GC_BYTES))
|
||||
log "GC pass $gc_pass reaped $GC_BLOBS blob(s), $GC_BYTES byte(s) freed."
|
||||
# Break on TWO consecutive zero passes, not one.
|
||||
#
|
||||
# A single zero only says nothing was collectible *at that instant*.
|
||||
# Releases cascade — reaping a source blob drops the references its
|
||||
# derived and attached rows held, and `on_blob_deleted` does that from
|
||||
# spawned tasks — so a pass can land in the gap between "source reaped"
|
||||
# and "dependents released" and report zero while work remains. The
|
||||
# import jobs added a level to that chain, which is when this started
|
||||
# biting.
|
||||
#
|
||||
# Cheap insurance: one extra trigger over an empty store, versus a
|
||||
# false pass that reports a clean disk while blobs remain.
|
||||
if [[ "$GC_BLOBS" -eq 0 ]]; then
|
||||
if [[ "${GC_ZERO_STREAK:-0}" -ge 1 ]]; then
|
||||
GC_DRAINED=1
|
||||
break
|
||||
fi
|
||||
GC_ZERO_STREAK=1
|
||||
else
|
||||
GC_ZERO_STREAK=0
|
||||
fi
|
||||
# Breathe before the next trigger, for two reasons:
|
||||
#
|
||||
# * The JobRegistry serialises runs of the same job. Firing the next
|
||||
# trigger before the previous run has fully unwound risks it being
|
||||
# rejected as already-running — which would come back as 0 reaped
|
||||
# and exit this loop early, declaring success with blobs still on
|
||||
# disk. A false pass is worse than a slow one.
|
||||
# * `on_blob_deleted` spawns detached unlink tasks that nothing
|
||||
# awaits, so some of the previous pass's disk work may still be in
|
||||
# flight.
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$GC_DRAINED" -ne 1 ]]; then
|
||||
log "WARNING: GC still reaping after 3 passes — the derivation graph may"
|
||||
log " be deeper than one level; raise the bound and check why."
|
||||
fi
|
||||
log "GC total: $GC_TOTAL_BLOBS blob(s), $GC_TOTAL_BYTES byte(s) freed."
|
||||
|
||||
# ── 4. Disk verification ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -343,6 +429,41 @@ fi
|
||||
|
||||
if [[ -n "$BLOB_FILES" ]]; then
|
||||
BLOB_COUNT=$(echo "$BLOB_FILES" | wc -l | tr -d ' ')
|
||||
|
||||
# Say WHY each one survived, not just that it did. A path alone cannot
|
||||
# distinguish the three causes, and they need opposite fixes: a positive
|
||||
# refcount means something still references it (a release was missed), an
|
||||
# orphan means GC never considered it (a reap predicate gap), and a row
|
||||
# with no manifest means the registry itself is inconsistent. Diagnosing
|
||||
# that by hand costs a round-trip through the whole suite.
|
||||
log "Diagnosing leftovers (refcounts and referrers):"
|
||||
while read -r f; do
|
||||
[[ -z "$f" ]] && continue
|
||||
h=$(basename "$f" .blob)
|
||||
docker compose -f "$COMPOSE_FILE" exec -T postgres-test \
|
||||
psql -U oxicloud_test -d oxicloud_test -tAqc "
|
||||
SELECT ' $h'
|
||||
|| ' manifest_refs=' || COALESCE((SELECT ref_count::text FROM storage.chunk_manifests WHERE file_hash='$h'), '-')
|
||||
|| ' blob_refs=' || COALESCE((SELECT ref_count::text FROM storage.blobs WHERE hash='$h'), '-')
|
||||
|| ' files=' || (SELECT count(*) FROM storage.files WHERE blob_hash='$h')
|
||||
|| ' derived=' || (SELECT count(*) FROM storage.content_derived_blobs WHERE blob_hash='$h')
|
||||
|| ' attached=' || (SELECT count(*) FROM storage.file_attached_blobs WHERE blob_hash='$h')
|
||||
-- When a derived row is what pins the blob, the question is
|
||||
-- why its SOURCE was never reaped — purge_derived_blobs only
|
||||
-- runs from the source's reap. Print the source and whether
|
||||
-- anything still holds it.
|
||||
|| COALESCE((SELECT ' src=' || d.source_hash
|
||||
|| ' src_files=' || (SELECT count(*) FROM storage.files WHERE blob_hash = d.source_hash)
|
||||
|| ' src_manifest=' || COALESCE((SELECT ref_count::text FROM storage.chunk_manifests WHERE file_hash = d.source_hash), '-')
|
||||
|| ' src_blob=' || COALESCE((SELECT ref_count::text FROM storage.blobs WHERE hash = d.source_hash), '-')
|
||||
FROM storage.content_derived_blobs d WHERE d.blob_hash='$h' LIMIT 1), '');" \
|
||||
< /dev/null \
|
||||
2> >(grep -v 'Executing external compose provider' >&2) || true
|
||||
# `< /dev/null`: `docker compose exec -T` reads stdin, and without this it
|
||||
# consumes the rest of the here-string — so only the FIRST leftover was
|
||||
# ever diagnosed and the others vanished silently.
|
||||
done <<< "$BLOB_FILES"
|
||||
|
||||
log "Leftover blob files ($BLOB_COUNT):"
|
||||
echo "$BLOB_FILES"
|
||||
fail "$BLOB_COUNT blob file(s) remain on disk after full cleanup"
|
||||
@@ -356,3 +477,122 @@ if [[ -n "$UPLOAD_FILES" ]]; then
|
||||
fi
|
||||
|
||||
log "OK — no blobs, thumbnails, or chunked-upload leftovers remain on disk."
|
||||
|
||||
# ── 4b. …and the registry agrees the store is empty ───────────────────────────
|
||||
#
|
||||
# The disk check above proves no BYTES are left. This proves no ROWS are,
|
||||
# which is the other direction and fails differently: a stale
|
||||
# `storage.blobs` row with nothing behind it means a reference was never
|
||||
# released, and the next `dedup_gc` will keep skipping it forever because
|
||||
# its count never reaches zero.
|
||||
#
|
||||
# Zero is the right assertion here, not "fewer than before". Everything the
|
||||
# suite created has been deleted by this point — the users, their drives,
|
||||
# and the cascade beneath them — and the disk check has already insisted the
|
||||
# blob store is empty. A non-zero registry alongside an empty disk is
|
||||
# precisely the divergence the consistency jobs below would report, caught
|
||||
# here first because a single number is easier to read than a findings list.
|
||||
STATS=$(curl -sf -H "$AUTH" "$base_url/api/admin/dedup/stats" || true)
|
||||
if [[ -z "$STATS" ]]; then
|
||||
log "WARNING: /api/admin/dedup/stats unavailable — registry emptiness not checked"
|
||||
else
|
||||
REMAINING_BLOBS=$(echo "$STATS" | jq -r '.unique_blobs // 0')
|
||||
REMAINING_BYTES=$(echo "$STATS" | jq -r '.total_physical_bytes // 0')
|
||||
if [[ "$REMAINING_BLOBS" -ne 0 ]]; then
|
||||
log "Registry still reports $REMAINING_BLOBS blob(s), $REMAINING_BYTES physical byte(s):"
|
||||
echo "$STATS" | jq -r 'to_entries[] | " \(.key): \(.value)"' 2>/dev/null | head
|
||||
fail "$REMAINING_BLOBS blob row(s) remain in the registry while the disk is empty"
|
||||
fi
|
||||
log "Registry clean: 0 blobs, 0 physical bytes."
|
||||
fi
|
||||
|
||||
# ── 5. Whole-suite consistency sweep ──────────────────────────────────────────
|
||||
#
|
||||
# The disk checks above prove nothing LEAKED. These prove the bookkeeping
|
||||
# behind it is honest — that every refcount matches what the reference
|
||||
# sources actually hold, and that no row points at bytes that are gone.
|
||||
#
|
||||
# End of suite is the right place, and the only place it is cheap. One
|
||||
# database serves every hurl file (which is why each must tear down after
|
||||
# itself), so by the time we get here the counters have absorbed every
|
||||
# upload, copy, move, share, trash and purge the suite performed — across
|
||||
# both copy paths, the derived tier and the attached tier. A drift that no
|
||||
# single test would notice, because each only inspects its own file, shows
|
||||
# up here as a mismatch.
|
||||
#
|
||||
# It runs AFTER the GC drain deliberately: mid-sweep state is legitimately
|
||||
# inconsistent (a manifest can sit at zero waiting for the next pass), so
|
||||
# checking before the drain would report normal in-flight state as drift.
|
||||
#
|
||||
# Zero findings is the assertion. These jobs are read-only, so a finding
|
||||
# here is a real invariant violation, not a repair opportunity.
|
||||
|
||||
# EVERY registered consistency tenant. Keep this list exhaustive: two of
|
||||
# these (drives, folders) were missing until 2026-08-28 and had never run
|
||||
# under test at all.
|
||||
CONSISTENCY_JOBS=(
|
||||
files_consistency
|
||||
folders_consistency
|
||||
drives_consistency
|
||||
blobs_consistency
|
||||
manifests_consistency
|
||||
backend_consistency
|
||||
# Catches what the others structurally cannot: a satellite mapping whose
|
||||
# source Blob is gone looks healthy to every refcount-based check — valid
|
||||
# reference, correct count, bytes present — while pinning its artifact
|
||||
# forever. That leak reached this suite as three unreclaimable blobs and
|
||||
# took four runs to identify.
|
||||
satellites_consistency
|
||||
)
|
||||
|
||||
CONSISTENCY_FAILED=0
|
||||
for job in "${CONSISTENCY_JOBS[@]}"; do
|
||||
# FAIL on an unknown job rather than warn-and-skip.
|
||||
#
|
||||
# The warning was there so a feature-gated build would not break, but the
|
||||
# cost is worse than the case it protects: renaming a job (or a typo)
|
||||
# silently removes it from the sweep, and the suite goes on reporting
|
||||
# green over a check that no longer runs. This list said
|
||||
# `derived_consistency` for exactly one commit after the rename and would
|
||||
# have skipped it without comment.
|
||||
TRIGGER=$(curl -sf -X POST -H "$AUTH" "$base_url/api/admin/jobs/$job/trigger") \
|
||||
|| fail "$job could not be triggered — renamed, unregistered, or a typo in CONSISTENCY_JOBS"
|
||||
[[ -z "$TRIGGER" ]] && fail "$job returned an empty body"
|
||||
|
||||
# The trigger is synchronous for these tenants, but the run row is what
|
||||
# carries the findings, so read it back rather than trusting the
|
||||
# trigger's own summary.
|
||||
# `list_job_runs` returns a bare JSON array, newest first. The `.runs` /
|
||||
# `.items` fallbacks are there so a future wrapping of the response does
|
||||
# not silently turn this check into a no-op.
|
||||
RUN_ID=$(curl -sf -H "$AUTH" "$base_url/api/admin/jobs/$job/runs?limit=1" \
|
||||
| jq -r 'if type == "array" then .[0].id
|
||||
else ((.runs // .items // [])[0].id) end // empty')
|
||||
if [[ -z "$RUN_ID" ]]; then
|
||||
log "WARNING: could not resolve a run id for $job — skipped"
|
||||
continue
|
||||
fi
|
||||
|
||||
FINDINGS=$(curl -sf -H "$AUTH" \
|
||||
"$base_url/api/admin/jobs/$job/runs/$RUN_ID/findings?limit=100")
|
||||
COUNT=$(echo "$FINDINGS" | jq -r \
|
||||
'if type == "array" then length
|
||||
else ((.findings // .items // []) | length) end')
|
||||
|
||||
if [[ "$COUNT" -gt 0 ]]; then
|
||||
log "$job reported $COUNT finding(s):"
|
||||
echo "$FINDINGS" | jq -r \
|
||||
'if type == "array" then .[] else (.findings // .items // [])[] end
|
||||
| " \(.severity // "?") \(.kind // .finding_kind // "?") \(.details // {} | tostring)"' \
|
||||
2>/dev/null | head -20
|
||||
CONSISTENCY_FAILED=1
|
||||
else
|
||||
log "$job: clean."
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$CONSISTENCY_FAILED" -eq 1 ]]; then
|
||||
fail "consistency jobs reported findings after the full suite — see above"
|
||||
fi
|
||||
|
||||
log "OK — all consistency jobs clean after the full suite."
|
||||
|
||||
Executable
+336
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud – legacy sidecar → blob migration (both import jobs)
|
||||
# =============================================================
|
||||
# Exercises `thumb_derived_import` and `thumb_attached_import` end to end,
|
||||
# which nothing else does: their unit tests cover only the directory walk,
|
||||
# never a run.
|
||||
#
|
||||
# ── How legacy state is manufactured ─────────────────────────────────────
|
||||
#
|
||||
# The test environment always starts fresh, so there is no pre-migration
|
||||
# data to import. We create it, and the reconstruction is EXACT rather than
|
||||
# an imitation: the on-disk layout did not change in this work. A
|
||||
# server-rendered thumbnail has always been written to
|
||||
# `{size}/{hash}.webp`, and an uploaded preview to `{size}/ext-{id}.jpg`.
|
||||
# The only thing that is new is the DB row.
|
||||
#
|
||||
# So: upload through the real API (which writes both the file and the row),
|
||||
# then delete the row. What remains on disk is byte-for-byte what a
|
||||
# pre-migration install has.
|
||||
#
|
||||
# Deleting the row must also release the reference it held, or the
|
||||
# manufactured state would carry a reference no legacy install ever had and
|
||||
# the end-of-suite registry check would report a leak that this script
|
||||
# caused. `file_attached_blobs` has an ON DELETE trigger that does it;
|
||||
# `content_derived_blobs` does not, so we decrement explicitly.
|
||||
#
|
||||
# ── What is asserted ─────────────────────────────────────────────────────
|
||||
#
|
||||
# 1. Both rows come back after the import.
|
||||
# 2. The uploaded preview survives a COPY — the user-visible point of
|
||||
# `file_attached_blobs`, and impossible before the row existed.
|
||||
# 3. Re-running imports nothing and changes no refcount. This is the
|
||||
# defect most likely to be silent: `store_attached_blob` is
|
||||
# ON CONFLICT DO UPDATE, so an import that skipped its existence
|
||||
# check would release and retake a reference on every run.
|
||||
#
|
||||
# Runs BEFORE storage_cleanup_check.sh, which deletes everything.
|
||||
#
|
||||
# Prerequisites: setup.hurl has run (admin exists); docker compose db up.
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
COMPOSE_FILE="$REPO_ROOT/tests/common/docker-compose.test.yml"
|
||||
STORAGE_PATH="${OXICLOUD_STORAGE_PATH:-$REPO_ROOT/tests/api/storage}"
|
||||
|
||||
# shellcheck source=test.env
|
||||
source "$SCRIPT_DIR/test.env"
|
||||
|
||||
log() { echo "[thumb-import] $*"; }
|
||||
|
||||
# Dump a job's findings before dying. Without this, an import that ran but
|
||||
# imported nothing looks identical to one that never ran — and the jobs
|
||||
# record precisely why they skipped a file (orphan, unreadable, store
|
||||
# failed). The first failure of this script was a misreported orphan, and
|
||||
# the finding naming it was sitting in the run the whole time.
|
||||
dump_findings() {
|
||||
local job="$1" run_id findings
|
||||
run_id=$(curl -sf -H "$AUTH" "$base_url/api/admin/jobs/$job/runs?limit=1" 2>/dev/null \
|
||||
| jq -r 'if type == "array" then .[0].id else ((.runs // .items // [])[0].id) end // empty')
|
||||
[[ -z "$run_id" ]] && { echo " ($job: no run found)" >&2; return; }
|
||||
findings=$(curl -sf -H "$AUTH" \
|
||||
"$base_url/api/admin/jobs/$job/runs/$run_id/findings?limit=20" 2>/dev/null || echo '[]')
|
||||
echo " $job findings:" >&2
|
||||
echo "$findings" | jq -r \
|
||||
'if type == "array" then .[] else (.findings // .items // [])[] end
|
||||
| " \(.kind // .finding_kind // "?") \(.details // {} | tostring)"' 2>/dev/null >&2 \
|
||||
|| echo " (unparseable)" >&2
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo $'\e[31m'"[thumb-import] FAIL: $*"$'\e[0m' >&2
|
||||
dump_findings thumb_derived_import
|
||||
dump_findings thumb_attached_import
|
||||
exit 1
|
||||
}
|
||||
|
||||
# psql inside the compose container — no host psql dependency, matching
|
||||
# how spawn-db.sh probes readiness.
|
||||
sql() {
|
||||
# Podman's docker-compose shim prints a provider banner to stderr on every
|
||||
# invocation, which buries this script's own output. Filtered rather than
|
||||
# discarded (`2>/dev/null`) so genuine psql errors still surface — losing
|
||||
# those would turn a broken query into a silently wrong assertion.
|
||||
#
|
||||
# Suppressing it at the source needs `[engine] compose_warning_logs = false`
|
||||
# in containers.conf, which is per-developer config and cannot be relied on
|
||||
# in CI.
|
||||
docker compose -f "$COMPOSE_FILE" exec -T postgres-test \
|
||||
psql -U oxicloud_test -d oxicloud_test -tAqc "$1" \
|
||||
2> >(grep -v 'Executing external compose provider' >&2)
|
||||
}
|
||||
|
||||
TOKEN=$(curl -sf -X POST "$base_url/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$username\",\"password\":\"$password\"}" \
|
||||
| jq -r '.access_token')
|
||||
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || fail "login failed"
|
||||
AUTH="Authorization: Bearer $TOKEN"
|
||||
|
||||
# ── 1. Create a file with BOTH sidecar shapes ────────────────────────────
|
||||
|
||||
SRC_FOLDER=$(curl -sf -X POST "$base_url/api/folders" -H "$AUTH" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"hurl-import-src"}' | jq -r '.id')
|
||||
DST_FOLDER=$(curl -sf -X POST "$base_url/api/folders" -H "$AUTH" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"hurl-import-dst"}' | jq -r '.id')
|
||||
[[ -n "$SRC_FOLDER" && "$SRC_FOLDER" != "null" ]] || fail "folder create failed"
|
||||
|
||||
UPLOAD=$(curl -sf -X POST "$base_url/api/files/upload" -H "$AUTH" \
|
||||
-F "folder_id=$SRC_FOLDER" \
|
||||
-F "file=@$REPO_ROOT/tests/fixtures/red-image.png;type=image/png")
|
||||
FILE_ID=$(echo "$UPLOAD" | jq -r '.id')
|
||||
BLOB_HASH=$(echo "$UPLOAD" | jq -r '.content_hash')
|
||||
[[ -n "$FILE_ID" && "$FILE_ID" != "null" ]] || fail "upload failed: $UPLOAD"
|
||||
log "uploaded file=$FILE_ID hash=${BLOB_HASH:0:12}"
|
||||
|
||||
# Render → writes {size}/{hash}.webp AND the content_derived_blobs row.
|
||||
curl -sf -H "$AUTH" "$base_url/api/files/$FILE_ID/thumbnail/preview" -o /dev/null \
|
||||
|| fail "render thumbnail failed"
|
||||
|
||||
# Upload → writes ext-{file_id}.jpg AND the file_attached_blobs row.
|
||||
curl -sf -X PUT -H "$AUTH" -H "Content-Type: image/png" \
|
||||
--data-binary "@$REPO_ROOT/tests/fixtures/green-image.png" \
|
||||
"$base_url/api/files/$FILE_ID/thumbnail/preview" -o /dev/null \
|
||||
|| fail "upload thumbnail failed"
|
||||
|
||||
UPLOADED_THUMB=$(mktemp)
|
||||
curl -sf -H "$AUTH" "$base_url/api/files/$FILE_ID/thumbnail/preview" -o "$UPLOADED_THUMB"
|
||||
|
||||
# ── 1b. Lay down the sidecars the server no longer writes ────────────────
|
||||
#
|
||||
# Since step 10d2 the write paths persist ONLY to the blob tiers, so an
|
||||
# upload no longer leaves anything under .thumbnails/ — which is the point,
|
||||
# but it removes the source this test used to manufacture legacy state from.
|
||||
#
|
||||
# So write them here, with the bytes the API just served, at the exact paths
|
||||
# the pre-10d2 code used: `{size}/{blob_hash}.jpg` for the rendered
|
||||
# thumbnail and `{size}/ext-{file_id}.jpg` for the upload. Requests omit
|
||||
# `Accept`, so both negotiate JPEG.
|
||||
#
|
||||
# This keeps the reconstruction faithful rather than approximate: same
|
||||
# bytes, same paths, same filenames a pre-migration install holds. What it
|
||||
# no longer does is rely on the current code to produce them — which it
|
||||
# cannot, and should not.
|
||||
SIDECAR_DIR="$STORAGE_PATH/.thumbnails/preview"
|
||||
mkdir -p "$SIDECAR_DIR"
|
||||
cp "$UPLOADED_THUMB" "$SIDECAR_DIR/ext-$FILE_ID.jpg"
|
||||
cp "$UPLOADED_THUMB" "$SIDECAR_DIR/$BLOB_HASH.jpg"
|
||||
# Identical bytes in both, which is realistic rather than a shortcut: they
|
||||
# dedup to one blob, so the derived and attached rows end up referencing the
|
||||
# same content while keeping separate mappings — exactly the property the
|
||||
# keying split exists to preserve.
|
||||
log "legacy sidecars written to $SIDECAR_DIR"
|
||||
|
||||
DERIVED_BEFORE=$(sql "SELECT count(*) FROM storage.content_derived_blobs WHERE source_hash='$BLOB_HASH';")
|
||||
ATTACHED_BEFORE=$(sql "SELECT count(*) FROM storage.file_attached_blobs WHERE file_id='$FILE_ID';")
|
||||
[[ "$DERIVED_BEFORE" -ge 1 ]] || fail "expected a content_derived_blobs row before stripping"
|
||||
[[ "$ATTACHED_BEFORE" -ge 1 ]] || fail "expected a file_attached_blobs row before stripping"
|
||||
log "rows present before stripping: derived=$DERIVED_BEFORE attached=$ATTACHED_BEFORE"
|
||||
|
||||
# ── 2. Strip the rows → this IS the legacy state ─────────────────────────
|
||||
#
|
||||
# Release each reference as the row goes, so the manufactured state matches
|
||||
# a pre-migration install rather than carrying references it never had.
|
||||
# file_attached_blobs does this via its ON DELETE trigger; the derived table
|
||||
# has no trigger (its Rust purge path releases explicitly), so do it here.
|
||||
|
||||
sql "WITH gone AS (
|
||||
DELETE FROM storage.content_derived_blobs
|
||||
WHERE source_hash='$BLOB_HASH'
|
||||
RETURNING blob_hash
|
||||
)
|
||||
UPDATE storage.chunk_manifests m
|
||||
SET ref_count = GREATEST(m.ref_count - 1, 0)
|
||||
FROM gone WHERE m.file_hash = gone.blob_hash;" >/dev/null
|
||||
|
||||
sql "DELETE FROM storage.file_attached_blobs WHERE file_id='$FILE_ID';" >/dev/null
|
||||
|
||||
[[ "$(sql "SELECT count(*) FROM storage.content_derived_blobs WHERE source_hash='$BLOB_HASH';")" == "0" ]] \
|
||||
|| fail "derived row survived the strip"
|
||||
[[ "$(sql "SELECT count(*) FROM storage.file_attached_blobs WHERE file_id='$FILE_ID';")" == "0" ]] \
|
||||
|| fail "attached row survived the strip"
|
||||
log "legacy state manufactured: files on disk, no rows."
|
||||
|
||||
# ── 3. Run the imports ───────────────────────────────────────────────────
|
||||
|
||||
for job in thumb_derived_import thumb_attached_import; do
|
||||
curl -sf -X POST -H "$AUTH" "$base_url/api/admin/jobs/$job/trigger" >/dev/null \
|
||||
|| fail "$job trigger failed"
|
||||
log "$job triggered."
|
||||
done
|
||||
|
||||
DERIVED_AFTER=$(sql "SELECT count(*) FROM storage.content_derived_blobs WHERE source_hash='$BLOB_HASH';")
|
||||
ATTACHED_AFTER=$(sql "SELECT count(*) FROM storage.file_attached_blobs WHERE file_id='$FILE_ID';")
|
||||
[[ "$DERIVED_AFTER" -ge 1 ]] || fail "thumb_derived_import did not restore the row"
|
||||
[[ "$ATTACHED_AFTER" -ge 1 ]] || fail "thumb_attached_import did not restore the row"
|
||||
log "rows restored: derived=$DERIVED_AFTER attached=$ATTACHED_AFTER"
|
||||
|
||||
# Provenance: imported rows carry the sentinel, which is how an operator
|
||||
# tells them from previews with a real uploader.
|
||||
UPLOADER=$(sql "SELECT uploaded_by FROM storage.file_attached_blobs WHERE file_id='$FILE_ID';")
|
||||
[[ "$UPLOADER" == "00000000-0000-0000-0000-000000000000" ]] \
|
||||
|| fail "imported row should carry the nil uploader sentinel, got '$UPLOADER'"
|
||||
|
||||
# ── 4. The user-visible point: a COPY inherits the preview ───────────────
|
||||
#
|
||||
# Impossible before the row existed — the ext- sidecar is keyed by file_id
|
||||
# and no copy path duplicates it, so the copy fell back to a render.
|
||||
|
||||
COPY_ID=$(curl -sf -X POST "$base_url/api/batch/files/copy" -H "$AUTH" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"file_ids\":[\"$FILE_ID\"],\"target_folder_id\":\"$DST_FOLDER\"}" \
|
||||
| jq -r '.successful[0].id')
|
||||
[[ -n "$COPY_ID" && "$COPY_ID" != "null" ]] || fail "copy failed"
|
||||
|
||||
COPY_THUMB=$(mktemp)
|
||||
curl -sf -H "$AUTH" "$base_url/api/files/$COPY_ID/thumbnail/preview" -o "$COPY_THUMB"
|
||||
cmp -s "$UPLOADED_THUMB" "$COPY_THUMB" \
|
||||
|| fail "copy did not inherit the imported preview"
|
||||
log "copy inherits the imported preview."
|
||||
|
||||
# ── 5. Idempotence: a second run imports nothing and churns nothing ──────
|
||||
#
|
||||
# The likely silent defect. store_attached_blob is ON CONFLICT DO UPDATE, so
|
||||
# an import that skipped its existence check would release and retake a
|
||||
# reference every run — invisible except as refcount drift.
|
||||
|
||||
ATTACHED_HASH=$(sql "SELECT blob_hash FROM storage.file_attached_blobs WHERE file_id='$FILE_ID' LIMIT 1;")
|
||||
[[ -n "$ATTACHED_HASH" ]] || fail "no attached blob_hash to check refcounts against"
|
||||
# A single-chunk blob has a manifest whose file_hash equals its own hash, so
|
||||
# this is the counter add_reference actually touches. `-` rather than empty
|
||||
# keeps the later comparison meaningful if the manifest is unexpectedly absent.
|
||||
REFS_BEFORE=$(sql "SELECT ref_count FROM storage.chunk_manifests WHERE file_hash='$ATTACHED_HASH';")
|
||||
REFS_BEFORE=${REFS_BEFORE:--}
|
||||
|
||||
for job in thumb_derived_import thumb_attached_import; do
|
||||
curl -sf -X POST -H "$AUTH" "$base_url/api/admin/jobs/$job/trigger" >/dev/null \
|
||||
|| fail "$job re-trigger failed"
|
||||
done
|
||||
|
||||
REFS_AFTER=$(sql "SELECT ref_count FROM storage.chunk_manifests WHERE file_hash='$ATTACHED_HASH';")
|
||||
REFS_AFTER=${REFS_AFTER:--}
|
||||
DERIVED_2=$(sql "SELECT count(*) FROM storage.content_derived_blobs WHERE source_hash='$BLOB_HASH';")
|
||||
ATTACHED_2=$(sql "SELECT count(*) FROM storage.file_attached_blobs WHERE file_id='$FILE_ID';")
|
||||
|
||||
[[ "$REFS_AFTER" == "$REFS_BEFORE" ]] \
|
||||
|| fail "re-run changed the attached blob refcount: $REFS_BEFORE → $REFS_AFTER"
|
||||
[[ "$DERIVED_2" == "$DERIVED_AFTER" ]] || fail "re-run duplicated derived rows"
|
||||
[[ "$ATTACHED_2" == "$ATTACHED_AFTER" ]] || fail "re-run duplicated attached rows"
|
||||
log "re-run is a no-op: rows and refcounts unchanged."
|
||||
|
||||
# ── 5b. Deletion: the destructive half, and the only one that can lose data
|
||||
#
|
||||
# Everything above is additive and recoverable. This unlinks files after a
|
||||
# readback check, so a defect here costs bytes — and until now it had never
|
||||
# executed under test at all: no test passed `repair=true`, so
|
||||
# verify_and_unlink, the sidecar_delete_unverified finding and the directory
|
||||
# removal were entirely unexercised.
|
||||
#
|
||||
# Four assertions, because "the files are gone" alone cannot tell a correct
|
||||
# drain from a destructive one:
|
||||
#
|
||||
# sidecars gone — the drain happened
|
||||
# rows still present — it deleted the COPY, not the record. Removing the
|
||||
# row would strand the blob exactly as the bulk-reap
|
||||
# bug did.
|
||||
# unverified == 0 — every unlink passed its readback rather than being
|
||||
# skipped, which is what makes the deletion safe
|
||||
# directory absent — the signal step 10e gates on, and the reason
|
||||
# `remove_dir` is used: it refuses a non-empty
|
||||
# directory, so success proves emptiness
|
||||
|
||||
[[ -f "$SIDECAR_DIR/$BLOB_HASH.jpg" ]] || fail "precondition: derived sidecar already gone before the repair run"
|
||||
[[ -f "$SIDECAR_DIR/ext-$FILE_ID.jpg" ]] || fail "precondition: attached sidecar already gone before the repair run"
|
||||
|
||||
for job in thumb_derived_import thumb_attached_import; do
|
||||
curl -sf -X POST -H "$AUTH" "$base_url/api/admin/jobs/$job/trigger?repair=true" >/dev/null \
|
||||
|| fail "$job repair-trigger failed"
|
||||
log "$job triggered with repair=true."
|
||||
done
|
||||
|
||||
[[ ! -f "$SIDECAR_DIR/$BLOB_HASH.jpg" ]] || fail "derived sidecar survived a repair run"
|
||||
[[ ! -f "$SIDECAR_DIR/ext-$FILE_ID.jpg" ]] || fail "attached sidecar survived a repair run"
|
||||
|
||||
DERIVED_KEPT=$(sql "SELECT count(*) FROM storage.content_derived_blobs WHERE source_hash='$BLOB_HASH';")
|
||||
ATTACHED_KEPT=$(sql "SELECT count(*) FROM storage.file_attached_blobs WHERE file_id='$FILE_ID';")
|
||||
[[ "$DERIVED_KEPT" -ge 1 ]] || fail "deletion removed the derived row; it must delete only the sidecar"
|
||||
[[ "$ATTACHED_KEPT" -ge 1 ]] || fail "deletion removed the attached row; it must delete only the sidecar"
|
||||
|
||||
for job in thumb_derived_import thumb_attached_import; do
|
||||
run_id=$(curl -sf -H "$AUTH" "$base_url/api/admin/jobs/$job/runs?limit=1" \
|
||||
| jq -r 'if type == "array" then .[0].id else ((.runs // .items // [])[0].id) end // empty')
|
||||
if [[ -n "$run_id" ]]; then
|
||||
unverified=$(curl -sf -H "$AUTH" \
|
||||
"$base_url/api/admin/jobs/$job/runs/$run_id/findings?limit=50" \
|
||||
| jq -r '[ (if type == "array" then .[] else (.findings // .items // [])[] end)
|
||||
| select((.kind // .finding_kind) == "sidecar_delete_unverified") ] | length')
|
||||
[[ "${unverified:-0}" -eq 0 ]] \
|
||||
|| fail "$job reported $unverified unverified unlink(s) — a blob did not read back"
|
||||
fi
|
||||
done
|
||||
|
||||
# The directory removal is best-effort in the job (another test's render could
|
||||
# repopulate it), so treat its absence as confirmation rather than a hard
|
||||
# requirement.
|
||||
if [[ -d "$STORAGE_PATH/.thumbnails" ]]; then
|
||||
log "NOTE: .thumbnails/ still present — non-empty when the job ran (find below)"
|
||||
find "$STORAGE_PATH/.thumbnails" -type f | head -5
|
||||
else
|
||||
log ".thumbnails/ removed — the absence step 10e gates on."
|
||||
fi
|
||||
|
||||
log "deletion verified: sidecars drained, rows kept, every unlink read back."
|
||||
|
||||
# ── 6. Teardown ──────────────────────────────────────────────────────────
|
||||
# Everything created here must go — one database serves the whole suite,
|
||||
# and storage_cleanup_check.sh afterwards asserts the registry drains to
|
||||
# zero.
|
||||
|
||||
rm -f "$UPLOADED_THUMB" "$COPY_THUMB"
|
||||
|
||||
for folder in "$SRC_FOLDER" "$DST_FOLDER"; do
|
||||
curl -sf -X DELETE -H "$AUTH" "$base_url/api/folders/$folder" -o /dev/null || true
|
||||
done
|
||||
TRASH=$(curl -sf -H "$AUTH" "$base_url/api/trash/resources" || echo '{}')
|
||||
for folder in "$SRC_FOLDER" "$DST_FOLDER"; do
|
||||
tid=$(echo "$TRASH" | jq -r --arg id "$folder" '.items[]? | select(.resource.id == $id) | .resource.id')
|
||||
[[ -n "$tid" ]] && curl -sf -X DELETE -H "$AUTH" "$base_url/api/trash/$tid" -o /dev/null || true
|
||||
done
|
||||
|
||||
log "OK — both imports restore their rows, the copy inherits the preview, and re-running is a no-op."
|
||||
@@ -0,0 +1,219 @@
|
||||
# =============================================================
|
||||
# OxiCloud – Thumbnail ETag is keyed on CONTENT, not on file id
|
||||
# =============================================================
|
||||
# Regression guard for a stale-cache bug.
|
||||
#
|
||||
# The thumbnail ETag used to be `"thumb-{file_id}-{size}-{format}"`, sent
|
||||
# with `Cache-Control: public, max-age=31536000, immutable`. Replacing a
|
||||
# file's content preserves its id — the upload service rebuilds the entity
|
||||
# with `parts.id` and a new hash, then fires `on_file_updated`, which
|
||||
# regenerates the thumbnails — so the server produced a NEW thumbnail while
|
||||
# advertising the OLD ETag. And `immutable` tells a conforming browser not
|
||||
# to revalidate at all inside the freshness window, so clients kept showing
|
||||
# the previous image for up to a year with no way to invalidate it.
|
||||
#
|
||||
# Keying on the content hash fixes it: new bytes → new hash → new ETag.
|
||||
#
|
||||
# This file asserts the invalidation direction. The sharing direction (two
|
||||
# distinct files with identical content answering with the SAME ETag, so a
|
||||
# copy revalidates to 304) is covered in `derived_blob_copy.hurl`.
|
||||
#
|
||||
# Overwrite goes through WebDAV PUT because that is the path that replaces
|
||||
# content in place; the REST upload endpoint creates a new file instead.
|
||||
#
|
||||
# Prerequisites: setup.hurl must have run (admin user exists).
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 – Login
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{username}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 – Upload the first image into a folder of its own.
|
||||
#
|
||||
# `folder_id` is required — the upload path resolves the owner from the
|
||||
# destination folder, so omitting it is a 500, not a root upload. The
|
||||
# folder also gives the WebDAV overwrite in step 4 a deterministic path.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "hurl-etag-src"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{folder_id}}
|
||||
file: file,fixtures/red-image.png; image/png
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
file_id: jsonpath "$.id"
|
||||
file_name: jsonpath "$.name"
|
||||
hash_before: jsonpath "$.content_hash"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 – Its thumbnail, and the ETag that goes with it
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/files/{{file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
etag_before: header "ETag"
|
||||
[Asserts]
|
||||
# `private`, because a thumbnail is Permission::Read gated — `public` let a
|
||||
# shared proxy hand one user's thumbnail to another. `no-cache` rather than
|
||||
# `immutable`, because this URL is keyed by file id and its bytes change
|
||||
# when content is replaced or a preview uploaded; `immutable` suppressed
|
||||
# revalidation entirely, which made the ETag below unobservable in a real
|
||||
# client.
|
||||
header "Cache-Control" contains "private"
|
||||
header "Cache-Control" contains "no-cache"
|
||||
header "Cache-Control" not contains "immutable"
|
||||
|
||||
|
||||
# Unchanged content revalidates to 304 — the caching path works.
|
||||
GET {{base_url}}/api/files/{{file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
If-None-Match: {{etag_before}}
|
||||
|
||||
HTTP 304
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 – Replace the content in place, keeping the same file id.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/hurl-etag-src/{{file_name}}
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: image/png
|
||||
file,fixtures/green-image.png;
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 200
|
||||
status < 300
|
||||
|
||||
|
||||
# Same file row, different content.
|
||||
#
|
||||
# Listed rather than fetched by id: `/api/files/{id}` is the DOWNLOAD
|
||||
# route (it returns the image bytes) and `/{id}/metadata` is the EXIF
|
||||
# endpoint — neither carries the FileDto. The folder holds exactly this
|
||||
# one file, so `count == 1` also proves the PUT overwrote in place
|
||||
# instead of creating a second file beside it.
|
||||
GET {{base_url}}/api/files?folder_id={{folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
hash_after: jsonpath "$[0].content_hash"
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].id" == "{{file_id}}"
|
||||
jsonpath "$[0].content_hash" != "{{hash_before}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 – The ETag must have changed with the content.
|
||||
#
|
||||
# This is the assertion the file exists for. With the id-keyed ETag it was
|
||||
# byte-identical to `etag_before`, and the next request would have been
|
||||
# answered 304 from cache — serving the OLD image indefinitely.
|
||||
#
|
||||
# Deliberately asserts the ETag only, not that the BODY changed. The moka
|
||||
# tier is still keyed on file_id and is invalidated from the spawned task
|
||||
# in `on_file_updated`, so a request landing before that task runs gets the
|
||||
# previous bytes under the new ETag. Asserting on bytes here would be a
|
||||
# race; the incoherence itself is tracked separately.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/files/{{file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
etag_after: header "ETag"
|
||||
[Asserts]
|
||||
header "ETag" != "{{etag_before}}"
|
||||
|
||||
|
||||
# A client holding the stale ETag must be told to refetch, not given a 304.
|
||||
GET {{base_url}}/api/files/{{file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
If-None-Match: {{etag_before}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "ETag" == "{{etag_after}}"
|
||||
|
||||
|
||||
# ...and the new ETag revalidates normally.
|
||||
GET {{base_url}}/api/files/{{file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{token}}
|
||||
If-None-Match: {{etag_after}}
|
||||
|
||||
HTTP 304
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 – Teardown. Hurl files share one database within run.sh, so the
|
||||
# folder goes too, and both leave trash empty behind them.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/files/{{file_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/api/trash/resources
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
trash_id: jsonpath "$.items[?(@.resource.id == '{{file_id}}')].resource.id"
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/{{trash_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
GET {{base_url}}/api/trash/resources
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
trash_folder_id: jsonpath "$.items[?(@.resource.id == '{{folder_id}}')].resource.id"
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/{{trash_folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
Reference in New Issue
Block a user