Merge pull request #696 from EdouardVanbelle/feat/thumbnails-on-backend-storage

This commit is contained in:
Dionisio Pozo
2026-09-01 06:29:32 +02:00
committed by GitHub
64 changed files with 8915 additions and 1046 deletions
+25 -14
View File
@@ -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
+6 -2
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+153
View File
@@ -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
View File
@@ -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
+144
View File
@@ -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
+11 -1
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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.
+37
View File
@@ -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;
}
/**
+203 -84
View File
@@ -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);
}
+9 -3
View File
@@ -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.';
+19 -2
View File
@@ -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.
+21
View File
@@ -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>;
+18
View File
@@ -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.
///
+252
View File
@@ -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
View File
@@ -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)
}
}
+9
View File
@@ -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)",
+37 -1
View File
@@ -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
}
}
+4 -2
View File
@@ -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};
+107 -2
View File
@@ -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());
+130 -5
View File
@@ -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() {
+42
View File
@@ -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,
+386 -17
View File
@@ -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
+4
View File
@@ -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;
+176 -53
View File
@@ -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()
}
+177 -52
View File
@@ -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"),
+54 -35
View File
@@ -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(),
+57 -9
View File
@@ -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"
# ─────────────────────────────────────────────────────────────
+308
View File
@@ -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
+418
View File
@@ -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
+22
View File
@@ -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"
+7
View File
@@ -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 ─────────────
+247 -7
View File
@@ -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."
+336
View File
@@ -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."
+219
View File
@@ -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