Merge pull request #651 from EdouardVanbelle/feat/recoverable-jobs

feat(recoverable jobs): add engine + consistency jobs
This commit is contained in:
Dionisio Pozo
2026-07-30 10:30:39 +02:00
committed by GitHub
48 changed files with 8828 additions and 365 deletions
+1
View File
@@ -172,6 +172,7 @@ export default defineConfig({
{ text: "User lifecycle", link: "/architecture/user-lifecycle" }, { text: "User lifecycle", link: "/architecture/user-lifecycle" },
{ text: "Authentication model", link: "/architecture/auth-model" }, { text: "Authentication model", link: "/architecture/auth-model" },
{ text: "Magic-link auth", link: "/architecture/magic-link-auth" }, { text: "Magic-link auth", link: "/architecture/magic-link-auth" },
{ text: "Background jobs", link: "/architecture/jobs" },
], ],
}, },
{ text: "FAQ", link: "/faq" }, { text: "FAQ", link: "/faq" },
+1
View File
@@ -74,3 +74,4 @@ src/
- [Caching Architecture →](/architecture/caching) - [Caching Architecture →](/architecture/caching)
- [Resource Listing API →](/architecture/resource-listing) - [Resource Listing API →](/architecture/resource-listing)
- [Storage Quotas →](/architecture/storage-quotas) - [Storage Quotas →](/architecture/storage-quotas)
- [Background Jobs →](/architecture/jobs)
+246
View File
@@ -0,0 +1,246 @@
# Background Jobs
OxiCloud runs periodic maintenance work through a central **JobRegistry**
scheduler. This document is for implementors adding a new tenant or
debugging an existing one.
The design rationale (why a registry vs. per-service `tokio::spawn`
loops, the two-engine split, plugin future) lives in the plan doc:
[`docs/plan/job-registry.md`](../plan/job-registry.md). This page
is the "how to plug in" reference.
---
## When does a background loop belong here?
Single decision question:
> **"Would an operator plausibly `POST /api/admin/jobs/{name}/trigger`
> to make it run right now?"**
If yes, register it with JobRegistry. You get:
- Uniform admin surface (list, trigger, last-outcome).
- Uniform `oxicloud::scheduler` log line per run with `elapsed_ms`.
- Panic containment (a handler that panics doesn't kill the scheduler).
- Exclusivity: only one in-flight run per job name (a tick that fires
while the previous run is still going is skipped, with a warning).
- Optional wall-clock timeout.
- Optional acceleration parameter (`?force=true` → `JobRunArgs.force`).
If no — the loop is a queue drainer (`tree_etag_flush_job`), a
continuous event reactor (`content_index_worker`), or a stats printer
(`db_pool_monitor`) — keep it as its own dedicated `tokio::spawn`
loop. Wedging it into JobRegistry adds framework overhead for no
operator benefit.
---
## Current tenants
| Job name | Cadence | Force semantic | Service |
|---|---|---|---|
| `trash_cleanup` | 24 h (hardcoded in DI, no env var yet) | ignored | [`trash_cleanup_service.rs`](../../src/infrastructure/services/trash_cleanup_service.rs) |
| `storage_reconcile`| `OXICLOUD_STORAGE_USAGE_RECONCILE_SECS` (default 600s, min 30s) | ignored | [`storage_usage_service.rs`](../../src/application/services/storage_usage_service.rs) |
| `dedup_gc` | on-demand only (trash cleanup runs it inline as its tail step) | `force=true` → `garbage_collect_force()` (skip orphan grace) | [`dedup_service.rs`](../../src/infrastructure/services/dedup_service.rs) |
| `grant_cleanup` | `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` (default 24h) — feature-gated by `OXICLOUD_GRANT_CLEANUP_ENABLED` | `force=true` → `purge(Some(0))` (grace_days=0) | [`grant_cleanup_service.rs`](../../src/infrastructure/services/grant_cleanup_service.rs) |
---
## Adding a new job — recipe
### 1. Implement `JobHandler` on your service
```rust
use std::sync::Arc;
use async_trait::async_trait;
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
pub const MY_JOB_NAME: &str = "my_job"; // stable snake_case
#[async_trait]
impl JobHandler for MyService {
fn name(&self) -> &str {
MY_JOB_NAME
}
async fn run(&self, args: &JobRunArgs) -> JobOutcome {
// …do work…
match self.do_the_work().await {
Ok(count) => JobOutcome::ok_with(
count,
serde_json::json!({
// Anything useful for `GET /api/admin/jobs` or logs.
"bytes_processed": total_bytes,
"forced": args.force,
}),
),
Err(e) => JobOutcome::err(format!("my_job failed: {e}")),
}
}
}
```
Rules:
- **`name()` must be stable** — it appears in log lines, admin URLs,
and `admin.background_runs.job_name` (once Part 2 lands). Renaming
is a breaking change to operator scripts and log dashboards.
- **`args.force` semantics are per-job.** If your job has no
acceleration mode (e.g. reconciliation, which is always idempotent),
ignore it. If it does (e.g. skip a grace window), document the
behaviour on the handler docstring.
- **Return `JobOutcome::ok_with(count, extra)`.** `count` is the
primary scalar operators read (rows swept, blobs reclaimed).
`extra` is a free-form JSON blob surfaced in the log line and the
admin listing.
- **Return `JobOutcome::err(msg)` on failure.** The supervisor logs
`outcome=err, cause=handler` and continues to the next tick. Don't
catch panics inside `run()` — the supervisor does it, and hiding
one loses the `cause=panicked` diagnostic.
### 2. Add a `register()` method to your service
Every service uses the chainable self-registration shape:
```rust
impl MyService {
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style chaining.
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
let interval = /* Some(Duration::from_secs(...)) or None for on-demand */;
registry.register(self.clone(), interval, None /* no timeout */).await;
self
}
}
```
- **Interval `Some(dur)`** → **scheduled**. The supervisor fires the
handler every `dur`.
- **Interval `None`** → **on-demand only**. Never fires periodically;
only reachable via `POST /api/admin/jobs/{name}/trigger` or a
programmatic call to `registry.trigger(name, args)`. Use for jobs
whose periodic work happens elsewhere (dedup GC piggybacks on
trash cleanup) but which still benefit from a uniform admin trigger.
- **Timeout `Some(dur)`** → the supervisor wraps the handler in
`tokio::time::timeout`. Timeout trip is logged as
`outcome=err, cause=timeout`. Use sparingly — most native jobs don't
need it. Note: aborting a task mid-run is best-effort; a handler
that ignores await points may still finish in the background.
### 3. Wire in `common/di.rs`
One statement per service:
```rust
let my_service = Arc::new(MyService::new(deps...))
.register(&core.job_registry)
.await;
```
`register()` panics on error (duplicate job name = DI wiring bug;
boot must fail loud) and emits a `job.registered` log line on
success. No `if let Err(e)` scaffolding needed at the call site.
`core.job_registry` is populated inside `create_core_services` and
lives on `CoreServices`. `SchedulerEngine` spawns the supervisor
task at the end of `build_app_state` after every service has
registered.
### 4. That's it
- `GET /api/admin/jobs` immediately lists the new job.
- `POST /api/admin/jobs/my_job/trigger` runs one dispatch off-schedule.
- `POST /api/admin/jobs/my_job/trigger?force=true` runs one dispatch
with `JobRunArgs { force: true }`.
- If the job is scheduled, the supervisor fires it at the configured
cadence.
No handler registration in the router, no admin trigger endpoint to
add — the framework already covers those uniformly.
---
## Admin surface
Production admin endpoints, always on, audit-logged. Admin-only via
the standard `/api/admin/*` middleware (no dedicated feature flag).
```
GET /api/admin/jobs
→ [{ name, interval_ms?, next_run_at?, last_run_at?, last_outcome?, running }]
POST /api/admin/jobs/{name}/trigger[?force=<bool>]
→ 200 { ok: true, outcome: { outcome: "ok" | "err", count, extra?, message? } }
→ 404 { error: "job not registered", name }
```
- `interval_ms` / `next_run_at` are absent for on-demand jobs
(`skip_serializing_if=None`).
- `last_run_at` / `last_outcome` are absent until the first run
completes.
- `running` is `true` iff the in-flight permit is currently held.
- Every trigger call emits a `target: "audit"` log line
(`event = "job.trigger"`) before dispatch.
---
## Log lines
Uniform target: `oxicloud::scheduler`.
| Event | Fields | When |
|---|---|---|
| `scheduler.started` | — | Supervisor loop starts |
| `scheduler.ready` | `registered = N` | All services have registered |
| `job.registered` | `job`, `cadence` ("every 24 h" / "on-demand") | Each `register()` call |
| `job.run` | `job`, `outcome` (ok\|err), `cause?` (handler\|timeout\|panicked), `count`, `elapsed_ms`, `extra?`, `error?` | Every dispatch |
| `job.tick_skipped` | `job`, `interval_ms`, `running_for_ms` | Tick fires while the previous run is still going |
| `job.trigger` | `job`, `force` (audit channel) | Admin `POST /trigger` |
`elapsed_ms` is the raw scalar in the structured field; the human
message renders it as `12ms` / `1.4s` / `4m30s` so `tail -f` operators
see the duration inline.
---
## Testing
The scheduler's own tests live in `src/infrastructure/scheduler/`
(`registry.rs::tests`, `engine.rs::tests`) and use dummy handlers.
No integration test infrastructure is needed to add a new service —
your service's normal unit tests cover the `run()` logic, and the
Hurl suite `tests/api/admin_jobs.hurl` covers the admin surface
generically.
If your service has a test that needs to construct the type WITHOUT
registering with a scheduler (e.g. isolated unit tests), skip the
`.register(&reg).await` chain and use the bare `Arc::new(Service::new(...))`.
---
## Non-goals
- **Cross-job dependencies.** No `depends_on` — each job runs
independently. If you find yourself needing "job B runs after job A
completes", route the completion signal through a lifecycle hook
(`FileLifecycleHook`, `BlobLifecycleHook`), not through the scheduler.
- **Distributed scheduling.** Single-process only. If OxiCloud ever
runs multi-node, the pattern is `SELECT … FOR UPDATE SKIP LOCKED`
on a lease table — not this design.
- **Cron expressions.** Fixed intervals only. Real cron
(day-of-week/month, arbitrary times) can layer on top later via a
`next_run: Box<dyn NextRun>` trait; nothing needs it today.
- **Backfill on startup.** If the process was down when a scheduled
tick was due, the missed tick is NOT caught up — the next tick fires
at its normal interval.
---
## Related
- Plan doc: [`docs/plan/job-registry.md`](../plan/job-registry.md)
- Long-running / resumable jobs (Part 2, not yet built):
[`docs/plan/job-registry.md#part-2--recoverable-run-engine`](../plan/job-registry.md#part-2--recoverable-run-engine)
- Consistency checks (a future Part 2 consumer):
[`docs/plan/consistency-check.md`](../plan/consistency-check.md)
+51 -31
View File
@@ -1,5 +1,25 @@
# Plan — Resumable consistency checks + `StatefulAdapter` contract # Plan — Resumable consistency checks + `StatefulAdapter` contract
> ⚠️ **PARTIALLY SUPERSEDED (Ed 2026-07-28).** The current shipping
> design organises consistency checks **by the subject they iterate**
> (drives / folders / files / storage), NOT by concern (blob / thumbnail
> / used_bytes). Each `*_consistency` job is a direct
> `RecoverableJobHandler` impl on the Part 2 engine — no
> `ConsistencyCheck` trait, no `StatefulAdapter` supertrait, no per-
> subsystem check registry. Cursor = row PK of the iterated subject.
>
> **See instead:**
> - Memory: `project_consistency_jobs_landscape` — the current taxonomy.
> - `docs/plan/job-registry.md` Part 2 §Native tenants — updated table.
> - `docs/architecture/jobs.md` — implementor guide.
>
> Sections below discuss `BlobConsistencyCheck`, `ThumbnailConsistencyCheck`,
> `UsedBytesConsistencyCheck` etc. as separate impls of a
> `ConsistencyCheck` trait. That IS retired. Read those sections for
> the invariants (grace-window trap, cursor discipline, findings
> idempotency) — they still apply. Ignore the trait shapes /
> registration wiring — the Part 2 engine covers those uniformly.
## Context ## Context
OxiCloud persists state in several independent subsystems: content-addressable OxiCloud persists state in several independent subsystems: content-addressable
@@ -38,14 +58,14 @@ This plan lands:
compiles without declaring its consistency contract. compiles without declaring its consistency contract.
3. An **educational surface** in trait doc-comments — decision axes 3. An **educational surface** in trait doc-comments — decision axes
(severity, direction, grace, cursor) and canonical-example pointers. (severity, direction, grace, cursor) and canonical-example pointers.
4. **Consistency-specific persistence** — `admin.consistency_findings`, 4. **Consistency-specific persistence** — `jobs.run_findings`,
idempotent-on-`(run_id, kind, resource_id)`. idempotent-on-`(run_id, kind, resource_id)`.
5. A **first check** — `BlobConsistencyCheck` (both directions, 5. A **first check** — `BlobConsistencyCheck` (both directions,
blob-keyed cursor, severity split). blob-keyed cursor, severity split).
**Layer boundary — the runtime is not in this plan.** The resumable **Layer boundary — the runtime is not in this plan.** The resumable
execution engine (cursor persistence, exclusivity, cancel protocol, execution engine (cursor persistence, exclusivity, cancel protocol,
crash recovery, `admin.background_runs` schema, `JobStore`, crash recovery, `jobs.recoverable_runs` schema, `JobStore`,
`RunOutcome`, `run_or_resume`) lives in `docs/plan/job-registry.md` `RunOutcome`, `run_or_resume`) lives in `docs/plan/job-registry.md`
Part 2. This plan describes what `ConsistencyCheck` implementors Part 2. This plan describes what `ConsistencyCheck` implementors
write and how the check-specific bits (findings, severity, write and how the check-specific bits (findings, severity,
@@ -53,7 +73,7 @@ write and how the check-specific bits (findings, severity,
**Order:** ships **after** the job-registry Part 2 engine lands. **Order:** ships **after** the job-registry Part 2 engine lands.
Consistency closes an operator-visibility gap today, but it depends Consistency closes an operator-visibility gap today, but it depends
on Part 2's `RecoverableJob` + `JobStore` + `admin.background_runs` on Part 2's `RecoverableJobHandler` + `JobStore` + `jobs.recoverable_runs`
primitives — those come first. Once both are in, consistency runs primitives — those come first. Once both are in, consistency runs
are admin-triggered v1, becoming periodic-triggered when a are admin-triggered v1, becoming periodic-triggered when a
`JobRegistry` (Part 1) tenant wraps `run_or_resume` for each `JobRegistry` (Part 1) tenant wraps `run_or_resume` for each
@@ -157,11 +177,11 @@ Race matrix — missing direction:
Nothing before "byte-exact whole-table snapshot verification" needs a Nothing before "byte-exact whole-table snapshot verification" needs a
quiescent server. Reserve `concurrent_safe() = false` for that one. quiescent server. Reserve `concurrent_safe() = false` for that one.
### Resumability — runs live in Part 2's `background_runs` ### Resumability — runs live in Part 2's `recoverable_runs`
Consistency runs are ordinary `RecoverableJob`s. The runtime plumbing Consistency runs are ordinary `RecoverableJobHandler`s. The runtime plumbing
— cursor persistence, exclusivity, cancel protocol, crash recovery, — cursor persistence, exclusivity, cancel protocol, crash recovery,
`admin.background_runs` schema, `JobStore` trait, `RunOutcome`, `jobs.recoverable_runs` schema, `JobStore` trait, `RunOutcome`,
`run_or_resume` helper — lives in `docs/plan/job-registry.md` Part 2. `run_or_resume` helper — lives in `docs/plan/job-registry.md` Part 2.
This plan does not redefine any of it. This plan does not redefine any of it.
@@ -172,10 +192,10 @@ This plan does not redefine any of it.
`SELECT DISTINCT ON (job_name)` return the last run of every check `SELECT DISTINCT ON (job_name)` return the last run of every check
alongside every other background job. alongside every other background job.
- Consistency's per-check knobs — `grace_window_secs`, `batch_size`, - Consistency's per-check knobs — `grace_window_secs`, `batch_size`,
`concurrent_safe` — live inside `background_runs.params` JSONB at `concurrent_safe` — live inside `recoverable_runs.params` JSONB at
run-start time. The check reads them back via run-start time. The check reads them back via
`serde_json::from_value(store.params()?)`. `serde_json::from_value(store.params()?)`.
- `background_runs.stats` accumulates `{"scanned_count": …, - `recoverable_runs.stats` accumulates `{"scanned_count": …,
"findings_this_run": …}`; readers call "findings_this_run": …}`; readers call
`(stats->>'scanned_count')::bigint`. `(stats->>'scanned_count')::bigint`.
@@ -183,9 +203,9 @@ The findings themselves are Layer C (this plan) — they don't
generalise to storage-migration or reextract: generalise to storage-migration or reextract:
```sql ```sql
CREATE TABLE admin.consistency_findings ( CREATE TABLE jobs.run_findings (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
run_id UUID NOT NULL REFERENCES admin.background_runs(id) ON DELETE CASCADE, run_id UUID NOT NULL REFERENCES jobs.recoverable_runs(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- OrphanBlob / MissingBlob / ... kind TEXT NOT NULL, -- OrphanBlob / MissingBlob / ...
severity TEXT NOT NULL, -- DataLoss / Reclaimable / ... severity TEXT NOT NULL, -- DataLoss / Reclaimable / ...
resource_id TEXT NOT NULL, resource_id TEXT NOT NULL,
@@ -193,15 +213,15 @@ CREATE TABLE admin.consistency_findings (
found_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), found_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (run_id, kind, resource_id) -- idempotent re-scan on resume UNIQUE (run_id, kind, resource_id) -- idempotent re-scan on resume
); );
CREATE INDEX ON admin.consistency_findings (run_id, severity); CREATE INDEX ON jobs.run_findings (run_id, severity);
``` ```
FK on `background_runs.id` links a finding back to the run that FK on `recoverable_runs.id` links a finding back to the run that
produced it; `ON DELETE CASCADE` clears findings when their run row produced it; `ON DELETE CASCADE` clears findings when their run row
is pruned by a future retention job. is pruned by a future retention job.
`admin.*` is a NEW schema, created by Part 2's migration — keep it `jobs.*` is a NEW schema, created by Part 2's migration — keep it
distinct from `auth.*` / `storage.*` so operational tables don't distinct from `auth.*` / `storage.*` / `admin.*` so operational tables don't
pollute domain schemas. pollute domain schemas.
### Non-obvious traps ### Non-obvious traps
@@ -223,7 +243,7 @@ learned the hard way in similar systems:
transitioned (was `MissingBlob`, blob has since landed → drop the transitioned (was `MissingBlob`, blob has since landed → drop the
finding, not the whole run). finding, not the whole run).
4. **Cooperative cancellation ONLY.** Between batches, poll 4. **Cooperative cancellation ONLY.** Between batches, poll
`background_runs.status`. A `tokio::spawn` abort mid-batch leaks — `recoverable_runs.status`. A `tokio::spawn` abort mid-batch leaks —
cursor unpersisted, findings half-written. Cancel path writes cursor unpersisted, findings half-written. Cancel path writes
`status='Paused'` + current cursor before returning. `status='Paused'` + current cursor before returning.
5. **Crash recovery on boot.** Any `status='Running'` at server start = 5. **Crash recovery on boot.** Any `status='Running'` at server start =
@@ -484,7 +504,7 @@ impl ConsistencyRegistry {
## Admin surface ## Admin surface
Consistency runs are ordinary `RecoverableJob`s (see Consistency runs are ordinary `RecoverableJobHandler`s (see
`docs/plan/job-registry.md` Part 2), so most operator actions reach `docs/plan/job-registry.md` Part 2), so most operator actions reach
them through the shared scheduler surface: them through the shared scheduler surface:
@@ -510,7 +530,7 @@ GET /api/admin/jobs/consistency_{name}/runs/{id}
``` ```
Findings enrichment on `runs/{id}` is consistency-specific — read Findings enrichment on `runs/{id}` is consistency-specific — read
from `admin.consistency_findings` and joined into the response. from `jobs.run_findings` and joined into the response.
Everything else is generic Part 2 behaviour. Everything else is generic Part 2 behaviour.
Production surface — always on, audit-logged. No feature-flag gate. Production surface — always on, audit-logged. No feature-flag gate.
@@ -528,22 +548,22 @@ Production surface — always on, audit-logged. No feature-flag gate.
`src/infrastructure/services/consistency/mod.rs` `src/infrastructure/services/consistency/mod.rs`
- `ConsistencyRegistry` (data structure only). - `ConsistencyRegistry` (data structure only).
- `PgCheckStore` — impl of `CheckStore` reading/writing - `PgCheckStore` — impl of `CheckStore` reading/writing
`admin.background_runs` (filtered to `job_name LIKE 'consistency_%'`) `jobs.recoverable_runs` (filtered to `job_name LIKE 'consistency_%'`)
+ `admin.consistency_findings`. + `jobs.run_findings`.
- `run_check(check, cursor, store)` — the runner that calls - `run_check(check, cursor, store)` — the runner that calls
`run_resumable`, applies timeout, records outcome. `run_resumable`, applies timeout, records outcome.
### 2. Schema migration ### 2. Schema migration
`migrations/YYYYMMDDHHMMSS_background_runs_admin_schema.sql` — creates `migrations/YYYYMMDDHHMMSS_background_runs_admin_schema.sql` — creates
the merged `admin.background_runs` table shared with the JobRegistry the merged `jobs.recoverable_runs` table shared with the JobRegistry
plan. Consistency checks own the `admin.consistency_findings` table plan. Consistency checks own the `jobs.run_findings` table
alone and reference `background_runs.id` via FK. alone and reference `recoverable_runs.id` via FK.
```sql ```sql
CREATE SCHEMA IF NOT EXISTS admin; CREATE SCHEMA IF NOT EXISTS admin;
CREATE TABLE admin.background_runs ( CREATE TABLE jobs.recoverable_runs (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
job_name TEXT NOT NULL, -- 'consistency_blobs', 'storage_migration', 'reextract_audio', ... job_name TEXT NOT NULL, -- 'consistency_blobs', 'storage_migration', 'reextract_audio', ...
status TEXT NOT NULL, -- Running / Paused / Completed / Failed / CancelRequested status TEXT NOT NULL, -- Running / Paused / Completed / Failed / CancelRequested
@@ -556,13 +576,13 @@ CREATE TABLE admin.background_runs (
error_message TEXT error_message TEXT
); );
CREATE UNIQUE INDEX one_active_run_per_job CREATE UNIQUE INDEX one_active_run_per_job
ON admin.background_runs (job_name) ON jobs.recoverable_runs (job_name)
WHERE status IN ('Running', 'Paused'); WHERE status IN ('Running', 'Paused');
CREATE INDEX ON admin.background_runs (last_progress_at) WHERE status = 'Running'; CREATE INDEX ON jobs.recoverable_runs (last_progress_at) WHERE status = 'Running';
CREATE TABLE admin.consistency_findings ( CREATE TABLE jobs.run_findings (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
run_id UUID NOT NULL REFERENCES admin.background_runs(id) ON DELETE CASCADE, run_id UUID NOT NULL REFERENCES jobs.recoverable_runs(id) ON DELETE CASCADE,
kind TEXT NOT NULL, kind TEXT NOT NULL,
severity TEXT NOT NULL, severity TEXT NOT NULL,
resource_id TEXT NOT NULL, resource_id TEXT NOT NULL,
@@ -570,7 +590,7 @@ CREATE TABLE admin.consistency_findings (
found_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), found_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (run_id, kind, resource_id) UNIQUE (run_id, kind, resource_id)
); );
CREATE INDEX ON admin.consistency_findings (run_id, severity); CREATE INDEX ON jobs.run_findings (run_id, severity);
``` ```
### 3. Supertrait bounds on existing state-owning ports ### 3. Supertrait bounds on existing state-owning ports
@@ -631,7 +651,7 @@ Ship this PR without the actual checks. Grep `TODO(consistency)` = punch list.
`src/interfaces/api/handlers/admin_handler.rs` `src/interfaces/api/handlers/admin_handler.rs`
- `start_consistency_check(name, force)` — insert an - `start_consistency_check(name, force)` — insert an
`admin.background_runs` row with `job_name = 'consistency_<name>'` `jobs.recoverable_runs` row with `job_name = 'consistency_<name>'`
and `status = 'Running'`, spawn a tokio task calling `run_check`, and `status = 'Running'`, spawn a tokio task calling `run_check`,
return `run_id`. Concurrent triggers hit the partial unique index return `run_id`. Concurrent triggers hit the partial unique index
and short-circuit to returning the surviving row. and short-circuit to returning the surviving row.
@@ -651,7 +671,7 @@ In `AppServiceFactory` init, after DB pool is up:
```rust ```rust
sqlx::query!( sqlx::query!(
"UPDATE admin.background_runs "UPDATE jobs.recoverable_runs
SET status = 'Paused', SET status = 'Paused',
error_message = COALESCE(error_message, 'server restart mid-run') error_message = COALESCE(error_message, 'server restart mid-run')
WHERE job_name LIKE 'consistency_%' WHERE job_name LIKE 'consistency_%'
@@ -660,7 +680,7 @@ sqlx::query!(
``` ```
Filtering on `job_name LIKE 'consistency_%'` scopes the sweep to Filtering on `job_name LIKE 'consistency_%'` scopes the sweep to
consistency runs; other tenants of `background_runs` (storage consistency runs; other tenants of `recoverable_runs` (storage
migration, reextract-*) run the same auto-Pause sweep from their own migration, reextract-*) run the same auto-Pause sweep from their own
boot-time hook. The JobRegistry supervisor's boot check may boot-time hook. The JobRegistry supervisor's boot check may
generalise this into a single scheduler-wide sweep — until then, one generalise this into a single scheduler-wide sweep — until then, one
+185 -47
View File
@@ -115,8 +115,8 @@ are already there. Keeping it as its own loop is right.
of trash-cleanup + storage-usage + db_pool_monitor + dedup GC + of trash-cleanup + storage-usage + db_pool_monitor + dedup GC +
grant-cleanup + tree-etag flush + content-index. High mechanical grant-cleanup + tree-etag flush + content-index. High mechanical
payoff, zero new schema, minimal review surface. payoff, zero new schema, minimal review surface.
2. **Part 2 lands next** — introduces `admin.background_runs` schema 2. **Part 2 lands next** — introduces `jobs.recoverable_runs` schema
+ `RecoverableJob` trait + `JobStore` port + `run_or_resume`. On + `RecoverableJobHandler` trait + `JobStore` port + `run_or_resume`. On
its own PR (schema change deserves independent review). its own PR (schema change deserves independent review).
3. **Consistency-check framework (`docs/plan/consistency-check.md`)** 3. **Consistency-check framework (`docs/plan/consistency-check.md`)**
lands third, consuming Part 2 as its runtime. lands third, consuming Part 2 as its runtime.
@@ -432,16 +432,16 @@ into the scheduler.
## Part 2 — Recoverable-Run Engine ## Part 2 — Recoverable-Run Engine
### Contract — `RecoverableJob` trait ### Contract — `RecoverableJobHandler` trait
Sibling to `JobHandler`, NOT a subtrait. A stateless job that only Sibling to `JobHandler`, NOT a subtrait. A stateless job that only
implements `JobHandler` never needs to know Part 2 exists. implements `JobHandler` never needs to know Part 2 exists.
```rust ```rust
#[async_trait] #[async_trait]
pub trait RecoverableJob: Send + Sync { pub trait RecoverableJobHandler: Send + Sync {
/// Stable snake_case identifier — matches the `job_name` column /// Stable snake_case identifier — matches the `job_name` column
/// in `admin.background_runs`. /// in `jobs.recoverable_runs`.
fn name(&self) -> &str; fn name(&self) -> &str;
/// Long-running, cooperative scan. The store is the job's ONLY /// Long-running, cooperative scan. The store is the job's ONLY
@@ -479,13 +479,13 @@ pub enum RunOutcome {
### `JobStore` trait ### `JobStore` trait
The port the engine passes to a recoverable job. Backed by The port the engine passes to a recoverable job. Backed by
`admin.background_runs` in production; can be mocked for unit tests. `jobs.recoverable_runs` in production; can be mocked for unit tests.
```rust ```rust
#[async_trait] #[async_trait]
pub trait JobStore: Send + Sync { pub trait JobStore: Send + Sync {
/// The `run_id` this handler was invoked with. Uniquely identifies /// The `run_id` this handler was invoked with. Uniquely identifies
/// the row in `admin.background_runs`. /// the row in `jobs.recoverable_runs`.
fn run_id(&self) -> Uuid; fn run_id(&self) -> Uuid;
/// Fixed at run start; used by consistency checks (and any other /// Fixed at run start; used by consistency checks (and any other
@@ -517,12 +517,12 @@ instance) are separate traits the impl composes on top of `JobStore`.
`JobStore` itself carries no findings/severity concept — those are `JobStore` itself carries no findings/severity concept — those are
Layer C in the consistency-check plan, not the engine's concern. Layer C in the consistency-check plan, not the engine's concern.
### Schema — `admin.background_runs` ### Schema — `jobs.recoverable_runs`
```sql ```sql
CREATE SCHEMA IF NOT EXISTS admin; CREATE SCHEMA IF NOT EXISTS admin;
CREATE TABLE admin.background_runs ( CREATE TABLE jobs.recoverable_runs (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
job_name TEXT NOT NULL, job_name TEXT NOT NULL,
status TEXT NOT NULL, -- Running / Paused / CancelRequested / Completed / Failed status TEXT NOT NULL, -- Running / Paused / CancelRequested / Completed / Failed
@@ -536,10 +536,10 @@ CREATE TABLE admin.background_runs (
); );
CREATE UNIQUE INDEX one_active_run_per_job CREATE UNIQUE INDEX one_active_run_per_job
ON admin.background_runs (job_name) ON jobs.recoverable_runs (job_name)
WHERE status IN ('Running', 'Paused', 'CancelRequested'); WHERE status IN ('Running', 'Paused', 'CancelRequested');
CREATE INDEX ON admin.background_runs (last_progress_at) CREATE INDEX ON jobs.recoverable_runs (last_progress_at)
WHERE status = 'Running'; WHERE status = 'Running';
``` ```
@@ -549,9 +549,9 @@ so it survives concurrent triggers, admin-vs-scheduler races, and
transaction interleavings. The `CancelRequested` inclusion prevents transaction interleavings. The `CancelRequested` inclusion prevents
a second trigger during cancel from spawning a parallel run. a second trigger during cancel from spawning a parallel run.
`admin.*` is a NEW schema — kept distinct from `auth.*` / `storage.*` `jobs.*` is a NEW schema — kept distinct from `auth.*` / `storage.*` / `admin.*`
so operational tables don't pollute domain schemas. Consistency so operational tables don't pollute domain schemas. Consistency
checks own their own `admin.consistency_findings` in the same checks own their own `jobs.run_findings` in the same
schema. schema.
Cursor is `BYTEA`, not JSONB, because per-job cursors are fixed-shape Cursor is `BYTEA`, not JSONB, because per-job cursors are fixed-shape
@@ -579,7 +579,7 @@ One `UPDATE` per checkpoint. Cheap, no row-lock contention (this
process owns the row): process owns the row):
```sql ```sql
UPDATE admin.background_runs UPDATE jobs.recoverable_runs
SET cursor = $2, SET cursor = $2,
stats = jsonb_set( stats = jsonb_set(
stats, stats,
@@ -618,7 +618,7 @@ that's a success. Log lines stay meaningful (`outcome=ok`,
The engine module exposes: The engine module exposes:
```rust ```rust
pub async fn run_or_resume<J: RecoverableJob + ?Sized>( pub async fn run_or_resume<J: RecoverableJobHandler + ?Sized>(
job: Arc<J>, job: Arc<J>,
store_factory: &dyn JobStoreFactory, store_factory: &dyn JobStoreFactory,
) -> JobOutcome ) -> JobOutcome
@@ -671,7 +671,7 @@ At `AppServiceFactory` init, after DB pool is up:
```rust ```rust
sqlx::query!( sqlx::query!(
"UPDATE admin.background_runs "UPDATE jobs.recoverable_runs
SET status = 'Paused', SET status = 'Paused',
error_message = COALESCE(error_message, 'server restart mid-run') error_message = COALESCE(error_message, 'server restart mid-run')
WHERE status IN ('Running', 'CancelRequested')" WHERE status IN ('Running', 'CancelRequested')"
@@ -703,18 +703,28 @@ GET /api/admin/jobs/{name}/runs/{id}
### Native tenants (Part 2) ### Native tenants (Part 2)
- **Blob storage backend migration.** `migration_job.rs` becomes a Consistency checks are organized **by the subject they iterate**, not
`RecoverableJob` impl. Cursor = last processed blob hash. Retires by the concern they check. Cursor = row PK of that subject. Adding a
the `Arc<RwLock<MigrationState>>` in-memory struct. new check = adding a per-row branch inside the job that walks that
- **Reextract audio metadata.** Currently synchronous inside the subject. See memory `project_consistency_jobs_landscape` for the full
admin HTTP request. Becomes a `RecoverableJob` iterating audio rationale + the merges/separations that fall out of the rule.
files by `file_id`.
- **Reextract image/video capture dates.** Same as above. | Tenant | Iterates | Cursor | v1 checks | Notes |
- **Consistency-check runs.** Every `ConsistencyCheck` impl gets |---|---|---|---|---|
wrapped by a `RecoverableJob` adapter; the wrapper writes to | `drives_consistency` | `storage.drives` | drive UUID | `used_bytes` drift (drive + user envelope) | Shipped Slice 3. |
`admin.background_runs` via `JobStore`, and separately writes | `folders_consistency` | `storage.folders` | folder UUID | `parent_trashed_mismatch` (live folder under trashed parent), `path_mismatch`, `lpath_mismatch` — both materialised columns compared to parent-chain reconstruction | Shipped Slice 4. Room to grow: `drive_id_parent_mismatch`, `orphan_root` (self-join already loads the fields). |
findings to `admin.consistency_findings` via a check-specific | `files_consistency` | `storage.files` | file UUID | `parent_folder_trashed` (live file under trashed folder), `missing_blob` (severity `data_loss` — `blob_hash` present in neither `storage.blobs` nor `storage.chunk_manifests`), `chunk_missing` (severity `data_loss` — manifest exists but points at chunks absent from `storage.blobs`; typical dedup GC race), `blob_size_mismatch` (denormalised `files.size` diverges from the authoritative size — manifest first, blob fallback) | Shipped Slice 6, CDC-aware Slice 10. Handles both storage paths: `storage.chunk_manifests` (post-Apr-2026 FastCDC ingest, dominant path) and `storage.blobs` (pre-CDC whole-file blob, legacy fallback). Physical backend-existence checks (chunk bytes actually on disk) belong in `storage_consistency`. Room to grow: `drive_id_parent_mismatch`, mime-type reconciliation. |
extension trait. See `docs/plan/consistency-check.md`. | `storage_consistency` | Storage backend (fs / S3) | object key / path | Each blob has a `storage.blobs` row (orphan detection) | `?deep=true` adds re-BLAKE3 + mime sniff. Orphan-side of the old bidirectional blob check + former `blob_integrity` + former `thumbnail_consistency`. |
| `grants_consistency` (future) | `storage.role_grants` | grant UUID | subject/resource/granted_by exist | |
| `storage_migration` | `storage.blobs` (source) → target backend | blob hash | Copy bytes; failures → `stats.failed_blobs` (and eventually `jobs.run_findings`) | Retires `Arc<RwLock<MigrationState>>` in `migration_job.rs`. |
| `reextract_audio` | `storage.files` where audio | file UUID | Re-run audio-tag parser, upsert `audio_metadata` | Retires synchronous admin-request execution. |
| `reextract_image` | `storage.files` where image/video | file UUID | Re-run EXIF/container date parser, upsert capture date | Same shape as reextract_audio. |
| `consistency_batch` (wrapper) | Iterates registered `*_consistency` jobs | — (JobHandler, not RecoverableJobHandler) | Sequentially triggers each sub-job; `?deep=true` propagates | Shipped Slice 5. One-click "run all" without per-job clicks; exclusivity via `job_name` prevents concurrent batches from stepping on each other. Batch itself always returns `Ok` — child failures land in `outcome.extra.per_check[<name>].outcome`. |
**Not consistency**: `POST /api/admin/dedup/recalculate` is aggregate-
stats-only (`unique_blobs`, `total_references`, `bytes_saved`) — one
SELECT + one UPDATE. Kept as its own admin endpoint; do NOT fold into
`storage_consistency` (different semantic — recompute vs verify).
### Verification (Part 2) ### Verification (Part 2)
@@ -733,7 +743,7 @@ GET /api/admin/jobs/{name}/runs/{id}
6. **Idempotent replay:** for consistency-check specifically, verify 6. **Idempotent replay:** for consistency-check specifically, verify
that re-processing the last unpersisted batch does NOT double-record that re-processing the last unpersisted batch does NOT double-record
findings (`UNIQUE (run_id, kind, resource_id)` on findings (`UNIQUE (run_id, kind, resource_id)` on
`admin.consistency_findings`). `jobs.run_findings`).
7. **`RunOutcome` bridge log lines:** completed run logs 7. **`RunOutcome` bridge log lines:** completed run logs
`outcome=ok, extra.completed=true`; paused logs `outcome=ok, extra.completed=true`; paused logs
`outcome=ok, extra.paused=true`; failed logs `outcome=err, cause=handler`. `outcome=ok, extra.paused=true`; failed logs `outcome=err, cause=handler`.
@@ -790,26 +800,154 @@ endpoint returns a uniform `{ ok, outcome: JobOutcome }` envelope with
job-specific fields under `outcome.extra`. Any external caller reading job-specific fields under `outcome.extra`. Any external caller reading
the old fields needs updating. the old fields needs updating.
### Admin UI — /admin/jobs page (frontend, future slice)
Operators shouldn't have to `curl` these endpoints in production —
they need a UI. Ships as a SvelteKit route once the backend surface is
complete. Rough shape:
**Route:** `/admin/jobs` (SvelteKit page under `frontend/src/routes/admin/jobs/`).
**Access:** admin-only; same guard as the rest of `/admin/*`.
**Page layout — one table, one drawer:**
```
┌── Jobs ─────────────────────────────────────────────────────────────┐
│ Name Cadence Last run Status Actions │
│ ───────────────────────────────────────────────────────────────────│
│ trash_cleanup every 24 h 3h ago ok [Run] │
│ storage_reconcile every 10 m 4m ago ok [Run] │
│ dedup_gc on-demand 1d ago ok [Run] │
│ grant_cleanup every 24 h never — [Run] │
│ drives_consistency on-demand never — [Run] │
│ consistency_batch on-demand never — [Run] [Run deep] │
└─────────────────────────────────────────────────────────────────────┘
```
Row click opens a right-side drawer with:
- Full JSON of the last outcome (`extra` fields explained per-job).
- For recoverable jobs: run history table (`GET /jobs/{name}/runs`),
each row expandable to full `RunSummary` (cursor, stats, params,
error_message).
- Per-run actions: `Cancel` (for Running rows only), `Trigger resume`
(for Paused rows — same trigger endpoint, `run_or_resume` picks
up the cursor).
**Data flow:**
- `GET /api/admin/jobs` — populates the main table. Polled every 5 s
when the page is visible (`document.visibilityState`).
- `POST /api/admin/jobs/{name}/trigger` — the "Run" button. `deep=true`
query for the "Run deep" variant (currently only shown on
`consistency_batch`).
- `POST /api/admin/jobs/{name}/cancel` — Cancel button on a Running
recoverable run.
- `GET /api/admin/jobs/{name}/runs` — populates the history table when
the drawer opens.
- `GET /api/admin/jobs/{name}/runs/{id}` — populates the per-run
detail expander.
**No new backend endpoints required** — every screen is driven by
what already exists.
**Visual conventions:**
- Status colour: `ok` = green, `err` = red, `Running` = blue-pulse,
`Paused` = amber, `CancelRequested` = amber-flash, `Completed` =
neutral grey, `Failed` = red.
- Findings surfacing is live as of Slice 7 (`jobs.run_findings` +
`store.record_finding` + `GET /api/admin/jobs/{name}/runs/{id}/findings`).
Drawer's "Findings" tab renders `kind`, `severity`, `resource_id`,
and per-tenant `detail` JSON.
**Slice ordering:** frontend page is a follow-up PR, not blocking any
backend slice. Order of appearance:
1. Backend Part 2 slices (engine, admin surface, first tenant) — done.
2. `jobs.run_findings` table + `store.record_finding` API — done (Slice 7).
3. `consistency_batch` + more tenants — done (Slices 5–6: drives + folders + files, plus batch).
4. Frontend `/admin/jobs` page — takes the completed backend surface
as-is; no backend changes required by the UI landing.
5. Progress estimation on `RunSummary.progress` (`fraction`, `kind`,
`scanned`, `total`) — **done (Slice 9)**. Tenants that CAN count
their subject override `RecoverableJobHandler::count_total()`;
`run_or_resume` seeds `params.total_rows` + `params.progress_kind`
on fresh runs; `row_to_summary` derives the `progress` block at
serialisation time. UI renders a bar; `kind = "approximate"` runs
get a striped fill so operators recognise proxy-derived
estimates. See memory `project_job_progress_estimation`.
### Notifications & alerting
Silent failure is the enemy — a consistency check that finds a
data-loss finding at 3 AM Sunday should reach an operator, not sit in
the log stream unread. When SMTP is wired, the supervisor emits an
alert email on the following:
- **Any job dispatch returns `JobOutcome::Err`.** Applies to both
Part 1 handler errors and Part 2 recoverable `RunOutcome::Failed`
(which translates to `Err` via `run_or_resume`'s bridge). Subject
line: `[OxiCloud] Job <name> failed`. Body includes: job name,
cause (`handler|timeout|panicked`), error message, run_id (Part 2
only), elapsed_ms, log-timestamp for grep, link to
`/admin/jobs?highlight=<name>` when the UI lands.
- **Consistency check surfaces one or more findings** (deferred to
the `jobs.run_findings` migration). Applies only to `*_consistency`
tenants. Body includes: run_id, findings count grouped by
`(kind, severity)`, worst-severity example, link to
`/admin/jobs/{name}/runs/{id}` when the UI lands.
**Delivery conditions:**
- Silent no-op when `email_sender` on `AppState` is `None` (SMTP not
configured). No error, no log spam — the mechanism is opt-in
through SMTP presence.
- Recipient: every user with `role = 'admin'`. Not a hardcoded
address — same rule as any admin-scoped notification the codebase
already sends.
- Rate limit: **at most 1 email per (job_name, kind) per 6 hours**,
keyed off an in-memory dedup table on `AppState`. Prevents a
flapping job (fails, retries, fails, ...) from mailbombing.
6 h chosen to match the operator-attention interval — a real
ongoing failure gets 4 alerts/day, enough to be noticed, not
enough to be filtered.
- Configurable OFF per job via env: `OXICLOUD_JOB_<NAME>_ALERT_ON_FAIL=false`
(default `true`). Same shape as the existing enable/disable knobs.
**Implementation notes** (for whichever slice picks this up):
- Reuses `EmailSender` port + `MagicLinkInviteService`-style templating
under `askama`. New template files:
`templates/emails/job_failed.{html,txt}` and
`templates/emails/consistency_findings.{html,txt}`.
- Dedup table lives on `AppState.job_alert_dedup:
Arc<Mutex<HashMap<(String, String), Instant>>>`. Cleaned lazily on
insert.
- Called from `SchedulerEngine::log_outcome` (Part 1 path) and from
`run_or_resume`'s terminal-write branch (Part 2 path). Both already
see the `JobOutcome`; adding a fire-and-forget email dispatch is
~10 lines each.
**Scope-out:** no Slack / webhook / PagerDuty integration in v1.
Email is the ONE alert channel until an operator concretely asks for
another. Layering webhooks on top later is trivial — same
"terminal outcome → notification" hook, different sink.
### Config surface — env vars ### Config surface — env vars
Canonical form for every job (Part 1 or Part 2 alike, AND for core **No new convention.** Each service keeps its natural per-service
workers even though they don't register with the scheduler): prefix (`OXICLOUD_GRANT_CLEANUP_*`, `OXICLOUD_STORAGE_USAGE_*`, …).
The `GET /api/admin/jobs` endpoint already gives operators a runtime
view of every registered job's interval, so grepping env-var prefixes
is no longer the primary discovery path.
``` Earlier drafts proposed a uniform `OXICLOUD_JOB_<NAME>_INTERVAL_*`
OXICLOUD_JOB_<NAME>_ENABLED convention, with legacy names as warned aliases. Killed 2026-07-28
OXICLOUD_JOB_<NAME>_INTERVAL_HOURS # or _INTERVAL_SECS for sub-hour cadences (Ed): normalising only the interval knob while leaving domain-specific
OXICLOUD_JOB_<NAME>_<CUSTOM>... # e.g. _GRACE_HOURS, _BATCH_SIZE tunables (`GRACE_DAYS`, `BATCH_SIZE`, …) at the natural prefix creates
``` *intra-service* prefix drift — worse than the *cross-service* drift it
was meant to solve. A service either goes fully to `OXICLOUD_JOB_*`
(disruptive rename of every knob) or fully stays at its native prefix
(no rename). We stay.
Core workers reuse this naming purely for uniform operator ergonomics The one real gap is **trash_cleanup has no env var today** (hardcoded
(e.g. `OXICLOUD_JOB_TREE_ETAG_FLUSH_INTERVAL_MS`) — the convention is 24h in DI). Adding `OXICLOUD_TRASH_CLEANUP_INTERVAL_HOURS` when we
what operators grep for; whether the loop is scheduler-driven or a need it uses the natural prefix — no new convention needed.
dedicated `tokio::spawn` is an implementation detail they don't see.
Existing per-service env vars keep working as **aliases** during
migration — `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` reads first, falls
back to `OXICLOUD_JOB_GRANT_CLEANUP_INTERVAL_HOURS`. Deprecated aliases
warn once on startup and stay recognised through one minor version.
### Logging schema ### Logging schema
@@ -874,7 +1012,7 @@ precludes it.
### Job-history observability ### Job-history observability
`admin.background_runs` already carries the latest run per Part 2 job `jobs.recoverable_runs` already carries the latest run per Part 2 job
— "last run time + status" is a `SELECT DISTINCT ON (job_name) …` — "last run time + status" is a `SELECT DISTINCT ON (job_name) …`
query. Deeper history (retention window, per-run drill-down UI) is query. Deeper history (retention window, per-run drill-down UI) is
deferred; the log stream is the source of truth for older runs. deferred; the log stream is the source of truth for older runs.
@@ -889,7 +1027,7 @@ No such need today.
- **Cross-job dependencies.** Register-time ordering only, not runtime - **Cross-job dependencies.** Register-time ordering only, not runtime
graph. graph.
- **Retention pruning of terminal `background_runs` rows.** Deferred - **Retention pruning of terminal `recoverable_runs` rows.** Deferred
until the volume warrants a policy. until the volume warrants a policy.
- **Prometheus / OpenMetrics export.** Log-only for now. - **Prometheus / OpenMetrics export.** Log-only for now.
- **Distributed scheduling.** Single-process. If OxiCloud ever runs - **Distributed scheduling.** Single-process. If OxiCloud ever runs
+171
View File
@@ -0,0 +1,171 @@
/**
* Admin JobRegistry endpoints — `/api/admin/jobs*` (see
* `docs/plan/job-registry.md`). Powers the "Jobs" tab of the admin panel.
*
* Every mutation goes through the standard admin auth path (Bearer JWT
* + admin-middleware role check). Read endpoints are cheap enough to
* poll while the panel is open.
*/
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { Finding, JobOutcome, JobSummary, RunSummary } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
/**
* Envelope wrapping the outcome from `POST /api/admin/jobs/{name}/trigger`.
* `ok: true` means "dispatch reached the handler"; the handler's own
* pass/fail is in `outcome.outcome`. For `consistency_batch`, per-child
* outcomes are inside `outcome.extra.per_check`.
*/
export interface TriggerResponse {
ok: boolean;
outcome: JobOutcome;
}
/** Envelope from `POST /api/admin/jobs/{name}/cancel`. `run_id` is
* the id of the run whose `Running` status was flipped to
* `CancelRequested` (null when nothing was in flight to cancel). */
export interface CancelResponse {
ok: boolean;
run_id: string | null;
}
/**
* `GET /api/admin/jobs` — full registry snapshot. One row per registered
* job (periodic + recoverable + coordinators like `consistency_batch`,
* which register as plain JobHandlers).
*/
export function listJobs(): Promise<JobSummary[]> {
return apiJson<JobSummary[]>('/api/admin/jobs', { credentials: 'same-origin' });
}
/**
* `POST /api/admin/jobs/{name}/trigger?force=X&deep=X` — dispatch a job
* on-demand. `force` bypasses per-tenant idempotency checks (e.g.
* `trash_cleanup` skipping when nothing is due). `deep` opts into slow
* variants (currently only `storage_consistency`, propagated by
* `consistency_batch` to every child).
*
* Throws on 4xx / 5xx with the backend's error message when present.
* A 404 means the job name isn't registered — surface that specifically
* so callers can distinguish "typo" from "handler blew up".
*/
export async function triggerJob(
name: string,
opts: { force?: boolean; deep?: boolean } = {}
): Promise<TriggerResponse> {
const params = new URLSearchParams();
if (opts.force) params.set('force', 'true');
if (opts.deep) params.set('deep', 'true');
const q = params.toString();
const url = `/api/admin/jobs/${encodeURIComponent(name)}/trigger${q ? `?${q}` : ''}`;
const res = await apiFetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() }
});
if (!res.ok) {
let msg = `trigger failed: ${res.status}`;
try {
const body = (await res.json()) as { error?: string; message?: string };
msg = body.error ?? body.message ?? msg;
} catch {
/* no JSON body */
}
throw new Error(msg);
}
return (await res.json()) as TriggerResponse;
}
/**
* `POST /api/admin/jobs/{name}/cancel` — cooperatively request cancel
* of the currently running instance. The handler observes it on its
* next `store.status()` poll and returns `RunOutcome::Paused` at the
* next safe boundary. If nothing is running, this is a no-op that
* returns `run_id: null`.
*/
export async function cancelJob(name: string): Promise<CancelResponse> {
const res = await apiFetch(`/api/admin/jobs/${encodeURIComponent(name)}/cancel`, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() }
});
if (!res.ok) {
let msg = `cancel failed: ${res.status}`;
try {
const body = (await res.json()) as { error?: string; message?: string };
msg = body.error ?? body.message ?? msg;
} catch {
/* no JSON body */
}
throw new Error(msg);
}
return (await res.json()) as CancelResponse;
}
/**
* `GET /api/admin/jobs/{name}/runs?limit=N` — history of recoverable
* runs for `name`, newest first. Backend caps `limit` at 100.
*/
export function listRuns(name: string, limit = 20): Promise<RunSummary[]> {
return apiJson<RunSummary[]>(`/api/admin/jobs/${encodeURIComponent(name)}/runs?limit=${limit}`, {
credentials: 'same-origin'
});
}
/** Envelope from `POST /api/admin/jobs/runs/purge`. `purged` is
* the count of terminal-run rows deleted (findings cascade with
* their parent run via the FK, no separate counter). */
export interface PurgeResponse {
purged: number;
retention_days: number;
}
/**
* `POST /api/admin/jobs/runs/purge?days=N` — operator-triggered
* retention cleanup. Deletes terminal runs (`Completed`, `Failed`)
* with `completed_at` older than `days` days ago; associated
* `jobs.run_findings` rows drop with them via CASCADE. Non-terminal
* runs (`Running`, `Paused`, `CancelRequested`) are ALWAYS
* preserved regardless of age.
*
* Backend enforces a minimum of 1 day defensively.
*/
export async function purgeJobRuns(days = 30): Promise<PurgeResponse> {
const res = await apiFetch(`/api/admin/jobs/runs/purge?days=${days}`, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() }
});
if (!res.ok) {
let msg = `purge failed: ${res.status}`;
try {
const body = (await res.json()) as { error?: string; message?: string };
msg = body.error ?? body.message ?? msg;
} catch {
/* no JSON body */
}
throw new Error(msg);
}
return (await res.json()) as PurgeResponse;
}
/**
* `GET /api/admin/jobs/{name}/runs/{id}/findings?limit=N&offset=M` —
* paginated findings for a specific run. Empty list = clean run,
* 404 = unknown run id.
*/
export function listFindings(
name: string,
runId: string,
opts: { limit?: number; offset?: number } = {}
): Promise<Finding[]> {
const params = new URLSearchParams();
params.set('limit', String(opts.limit ?? 100));
if (opts.offset) params.set('offset', String(opts.offset));
return apiJson<Finding[]>(
`/api/admin/jobs/${encodeURIComponent(name)}/runs/${encodeURIComponent(runId)}/findings?${params}`,
{ credentials: 'same-origin' }
);
}
+99
View File
@@ -522,3 +522,102 @@ export interface FolderAncestorsResponse {
ancestors: FolderAncestor[]; ancestors: FolderAncestor[];
access_source: AccessSource; access_source: AccessSource;
} }
// ─── Job registry (Part 1 + Part 2) ────────────────────────────────────────
//
// Maps `src/infrastructure/scheduler/*` DTOs 1:1. See
// `docs/plan/job-registry.md` for the backend contract; the shapes below
// are what the `/api/admin/jobs*` endpoints emit.
/**
* `JobOutcome` — the uniform outcome the scheduler logs and stores for
* every job dispatch. Serialised with `#[serde(tag = "outcome")]` so the
* discriminant is the `outcome` field, not the object key.
*/
export type JobOutcome =
| { outcome: 'ok'; count: number; extra?: unknown }
| { outcome: 'err'; message: string };
/**
* `JobSummary` — one row per registered job in `GET /api/admin/jobs`.
* Cadence + last-run bookkeeping. `interval_ms` / `next_run_at` are
* `undefined` on on-demand jobs (serde skips `Option::None`).
*/
export interface JobSummary {
name: string;
interval_ms?: number;
next_run_at?: string;
last_run_at?: string;
last_outcome?: JobOutcome;
running: boolean;
}
/**
* `RunStatus` values allowed in `jobs.recoverable_runs.status`. The
* non-terminal set (Running / Paused / CancelRequested) is what the
* DB's `one_active_run_per_job` partial unique index scopes.
*/
export type RunStatus = 'Running' | 'Paused' | 'CancelRequested' | 'Completed' | 'Failed';
/**
* `RunSummary` — one row per recoverable-job run from
* `GET /api/admin/jobs/{name}/runs`. Terminal + non-terminal rows both
* appear. `stats` / `params` are opaque JSON — job-specific shape;
* consumers should key off `job_name` to decide what to render.
* `cursor_hex` is present only when the run has advanced past the
* initial state (paused mid-scan is the typical case).
*/
export interface RunSummary {
id: string;
job_name: string;
status: RunStatus;
started_at: string;
last_progress_at: string;
completed_at?: string;
stats: Record<string, unknown>;
params: Record<string, unknown>;
cursor_hex?: string;
error_message?: string;
/** Populated when the tenant reported a countable subject at run
* start (`RecoverableJobHandler::count_total`). Absent when the
* tenant can't count — the UI hides the progress bar and falls
* back to raw `scanned_count`. */
progress?: RunProgress;
}
/**
* Confidence level of a `RunProgress` fraction. Wire lowercase per
* the `#[serde(rename_all = "lowercase")]` on the Rust enum.
*
* - `count` — `scanned_count / total_rows` where `total_rows` came
* from a definitive `COUNT(*)` on the subject table.
* - `approximate` — proxy-derived total (e.g. `storage_consistency`
* using DB blob count as a stand-in for backend object count).
* Fraction can legitimately exceed 1.0 at run end — the deviation
* quantifies the drift the check is looking for.
*/
export type ProgressKind = 'count' | 'approximate';
export interface RunProgress {
fraction: number;
kind: ProgressKind;
scanned: number;
total: number;
}
/**
* `Finding` — one row from `GET /api/admin/jobs/{name}/runs/{id}/findings`.
* Persisted by consistency tenants via `store.record_finding()`. Consumers
* key off `kind` to know the shape of `detail` (per-tenant JSON — e.g.
* `stale_used_bytes` carries `{cached, actual, delta}`; `missing_blob`
* carries `{blob_hash}`; …).
*/
export interface Finding {
id: string;
run_id: string;
kind: string;
severity: string;
resource_id?: string;
detail: Record<string, unknown>;
created_at: string;
}
File diff suppressed because it is too large Load Diff
+139 -16
View File
@@ -32,15 +32,12 @@
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte')); const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
interface NavLink { interface NavLink {
href: /**
| '/files' * String rather than a literal union so admin links (which
| '/shared' * include a dynamic path segment) can share the same shape.
| '/shared-with-me' * `resolve()` accepts any string, so no type-level cost.
| '/recent' */
| '/favorites' href: string;
| '/photos'
| '/music'
| '/trash';
label: string; label: string;
icon: string; icon: string;
/** Stable key driving the per-section icon colour (see sidebar.css). */ /** Stable key driving the per-section icon colour (see sidebar.css). */
@@ -69,12 +66,103 @@
{ href: '/trash', label: t('nav.trash', 'Trash'), icon: 'trash', section: 'trash' } { href: '/trash', label: t('nav.trash', 'Trash'), icon: 'trash', section: 'trash' }
]; ];
// Admin sidebar — populated when the URL is under /admin. The
// admin +page.svelte used to render its own horizontal tab
// strip; that was displaced here so the section navigation
// scales past ~7 items and matches deep-link URLs from the
// address bar.
const ADMIN_LINKS: NavLink[] = [
{
href: '/admin',
label: t('admin.dashboard', 'Dashboard'),
icon: 'chart-pie',
section: 'admin-dashboard'
},
{
href: '/admin/users',
label: t('admin.users', 'Users'),
icon: 'users',
section: 'admin-users'
},
{
href: '/admin/drives',
label: t('admin.drives', 'Drives'),
icon: 'folder',
section: 'admin-drives'
},
{
href: '/admin/mounts',
label: t('admin.mounts', 'External Mounts'),
icon: 'folder',
section: 'admin-mounts'
},
{
href: '/admin/oidc',
label: t('admin.oidc', 'OIDC / SSO'),
icon: 'key',
section: 'admin-oidc'
},
{
href: '/admin/storage',
label: t('admin.storage_tab', 'Storage'),
icon: 'database',
section: 'admin-storage'
},
{
href: '/admin/smtp',
label: t('admin.smtp', 'Email (SMTP)'),
icon: 'envelope',
section: 'admin-smtp'
},
{
href: '/admin/plugins',
label: t('admin.plugins', 'Plugins'),
icon: 'layer-group',
section: 'admin-plugins'
},
{
href: '/admin/jobs',
label: t('admin.jobs.tab', 'Jobs'),
icon: 'cogs',
section: 'admin-jobs'
}
];
const isAdmin = $derived(session.user?.role === 'admin'); const isAdmin = $derived(session.user?.role === 'admin');
// Any URL under /admin swaps the sidebar to admin mode. Uses
// startsWith so a trailing slash / query params / hash don't
// desync. Root `/admin` counts too (dashboard).
const isAdminSection = $derived(page.url.pathname.startsWith('/admin'));
const currentLinks = $derived(isAdminSection ? ADMIN_LINKS : LINKS);
function active(href: string): boolean { function active(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`); return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
} }
// Sidebar-item active check. Non-admin links use `active()`
// (matches href + any subpath). Admin links need a stricter
// rule for `/admin` itself — a plain `startsWith('/admin/')`
// would light up the Dashboard item on `/admin/drives` too.
// So `/admin` matches ONLY the exact path; every other admin
// item uses the same startsWith rule as before.
function activeLink(href: string): boolean {
if (href === '/admin') return page.url.pathname === '/admin';
return active(href);
}
/**
* Data-driven sidebar links (`LINKS`, `ADMIN_LINKS`) hold
* runtime strings, not compile-time route keys. SvelteKit's
* typed `resolve()` refuses them; we know they're valid
* routes at runtime. Cast at the callsite, one place, so
* the template stays clean.
*/
function navHref(href: string): string {
// @ts-expect-error runtime-known route string, not a literal typed key
return resolve(href);
}
// ── Sidebar drop targets ───────────────────────────────────────────────── // ── Sidebar drop targets ─────────────────────────────────────────────────
// The row-drag on `/files` (and other resource surfaces) sets a // The row-drag on `/files` (and other resource surfaces) sets a
// `application/x-oxi-item` MIME with a JSON array of `{ id, name, kind }`. // `application/x-oxi-item` MIME with a JSON array of `{ id, name, kind }`.
@@ -467,16 +555,50 @@
<div class="app-name">OxiCloud</div> <div class="app-name">OxiCloud</div>
</a> </a>
<nav class="nav-menu" aria-label={t('nav.primary', 'Primary')}> <nav
{#each LINKS as link (link.href)} class="nav-menu"
{@const isDropTarget = link.href === '/favorites' || link.href === '/trash'} class:nav-menu--admin={isAdminSection}
aria-label={isAdminSection
? t('nav.admin_sections', 'Admin sections')
: t('nav.primary', 'Primary')}
>
{#if isAdminSection}
<!--
"Back to app" escape hatch — the sidebar swaps to
admin mode when the URL is under /admin/*, so the
usual Files/Shared links disappear. A dedicated back
entry gives the operator a one-click way out without
having to hunt through the user menu.
-->
<a
class="nav-item nav-item--back"
href={resolve('/files')}
data-testid="appshell-nav-admin-back-link"
onclick={() => (sidebarOpen = false)}
>
<Icon name="arrow-left" />
<span>{t('nav.back_to_app', 'Back to OxiCloud')}</span>
</a>
{/if}
<!--
Sidebar links come from data-driven arrays (LINKS,
ADMIN_LINKS), so hrefs are runtime strings rather than
compile-time-known route keys. `navHref()` internally
routes them through `resolve()`, but ESLint's static
check can't see through the wrapper. Disabling the
rule here rather than sprinkling per-line directives.
-->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
{#each currentLinks as link (link.href)}
{@const isDropTarget =
!isAdminSection && (link.href === '/favorites' || link.href === '/trash')}
<a <a
class="nav-item" class="nav-item"
class:active={active(link.href)} class:active={activeLink(link.href)}
class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href} class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href}
href={resolve(link.href)} href={navHref(link.href)}
data-section={link.section} data-section={link.section}
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`} data-testid={`appshell-nav-${link.section}-link`}
onclick={() => (sidebarOpen = false)} onclick={() => (sidebarOpen = false)}
ondragover={isDropTarget ? (e) => sidebarOnDragOver(e, link.href) : undefined} ondragover={isDropTarget ? (e) => sidebarOnDragOver(e, link.href) : undefined}
ondragleave={isDropTarget ? () => sidebarOnDragLeave(link.href) : undefined} ondragleave={isDropTarget ? () => sidebarOnDragLeave(link.href) : undefined}
@@ -485,10 +607,11 @@
<Icon name={link.icon} /> <Icon name={link.icon} />
<span>{link.label}</span> <span>{link.label}</span>
</a> </a>
{#if link.href === '/files' && !session.isExternalUser} {#if !isAdminSection && link.href === '/files' && !session.isExternalUser}
<DrivePicker onnavigate={() => (sidebarOpen = false)} /> <DrivePicker onnavigate={() => (sidebarOpen = false)} />
{/if} {/if}
{/each} {/each}
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</nav> </nav>
{#if session.user} {#if session.user}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { errorMessage, errorToast } from '$lib/utils/errors'; import { errorMessage, errorToast } from '$lib/utils/errors';
import { dateTimeFormatFor } from '$lib/utils/display'; import { dateTimeFormatFor } from '$lib/utils/display';
import { import {
@@ -76,6 +77,7 @@
DrivePoliciesPartial, DrivePoliciesPartial,
User User
} from '$lib/api/types'; } from '$lib/api/types';
import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte'; import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte';
@@ -172,8 +174,69 @@
} }
} }
type Tab = 'dashboard' | 'users' | 'drives' | 'mounts' | 'plugins' | 'oidc' | 'storage' | 'smtp'; type Tab =
let tab = $state<Tab>('dashboard'); | 'dashboard'
| 'users'
| 'drives'
| 'mounts'
| 'plugins'
| 'oidc'
| 'storage'
| 'smtp'
| 'jobs';
const VALID_TABS: readonly Tab[] = [
'dashboard',
'users',
'drives',
'mounts',
'plugins',
'oidc',
'storage',
'smtp',
'jobs'
];
function parseTab(raw: string | undefined): Tab {
return VALID_TABS.includes(raw as Tab) ? (raw as Tab) : 'dashboard';
}
// URL is the source of truth for the current tab. Navigation
// is via the AppShell sidebar (see `ADMIN_LINKS` there);
// this page just reads `page.params.tab` and renders the
// matching content block. Unidirectional URL→state means no
// `$effect` loop is even possible.
const tab = $derived<Tab>(parseTab(page.params.tab));
/**
* Human-readable label for the current section — feeds the
* page title (`Admin › Jobs · OxiCloud`) and the h1. Kept in
* sync with the `ADMIN_LINKS` labels in AppShell manually
* (small list, unlikely to drift). Extracting to a shared
* module would be over-engineering for 9 strings.
*/
const tabLabel = $derived.by<string>(() => {
switch (tab) {
case 'dashboard':
return t('admin.dashboard', 'Dashboard');
case 'users':
return t('admin.users', 'Users');
case 'drives':
return t('admin.drives', 'Drives');
case 'mounts':
return t('admin.mounts', 'External Mounts');
case 'plugins':
return t('admin.plugins', 'Plugins');
case 'oidc':
return t('admin.oidc', 'OIDC / SSO');
case 'storage':
return t('admin.storage_tab', 'Storage');
case 'smtp':
return t('admin.smtp', 'Email (SMTP)');
case 'jobs':
return t('admin.jobs.tab', 'Jobs');
}
});
// Dashboard // Dashboard
let dashboard = $state<AdminDashboard | null>(null); let dashboard = $state<AdminDashboard | null>(null);
@@ -1462,7 +1525,8 @@
plugins: false, plugins: false,
oidc: false, oidc: false,
storage: false, storage: false,
smtp: false smtp: false,
jobs: false
}); });
$effect(() => { $effect(() => {
@@ -1493,7 +1557,9 @@
}); });
</script> </script>
<svelte:head><title>{t('admin.title', 'Admin')} · OxiCloud</title></svelte:head> <svelte:head>
<title>{t('admin.title', 'Admin')} › {tabLabel} · OxiCloud</title>
</svelte:head>
{#snippet envBadge(on: boolean)} {#snippet envBadge(on: boolean)}
{#if on} {#if on}
@@ -1503,83 +1569,20 @@
{/if} {/if}
{/snippet} {/snippet}
<!--
The horizontal tab-bar that used to live here was displaced into
the shared AppShell sidebar (context-aware — swaps to admin
sections when the URL is under /admin/*). This page now renders
ONLY the tab content; navigation is via the sidebar + URL. See
`lib/components/AppShell.svelte` (ADMIN_LINKS).
-->
<main class="admin"> <main class="admin">
<h1>{t('admin.title', 'Admin')}</h1> <!--
H1 shows the current section since the sidebar is what
<div class="tabs" role="tablist"> communicates which admin area we're in — the plain "Admin"
<button h1 was informationless once the tab bar moved out.
role="tab" -->
data-testid="admin-dashboard-tab" <h1>{tabLabel}</h1>
aria-selected={tab === 'dashboard'}
onclick={() => (tab = 'dashboard')}
>
<Icon name="chart-pie" />
{t('admin.dashboard', 'Dashboard')}
</button>
<button
role="tab"
data-testid="admin-users-tab"
aria-selected={tab === 'users'}
onclick={() => (tab = 'users')}
>
<Icon name="users" />
{t('admin.users', 'Users')}
</button>
<button
role="tab"
data-testid="admin-drives-tab"
aria-selected={tab === 'drives'}
onclick={() => (tab = 'drives')}
>
<Icon name="folder" />
{t('admin.drives', 'Drives')}
</button>
<button
role="tab"
data-testid="admin-mounts-tab"
aria-selected={tab === 'mounts'}
onclick={() => (tab = 'mounts')}
>
<Icon name="folder" />
{t('admin.mounts', 'External Mounts')}
</button>
<button
role="tab"
data-testid="admin-oidc-tab"
aria-selected={tab === 'oidc'}
onclick={() => (tab = 'oidc')}
>
<Icon name="key" />
{t('admin.oidc', 'OIDC / SSO')}
</button>
<button
role="tab"
data-testid="admin-storage-tab"
aria-selected={tab === 'storage'}
onclick={() => (tab = 'storage')}
>
<Icon name="database" />
{t('admin.storage_tab', 'Storage')}
</button>
<button
role="tab"
data-testid="admin-smtp-tab"
aria-selected={tab === 'smtp'}
onclick={() => (tab = 'smtp')}
>
<Icon name="envelope" />
{t('admin.smtp', 'Email (SMTP)')}
</button>
<button
role="tab"
data-testid="admin-plugins-tab"
aria-selected={tab === 'plugins'}
onclick={() => (tab = 'plugins')}
>
<Icon name="layer-group" />
{t('admin.plugins', 'Plugins')}
</button>
</div>
{#if tab === 'dashboard'} {#if tab === 'dashboard'}
{#if dashboardError} {#if dashboardError}
@@ -2787,6 +2790,8 @@
</tbody> </tbody>
</table> </table>
{/if} {/if}
{:else if tab === 'jobs'}
<AdminJobsPanel />
{:else if !pluginsAvailable} {:else if !pluginsAvailable}
<p class="status">{t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}</p> <p class="status">{t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}</p>
{:else if pluginsError} {:else if pluginsError}
@@ -4092,26 +4097,6 @@
gap: 1rem; gap: 1rem;
} }
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.tabs button {
padding: 0.5rem 1rem;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button[aria-selected='true'] {
color: var(--color-text);
border-bottom-color: var(--color-primary);
}
.bar { .bar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -1,12 +1,46 @@
import { it, expect, vi, beforeEach } from 'vitest'; import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({ const { session, ui, pageState } = vi.hoisted(() => ({
session: { user: { id: '1', username: 'admin', role: 'admin' } }, session: { user: { id: '1', username: 'admin', role: 'admin' } },
ui: { notify: vi.fn() } ui: { notify: vi.fn() },
// Mock of SvelteKit's `$app/state` `page` — post-URL-routing
// the admin page reads `page.params.tab` to derive which
// section to render. Tests set the tab via `setTab(...)`
// BEFORE `render(AdminPage)`; the derived picks it up on
// initial mount. Previously the tab was chosen by clicking a
// horizontal-tab button that no longer exists.
pageState: {
page: {
url: new URL('http://localhost/admin'),
params: {} as Record<string, string | undefined>,
route: { id: '/admin/[[tab]]' },
status: 200,
error: null,
data: {},
form: null,
state: {}
}
}
})); }));
vi.mock('$lib/stores/session.svelte', () => ({ session })); vi.mock('$lib/stores/session.svelte', () => ({ session }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui })); vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$app/state', () => pageState);
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/paths', () => ({ base: '', resolve: (r: string) => r }));
/**
* Set the current tab BEFORE calling `render(AdminPage)`. The
* admin page reads the URL-derived tab in a `$derived`, which
* captures the value at first render — mutating this mock later
* doesn't retrigger. Tests that exercise multiple tabs render
* once per tab (each in a fresh `render` call — @testing-library
* unmounts between tests via its `beforeEach` cleanup).
*/
function setTab(tab: string | undefined) {
pageState.page.params = tab ? { tab } : {};
pageState.page.url = new URL(`http://localhost/admin${tab ? '/' + tab : ''}`);
}
vi.mock('$lib/api/endpoints/admin', () => ({ vi.mock('$lib/api/endpoints/admin', () => ({
clearPluginLogs: vi.fn(), clearPluginLogs: vi.fn(),
createExternalMount: vi.fn(), createExternalMount: vi.fn(),
@@ -87,6 +121,10 @@ const mount = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Reset the tab mock so a test that sets `setTab('users')`
// doesn't leak into the next test (default = /admin →
// dashboard).
setTab(undefined);
m(admin.getDashboard).mockResolvedValue(dashboard); m(admin.getDashboard).mockResolvedValue(dashboard);
m(admin.listUsers).mockResolvedValue({ total: 1, users: [user] }); m(admin.listUsers).mockResolvedValue({ total: 1, users: [user] });
m(admin.listPlugins).mockResolvedValue({ available: true, enabled: true, plugins: [] }); m(admin.listPlugins).mockResolvedValue({ available: true, enabled: true, plugins: [] });
@@ -136,8 +174,8 @@ it('toggles registration from the dashboard', async () => {
it('loads users when the users tab is opened and creates a user', async () => { it('loads users when the users tab is opened and creates a user', async () => {
m(admin.createUser).mockResolvedValue(undefined); m(admin.createUser).mockResolvedValue(undefined);
setTab('users');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await waitFor(() => expect(admin.listUsers).toHaveBeenCalled()); await waitFor(() => expect(admin.listUsers).toHaveBeenCalled());
await fireEvent.click(await screen.findByTestId('admin-users-create-btn')); await fireEvent.click(await screen.findByTestId('admin-users-create-btn'));
await fireEvent.input(await screen.findByTestId('admin-create-user-username-input'), { await fireEvent.input(await screen.findByTestId('admin-create-user-username-input'), {
@@ -151,33 +189,33 @@ it('loads users when the users tab is opened and creates a user', async () => {
}); });
it('loads OIDC settings when the OIDC tab is opened', async () => { it('loads OIDC settings when the OIDC tab is opened', async () => {
setTab('oidc');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
await waitFor(() => expect(admin.getOidcSettings).toHaveBeenCalled()); await waitFor(() => expect(admin.getOidcSettings).toHaveBeenCalled());
}); });
it('loads storage + migration when the storage tab is opened', async () => { it('loads storage + migration when the storage tab is opened', async () => {
setTab('storage');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
await waitFor(() => expect(admin.getStorageSettings).toHaveBeenCalled()); await waitFor(() => expect(admin.getStorageSettings).toHaveBeenCalled());
await waitFor(() => expect(admin.getMigration).toHaveBeenCalled()); await waitFor(() => expect(admin.getMigration).toHaveBeenCalled());
}); });
it('loads SMTP info when the SMTP tab is opened', async () => { it('loads SMTP info when the SMTP tab is opened', async () => {
setTab('smtp');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
await waitFor(() => expect(admin.getSmtpInfo).toHaveBeenCalled()); await waitFor(() => expect(admin.getSmtpInfo).toHaveBeenCalled());
}); });
it('loads plugins when the plugins tab is opened', async () => { it('loads plugins when the plugins tab is opened', async () => {
setTab('plugins');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-plugins-tab'));
await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled()); await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled());
}); });
it('loads external mounts when the mounts tab is opened and lists them', async () => { it('loads external mounts when the mounts tab is opened and lists them', async () => {
setTab('mounts');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-mounts-tab'));
await waitFor(() => expect(admin.listExternalMounts).toHaveBeenCalled()); await waitFor(() => expect(admin.listExternalMounts).toHaveBeenCalled());
// The configured mount is rendered in the table. // The configured mount is rendered in the table.
expect(await screen.findByText('Media')).toBeTruthy(); expect(await screen.findByText('Media')).toBeTruthy();
@@ -194,8 +232,8 @@ it('creates a mount from the mounts form', async () => {
mount_path: 'Personal/Photos', mount_path: 'Personal/Photos',
config: { path: '/srv/photos', read_only: false } config: { path: '/srv/photos', read_only: false }
}); });
setTab('mounts');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-mounts-tab'));
await fireEvent.input(await screen.findByTestId('mount-name'), { await fireEvent.input(await screen.findByTestId('mount-name'), {
target: { value: 'Photos' } target: { value: 'Photos' }
}); });
@@ -212,8 +250,8 @@ it('creates a mount from the mounts form', async () => {
it('deletes a mount through the confirm modal', async () => { it('deletes a mount through the confirm modal', async () => {
m(admin.deleteExternalMount).mockResolvedValue(undefined); m(admin.deleteExternalMount).mockResolvedValue(undefined);
setTab('mounts');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-mounts-tab'));
await fireEvent.click(await screen.findByTestId('mount-delete')); await fireEvent.click(await screen.findByTestId('mount-delete'));
// deleteMount() gates on the styled confirm modal. // deleteMount() gates on the styled confirm modal.
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn')); await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
@@ -222,8 +260,8 @@ it('deletes a mount through the confirm modal', async () => {
it("toggles a user's role through the confirm modal", async () => { it("toggles a user's role through the confirm modal", async () => {
m(admin.setUserRole).mockResolvedValue(undefined); m(admin.setUserRole).mockResolvedValue(undefined);
setTab('users');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await fireEvent.click(await screen.findByTestId('admin-user-toggle-role-u1')); await fireEvent.click(await screen.findByTestId('admin-user-toggle-role-u1'));
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn')); await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
await waitFor(() => expect(admin.setUserRole).toHaveBeenCalledWith('u1', 'admin')); await waitFor(() => expect(admin.setUserRole).toHaveBeenCalledWith('u1', 'admin'));
@@ -231,8 +269,8 @@ it("toggles a user's role through the confirm modal", async () => {
it('deactivates a user through the confirm modal', async () => { it('deactivates a user through the confirm modal', async () => {
m(admin.setUserActive).mockResolvedValue(undefined); m(admin.setUserActive).mockResolvedValue(undefined);
setTab('users');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
await fireEvent.click(await screen.findByTestId('admin-user-toggle-active-u1')); await fireEvent.click(await screen.findByTestId('admin-user-toggle-active-u1'));
await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn')); await fireEvent.click(await screen.findByTestId('admin-confirm-ok-btn'));
await waitFor(() => expect(admin.setUserActive).toHaveBeenCalledWith('u1', false)); await waitFor(() => expect(admin.setUserActive).toHaveBeenCalledWith('u1', false));
@@ -240,8 +278,8 @@ it('deactivates a user through the confirm modal', async () => {
it('saves OIDC settings from the OIDC form', async () => { it('saves OIDC settings from the OIDC form', async () => {
m(admin.saveOidc).mockResolvedValue(undefined); m(admin.saveOidc).mockResolvedValue(undefined);
setTab('oidc');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
await fireEvent.input(await screen.findByTestId('admin-oidc-issuer-input'), { await fireEvent.input(await screen.findByTestId('admin-oidc-issuer-input'), {
target: { value: 'https://idp.test' } target: { value: 'https://idp.test' }
}); });
@@ -251,8 +289,8 @@ it('saves OIDC settings from the OIDC form', async () => {
it('sends an SMTP test email', async () => { it('sends an SMTP test email', async () => {
m(admin.sendSmtpTest).mockResolvedValue({ ok: true } as never); m(admin.sendSmtpTest).mockResolvedValue({ ok: true } as never);
setTab('smtp');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
await fireEvent.input(await screen.findByTestId('admin-smtp-to-input'), { await fireEvent.input(await screen.findByTestId('admin-smtp-to-input'), {
target: { value: 'to@x.test' } target: { value: 'to@x.test' }
}); });
@@ -263,8 +301,8 @@ it('sends an SMTP test email', async () => {
it('saves storage settings and starts a migration', async () => { it('saves storage settings and starts a migration', async () => {
m(admin.saveStorage).mockResolvedValue(undefined); m(admin.saveStorage).mockResolvedValue(undefined);
m(admin.migrationAction).mockResolvedValue(undefined); m(admin.migrationAction).mockResolvedValue(undefined);
setTab('storage');
render(AdminPage); render(AdminPage);
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
await fireEvent.submit(await screen.findByTestId('admin-storage-form')); await fireEvent.submit(await screen.findByTestId('admin-storage-form'));
await waitFor(() => expect(admin.saveStorage).toHaveBeenCalled()); await waitFor(() => expect(admin.saveStorage).toHaveBeenCalled());
await fireEvent.click(await screen.findByTestId('admin-migration-start-btn')); await fireEvent.click(await screen.findByTestId('admin-migration-start-btn'));
+1 -1
View File
@@ -806,7 +806,7 @@
<style> <style>
/* Filter cluster lives inside ResourceList's action-bar snippet now, /* Filter cluster lives inside ResourceList's action-bar snippet now,
but the actual DOM is scoped to THIS component's <style> block — but the actual DOM is scoped to THIS component's \3c style> block —
Svelte's scoped selectors still apply because these are declared Svelte's scoped selectors still apply because these are declared
with the elements they style below. with the elements they style below.
+70 -1
View File
@@ -80,6 +80,8 @@
"trash": "Trash", "trash": "Trash",
"groups": "Groups", "groups": "Groups",
"primary": "Primary", "primary": "Primary",
"admin_sections": "Admin sections",
"back_to_app": "Back to OxiCloud",
"profile": "Profile", "profile": "Profile",
"shared_with_me": "Shared with me", "shared_with_me": "Shared with me",
"toggle": "Toggle navigation menu" "toggle": "Toggle navigation menu"
@@ -1191,7 +1193,74 @@
"delete_user_title": "Delete user", "delete_user_title": "Delete user",
"delete_user_warning": "You are about to permanently delete \"{{name}}\". This will remove the account, revoke every session, and reap the personal drive. This cannot be undone.", "delete_user_warning": "You are about to permanently delete \"{{name}}\". This will remove the account, revoke every session, and reap the personal drive. This cannot be undone.",
"delete_user_confirm_hint": "To confirm, type the account email below: {{email}}", "delete_user_confirm_hint": "To confirm, type the account email below: {{email}}",
"deleting": "Deleting…" "deleting": "Deleting…",
"jobs": {
"tab": "Jobs",
"title": "Jobs",
"hint": "Fires periodic + on-demand jobs. Consistency checks are safe to run at any time — they are read-only.",
"run_all_consistency": "Run all consistency checks",
"run_deep": "Run deep",
"run_deep_hint": "Also runs slow variants (blob re-hash, bitrot detection).",
"col_name": "Name",
"col_cadence": "Cadence",
"col_last_run": "Last run",
"col_outcome": "Outcome",
"col_state": "State",
"col_actions": "Actions",
"col_started_at": "Started",
"col_status": "Status",
"col_duration": "Duration",
"col_scanned": "Scanned",
"col_progress": "Progress",
"col_findings": "Findings",
"progress_exact_tooltip": "{{pct}} ({{scanned}} / {{total}})",
"progress_approx_tooltip": "{{pct}} ({{scanned}} / {{total}} — approximate, backend proxy)",
"progress_scanned_only": "{{n}} scanned",
"progress_scanned_only_tooltip": "No total available for this run (pre-progress-bar deploy or the tenant does not report a countable subject).",
"findings_present_tooltip": "Expand this run to see per-finding detail.",
"col_error": "Error",
"col_kind": "Kind",
"col_severity": "Severity",
"col_resource": "Resource",
"col_detail": "Detail",
"run": "Run",
"cancel": "Cancel",
"refresh": "Refresh",
"runs_title": "Recent runs",
"run_json": "Run summary (JSON)",
"findings_title": "Findings",
"no_runs": "No runs yet.",
"no_findings": "No findings — clean run.",
"none_registered": "No jobs registered.",
"on_demand": "on-demand",
"every_h": "every {{n}} h",
"every_min": "every {{n}} min",
"every_sec": "every {{n}} s",
"outcome_ok": "ok",
"outcome_err": "err",
"outcome_issues": "issues",
"outcome_notices": "notices",
"n_findings": "{{n}} findings",
"n_notices": "{{n}} notices",
"notices_present_tooltip": "Informational findings — no action required. Expand for detail.",
"purge": "Purge old runs",
"purge_hint": "Delete completed and failed run history older than the chosen retention window. Findings drop with their parent runs. Non-terminal runs are always preserved.",
"purge_title": "Purge old job history",
"purge_body": "Delete completed and failed run history older than the chosen number of days. Findings drop with their parent runs. Non-terminal runs (running, paused, cancel-requested) are always preserved.",
"purge_days_label": "Retention (days)",
"purge_confirm": "Purge",
"purge_done": "{{n}} old run(s) purged (retention {{days}} days)",
"state_running": "running",
"never": "never",
"just_now": "just now",
"n_min_ago": "{{n}} min ago",
"n_h_ago": "{{n}} h ago",
"n_d_ago": "{{n}} d ago",
"triggered_ok": "{{name}} triggered successfully",
"triggered_err": "{{name}} failed: {{message}}",
"cancel_requested": "Cancel requested — {{name}} will pause at the next safe boundary",
"cancel_noop": "Nothing to cancel — {{name}} is not running"
}
}, },
"profile": { "profile": {
"page_title": "Profile", "page_title": "Profile",
+2 -2
View File
@@ -71,14 +71,14 @@ coverage-integration filter='mount':
# of this file would otherwise leak in) cannot point the tests at the # of this file would otherwise leak in) cannot point the tests at the
# real dev DB. The test pool helpers also refuse non-`oxicloud_test` # real dev DB. The test pool helpers also refuse non-`oxicloud_test`
# URLs as defence in depth. # URLs as defence in depth.
test-integration: test-integration filter='':
bash tests/common/spawn-db.sh bash tests/common/spawn-db.sh
PGHOST=localhost PGPORT=5433 PGUSER=oxicloud_test PGPASSWORD=oxicloud_test \ PGHOST=localhost PGPORT=5433 PGUSER=oxicloud_test PGPASSWORD=oxicloud_test \
PGDATABASE=oxicloud_test \ PGDATABASE=oxicloud_test \
bash tests/common/init-test-schema.sh bash tests/common/init-test-schema.sh
DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \ DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \
RUSTFLAGS='--cfg integration_tests' \ RUSTFLAGS='--cfg integration_tests' \
cargo test --workspace --tests cargo test --workspace --tests {{filter}}
bash tests/common/stop-db.sh bash tests/common/stop-db.sh
test-one name: test-one name:
@@ -0,0 +1,91 @@
-- ============================================================================
-- Slice 1 of Part 2 (Recoverable-Run Engine) — see
-- docs/plan/job-registry.md#part-2--recoverable-run-engine.
--
-- Introduces the `jobs.*` schema housing state for long-running,
-- restart-tolerant jobs (storage migration, reextract-*, consistency
-- checks). Deliberately distinct from `auth.*` / `storage.*` / `admin.*`
-- because this is JOB-runtime state, not domain data.
--
-- This migration lands the ENGINE table only. Job-specific per-record
-- artifacts (the `jobs.run_findings` generic table used by consistency
-- checks + migration failure logs + reextract failures) land alongside
-- Slice 2 or its first tenant PR — deferred here to keep the review
-- surface tight.
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS jobs;
-- ─── recoverable_runs ────────────────────────────────────────────────────────
-- One row per RUN of a RecoverableJobHandler tenant. Non-terminal
-- rows carry the live cursor + stats; terminal rows are the audit
-- trail (last-run visibility for `GET /api/admin/jobs`, retention
-- pruning deferred).
--
-- `status` values (TEXT, enforced by the partial unique index below
-- plus per-tenant discipline):
-- • 'Running' — actively executing.
-- • 'Paused' — cooperatively yielded (cancel poll or
-- graceful shutdown). Resumable from `cursor`.
-- • 'CancelRequested' — cancel signalled; handler is winding down.
-- Still non-terminal — a re-trigger must NOT
-- spawn a parallel run.
-- • 'Completed' — walked the whole space. Terminal.
-- • 'Failed' — irrecoverable error. Terminal.
-- `error_message` is populated.
--
-- Column notes:
-- • `started_at` is FIXED at run start — long-running consistency
-- scans use it as their grace-window reference (see trap #1 in
-- docs/plan/consistency-check.md).
-- • `last_progress_at` bumps on every checkpoint. Doubles as heartbeat
-- for the boot-time crash-recovery sweep (Running rows with stale
-- heartbeats get flipped to Paused on server restart).
-- • `cursor` is opaque bytes per-job (BLAKE3 hash for blob scans,
-- UUID for file scans, ltree path for folder-tree scans). NULL on
-- a fresh run's first checkpoint.
-- • `stats` accumulates per-run counters (scanned_count,
-- migrated_blobs, findings_this_run, …). JSONB shape is per-job;
-- the reserved top-level `count` mirrors JobOutcome::Ok.count.
-- • `params` carries per-run params captured at start (grace_window_secs,
-- source_backend, …). Distinct from stats — params are set once,
-- stats accumulate.
CREATE TABLE jobs.recoverable_runs (
id UUID PRIMARY KEY,
job_name TEXT NOT NULL,
status TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
last_progress_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
cursor BYTEA,
stats JSONB NOT NULL DEFAULT '{}'::jsonb,
params JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT
);
-- Exclusivity — the load-bearing invariant.
--
-- "At most one non-terminal run per job_name." Enforced at the DB
-- layer so concurrent triggers or scheduler-vs-operator races cannot
-- create a parallel run. The partial index makes duplicate INSERTs
-- fail with a unique-violation; the trigger-endpoint handler catches
-- that and returns the surviving row instead.
--
-- CancelRequested is INCLUDED — a second trigger during cancel must
-- not spawn a parallel run.
CREATE UNIQUE INDEX one_active_run_per_job
ON jobs.recoverable_runs (job_name)
WHERE status IN ('Running', 'Paused', 'CancelRequested');
-- Boot recovery sweep index. On restart, `UPDATE ... SET status='Paused'
-- WHERE status='Running' OR status='CancelRequested'` finds all abandoned
-- rows. Partial index keeps it a fast index-only scan even when the table
-- accumulates terminal (Completed/Failed) rows over time.
CREATE INDEX ON jobs.recoverable_runs (last_progress_at)
WHERE status = 'Running';
-- "Latest run per job" query — powers `GET /api/admin/jobs` when
-- recoverable jobs appear in the listing (`SELECT DISTINCT ON (job_name)
-- ... ORDER BY job_name, started_at DESC`).
CREATE INDEX ON jobs.recoverable_runs (job_name, started_at DESC);
@@ -0,0 +1,62 @@
-- ============================================================================
-- Slice 7 of Part 2 — persistent finding storage.
--
-- Findings from consistency jobs (and eventually storage_migration failure
-- rows, reextract failures) currently flow into `tracing::warn!` targeted
-- at `oxicloud::consistency`. That works for live tailing but rotates
-- away — an operator opening the admin UI a day after a run has nothing
-- to drill into. This table makes findings first-class + queryable.
--
-- `run_findings` is INTENTIONALLY generic. The consistency-check plan and
-- the plan doc's tenant table both list `kind` + `severity` + `resource_id`
-- + `detail` as the union of what every current tenant needs. Adding a
-- tenant with a novel per-finding field means widening `detail`
-- (JSONB, per-tenant shape), not adding a column.
--
-- Cascade rule: findings live and die with their parent run. Deleting a
-- terminal run row (retention pruning, planned) also drops its findings.
-- ============================================================================
CREATE TABLE jobs.run_findings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL
REFERENCES jobs.recoverable_runs(id)
ON DELETE CASCADE,
-- Machine-readable kind (e.g. 'stale_used_bytes', 'missing_blob').
-- Stable across releases per the audit-log convention — see
-- feedback memory `enum_over_string_literals_in_logs`. New failure
-- mode = new value, never repurpose an existing one.
kind TEXT NOT NULL,
-- Severity spectrum:
-- 'data_loss' — bytes / rows unreachable or gone.
-- 'inconsistent' — counters / materialised values wrong,
-- content intact.
-- 'anomaly' — surprising state worth surfacing, no known impact.
-- TEXT (not ENUM) so tenants can grow the vocabulary without a schema
-- migration; the app-layer types.rs is where the canonical set lives.
severity TEXT NOT NULL,
-- Nullable — some findings pertain to the run as a whole
-- (e.g. "backend enumeration truncated after 1M keys") rather than
-- one specific resource.
resource_id UUID,
-- Per-tenant per-finding structured detail (cached/actual/delta,
-- blob_hash, expected/stored path, ...). Consumers key off `kind`
-- to know the shape.
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Time-ordered listing per run: `GET /api/admin/jobs/{name}/runs/{id}/findings`
-- pages by (run_id, created_at) — index-only scan.
CREATE INDEX ON jobs.run_findings (run_id, created_at);
-- Aggregation queries — "how many `missing_blob` findings across all
-- runs of `files_consistency`" or "how many `data_loss`-severity
-- findings today". Both need `(kind)` and `(severity)` predicates; a
-- composite works for either since the leading column is selective.
CREATE INDEX ON jobs.run_findings (kind, severity, created_at);
COMMENT ON TABLE jobs.run_findings IS
'Structured per-finding records emitted by recoverable jobs. Replaces '
'the transitional `tracing::warn!(event=consistency_finding)` calls '
'in the consistency tenants — see docs/plan/job-registry.md Part 2.';
@@ -10,6 +10,7 @@
//! and PostgreSQL index logic in `DedupService` itself. //! and PostgreSQL index logic in `DedupService` itself.
use bytes::Bytes; use bytes::Bytes;
use chrono::{DateTime, Utc};
use futures::Stream; use futures::Stream;
use serde::Serialize; use serde::Serialize;
use std::future::Future; use std::future::Future;
@@ -18,6 +19,53 @@ use std::pin::Pin;
use crate::domain::errors::DomainError; use crate::domain::errors::DomainError;
/// One row returned by [`BlobStorageBackend::list_blob_hashes`] — the
/// hash of a blob physically present on the backend, plus its
/// last-modified timestamp when the backend can supply one. `mtime`
/// is used by `backend_consistency` to skip freshly-created files
/// still within the write grace window (avoids false-positive
/// orphans during the durability-before-visibility window that
/// `dedup_service` opens).
#[derive(Debug, Clone)]
pub struct BackendBlobEntry {
pub hash: String,
/// `None` when the backend doesn't track mtime — the consistency
/// scan then falls back to treating the entry as "old enough" and
/// will emit an orphan finding without a grace check.
pub mtime: Option<DateTime<Utc>>,
}
/// A file present in the blob-storage namespace but NOT matching the
/// canonical `<64-hex>.blob` shape. Sidecars (`.blob.orig`,
/// `.blob.lost`, `.blob.tmp`), wrong extensions, non-hex names —
/// anything the enumeration filter skips for the blob list. Surfaced
/// so `backend_consistency` can emit them as `anomaly` notices
/// (informational only — they don't hurt anything but the operator
/// should know they're there).
#[derive(Debug, Clone)]
pub struct BackendUnknownEntry {
/// Backend-relative path (`04/04f48c...blob.orig` on local FS or
/// as an S3 key). Included in the finding detail so operators
/// can locate it.
pub path: String,
pub mtime: Option<DateTime<Utc>>,
}
/// Return type of [`BlobStorageBackend::list_blob_hashes`] — one
/// batch of the enumeration. Struct (not tuple) so adding future
/// per-batch metadata (e.g. `truncated: bool`) doesn't break every
/// backend impl. `next_cursor = None` signals end of enumeration.
///
/// Backends that don't track sidecar/unknown files leave `unknowns`
/// empty; the tenant just doesn't emit any `unknown_backend_file`
/// notices from that batch.
#[derive(Debug, Clone)]
pub struct BlobListPage {
pub blobs: Vec<BackendBlobEntry>,
pub unknowns: Vec<BackendUnknownEntry>,
pub next_cursor: Option<String>,
}
/// Boxed future alias used by [`BlobStorageBackend`] to keep the trait dyn-compatible. /// Boxed future alias used by [`BlobStorageBackend`] to keep the trait dyn-compatible.
type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
@@ -145,4 +193,40 @@ pub trait BlobStorageBackend: Send + Sync + 'static {
fn read_prefetch(&self) -> usize { fn read_prefetch(&self) -> usize {
1 1
} }
/// Enumerate blob entries physically present on this backend, in
/// implementation-defined order — cursor-based paging.
///
/// * `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.
/// * `limit` — soft cap on batch size; backends may return
/// fewer (e.g. end of a shard directory).
///
/// Returns `(entries, next_cursor)`. `next_cursor = None` means
/// enumeration is complete. Each `BackendBlobEntry` carries the
/// hash + optional mtime for grace-window filtering.
///
/// The trait default returns
/// [`DomainError::NotSupported`](DomainError::not_supported)
/// — future backends that genuinely can't enumerate (some
/// write-only queue, some read-only mirror) can inherit it. All
/// currently-shipped backends (local, S3, Azure) override.
///
/// Filtering out non-blob artifacts (temp files, `.corrupt` /
/// `.lost` sidecars, encryption metadata) is the backend's
/// responsibility — the tenant walks whatever this returns.
fn list_blob_hashes(
&self,
_cursor: Option<String>,
_limit: usize,
) -> BoxFut<'_, Result<BlobListPage, DomainError>> {
Box::pin(async {
Err(DomainError::operation_not_supported(
"list_blob_hashes",
"this backend does not implement enumeration",
))
})
}
} }
@@ -578,9 +578,25 @@ impl StorageUsageService {
pub const STORAGE_RECONCILE_JOB_NAME: &str = "storage_reconcile"; pub const STORAGE_RECONCILE_JOB_NAME: &str = "storage_reconcile";
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRunArgs}; use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
use async_trait::async_trait; use async_trait::async_trait;
impl StorageUsageService {
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style chaining. Scheduled tenant with
/// interval = `max(30s, interval_secs)`. See
/// `docs/plan/job-registry.md` Part 1.
pub async fn register(
self: Arc<Self>,
registry: &JobRegistry,
interval_secs: u64,
) -> Arc<Self> {
let interval = Self::reconciliation_interval(interval_secs);
registry.register(self.clone(), Some(interval), None).await;
self
}
}
#[async_trait] #[async_trait]
impl JobHandler for StorageUsageService { impl JobHandler for StorageUsageService {
fn name(&self) -> &str { fn name(&self) -> &str {
+194 -75
View File
@@ -322,6 +322,13 @@ impl AppServiceFactory {
let blob_lifecycle = let blob_lifecycle =
Arc::new(BlobLifecycleService::new().with_hook(thumbnail_service.clone())); Arc::new(BlobLifecycleService::new().with_hook(thumbnail_service.clone()));
// Hold a clone of the fully-decorated blob backend for use by
// `blobs_consistency` (physical-existence + bit-rot probes)
// further down the DI chain. Must be captured BEFORE the
// `dedup_service` construction below because that call moves
// `blob_backend` into DedupService.
let blob_backend_for_consistency = blob_backend.clone();
// Deduplication service — PRIMARY blob storage engine (PostgreSQL-backed index) // Deduplication service — PRIMARY blob storage engine (PostgreSQL-backed index)
let dedup_service = Arc::new( let dedup_service = Arc::new(
crate::infrastructure::services::dedup_service::DedupService::new( crate::infrastructure::services::dedup_service::DedupService::new(
@@ -436,6 +443,15 @@ impl AppServiceFactory {
// have landed. // have landed.
let job_registry = Arc::new(JobRegistry::new()); let job_registry = Arc::new(JobRegistry::new());
// Recoverable-run engine's PG provider (`jobs.recoverable_runs`).
// Runs on the maintenance pool so a long-running scan's cursor
// updates never contend with the request-path pool. Boot-time
// crash-recovery sweep fires in `build_app_state` after the
// provider is placed on `AppState`.
let job_store_provider = Arc::new(
crate::infrastructure::scheduler::PgJobStoreProvider::new(maintenance_pool.clone()),
);
Ok(CoreServices { Ok(CoreServices {
path_service, path_service,
file_content_cache, file_content_cache,
@@ -449,6 +465,8 @@ impl AppServiceFactory {
zip_service: None, // Placeholder - replaced after app services init zip_service: None, // Placeholder - replaced after app services init
config: self.config.clone(), config: self.config.clone(),
job_registry, job_registry,
job_store_provider,
blob_backend: blob_backend_for_consistency,
}) })
} }
@@ -878,27 +896,18 @@ impl AppServiceFactory {
// (`docs/plan/job-registry.md` Part 1) instead of spawning its own // (`docs/plan/job-registry.md` Part 1) instead of spawning its own
// tokio interval loop. `SchedulerEngine::start` fires the actual // tokio interval loop. `SchedulerEngine::start` fires the actual
// supervisor task at the end of `build_app_state`. // supervisor task at the end of `build_app_state`.
let cleanup_service = Arc::new(TrashCleanupService::new( // Self-registering constructor chain — TrashCleanupService owns
// its interval + timeout shape; DI only supplies deps.
// `.register(&reg)` fires the uniform `job.registered` log line
// and panics on wiring error (duplicate name = boot must fail
// loud). See `docs/plan/job-registry.md` Part 1.
let _ = Arc::new(TrashCleanupService::new(
trash_repo.clone(), trash_repo.clone(),
core.dedup_service.clone(), core.dedup_service.clone(),
24, // Run cleanup every 24 hours 24, // Run cleanup every 24 hours
)); ))
let interval = cleanup_service.interval(); .register(&core.job_registry)
if let Err(e) = core .await;
.job_registry
.register(cleanup_service.clone(), Some(interval), None)
.await
{
// Duplicate registration is the only failure mode today and
// shouldn't happen in the normal DI flow. Log + continue so
// trash service still lands even if scheduling didn't.
tracing::error!("Failed to register trash_cleanup job with scheduler: {e}");
} else {
tracing::info!(
"Trash cleanup registered with scheduler (interval {} h)",
interval.as_secs() / 3600
);
}
Some(service as Arc<TrashService>) Some(service as Arc<TrashService>)
} }
@@ -1125,7 +1134,12 @@ impl AppServiceFactory {
// and invalidation would be a no-op observed by nobody — // and invalidation would be a no-op observed by nobody —
// this is the trap that regressed the used_bytes freshness // this is the trap that regressed the used_bytes freshness
// after perf commit `12dc648c`. // after perf commit `12dc648c`.
let service = Arc::new( // Keep cached storage usage fresh off the request path: GET
// /api/auth/me no longer recomputes the O(N) SUM per call; a
// periodic sweep does it instead (on the maintenance pool).
// Self-registering constructor chain — StorageUsageService owns
// its interval-clamping via `Self::reconciliation_interval`.
Arc::new(
crate::application::services::storage_usage_service::StorageUsageService::new( crate::application::services::storage_usage_service::StorageUsageService::new(
maintenance_pool.clone(), maintenance_pool.clone(),
user_repository, user_repository,
@@ -1134,28 +1148,9 @@ impl AppServiceFactory {
drive_repo drive_repo
as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>, as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
), ),
); )
// Keep cached storage usage fresh off the request path: GET .register(&core.job_registry, self.config.storage.usage_reconcile_secs)
// /api/auth/me no longer recomputes the O(N) SUM per call; a
// periodic sweep does it instead (on the maintenance pool).
// Registered with the periodic-job scheduler
// (`docs/plan/job-registry.md` Part 1); the retired
// `start_reconciliation_job` used to spawn its own interval loop.
let interval =
StorageUsageService::reconciliation_interval(self.config.storage.usage_reconcile_secs);
if let Err(e) = core
.job_registry
.register(service.clone(), Some(interval), None)
.await .await
{
tracing::error!("Failed to register storage_reconcile job: {e}");
} else {
tracing::info!(
"Storage-usage reconciliation registered with scheduler (interval {}s)",
interval.as_secs()
);
}
service
} }
/// Starts the tree-ETag flush job (requires database). /// Starts the tree-ETag flush job (requires database).
@@ -1279,22 +1274,112 @@ impl AppServiceFactory {
// Register on-demand-only jobs whose owning service lives on // Register on-demand-only jobs whose owning service lives on
// CoreServices. Dedup GC has NO periodic tick — trash cleanup's // CoreServices. Dedup GC has NO periodic tick — trash cleanup's
// sweep already runs GC as its tail step, so a periodic dedup // sweep already runs GC as its tail step, so a periodic dedup
// schedule would double the work. Registering with `interval = // schedule would double the work. The `register()` method
// None` keeps it admin-triggerable through the uniform scheduler // encapsulates the on-demand shape.
// surface (`POST /api/admin/jobs/dedup_gc/trigger`). let _ = core
if let Err(e) = core .dedup_service
.job_registry .clone()
.register( .register(&core.job_registry)
core.dedup_service.clone() as Arc<dyn crate::infrastructure::scheduler::JobHandler>, .await;
None, // on-demand only
None, // no timeout // First recoverable-run tenant (`docs/plan/job-registry.md`
// Part 2). Iterates `storage.drives` and reports each drive
// whose cached `used_bytes` differs from `SUM(files.size)`.
// On-demand only — read-only diagnostic, not periodic.
// Runs on the maintenance pool alongside the other sweeps.
let job_store_provider_dyn: Arc<dyn crate::infrastructure::scheduler::JobStoreProvider> =
core.job_store_provider.clone();
let _ = Arc::new(
crate::infrastructure::services::drives_consistency_service::DrivesConsistencyCheck::new(
maintenance_pool.clone(),
),
) )
.await .register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
{ .await;
tracing::error!("Failed to register dedup_gc job with scheduler: {e}");
} else { // Second recoverable-run tenant. Iterates `storage.folders`
tracing::info!("Dedup GC registered with scheduler (on-demand only)"); // and reports each row whose materialised path/lpath or
} // parent-trashed state has drifted from the parent-chain
// reconstruction — same subject-iteration pattern as drives.
// On-demand only; findings surface via the
// `oxicloud::consistency` tracing target until the
// `jobs.run_findings` table lands.
let _ = Arc::new(
crate::infrastructure::services::folders_consistency_service::FoldersConsistencyCheck::new(
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
// absent blob row), and `blob_size_mismatch` (denormalised
// size drift). One SQL round-trip loads folder + blob via
// two LEFT JOINs; per-row branches key off the join
// results.
let _ = Arc::new(
crate::infrastructure::services::files_consistency_service::FilesConsistencyCheck::new(
maintenance_pool.clone(),
),
)
.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.
let _ = Arc::new(
crate::infrastructure::services::blobs_consistency_service::BlobsConsistencyCheck::new(
maintenance_pool.clone(),
core.blob_backend.clone(),
),
)
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
.await;
// Fifth recoverable-run tenant. Iterates the storage
// backend (via `BlobStorageBackend::list_blob_hashes`) and
// reports every physical blob that has no matching row in
// `storage.blobs`. Closes the reference graph together with
// `blobs_consistency`: this tenant walks backend→DB, that
// one walks DB→backend. Enumeration is backend-specific but
// the tenant is fully backend-agnostic — each backend owns
// its own layout knowledge (local walks `.blobs/`, S3 uses
// ListObjectsV2, migration wrapper refuses mid-migration).
let _ = Arc::new(
crate::infrastructure::services::backend_consistency_service::BackendConsistencyCheck::new(
maintenance_pool.clone(),
core.blob_backend.clone(),
),
)
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
.await;
// "Run all consistency checks" coordinator. Plain JobHandler
// (not RecoverableJobHandler) — it dispatches, doesn't scan.
// MUST register AFTER every `*_consistency` tenant so the
// snapshot ordering in `GET /api/admin/jobs` shows children
// then wrapper; snapshot filtering happens at run time so
// late registration is fine. Weak<JobRegistry> internally
// breaks the Arc cycle.
let _ = Arc::new(
crate::infrastructure::services::consistency_batch_service::ConsistencyBatch::new(
&core.job_registry,
),
)
.register_job(&core.job_registry)
.await;
// 2. Repository services (requires PgPool for all metadata) // 2. Repository services (requires PgPool for all metadata)
let repos = self.create_repository_services(&core, &pool); let repos = self.create_repository_services(&core, &pool);
@@ -1484,32 +1569,18 @@ impl AppServiceFactory {
self.start_content_index_job(&maintenance_pool, &core, content_index); self.start_content_index_job(&maintenance_pool, &core, content_index);
grant_cleanup_service = if core.config.features.grant_cleanup.enabled { grant_cleanup_service = if core.config.features.grant_cleanup.enabled {
// Self-registering constructor chain. Grant-cleanup owns
// its interval + on `?force=true` handling; DI only decides
// whether to instantiate at all (feature-gated).
let svc = Arc::new( let svc = Arc::new(
crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new( crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new(
authorization.clone(), authorization.clone(),
core.config.features.grant_cleanup.grace_days, core.config.features.grant_cleanup.grace_days,
core.config.features.grant_cleanup.interval_hours, core.config.features.grant_cleanup.interval_hours,
), ),
); )
// Registered with the periodic-job scheduler .register(&core.job_registry)
// (`docs/plan/job-registry.md` Part 1); the retired .await;
// `start_cleanup_job` used to spawn its own interval loop.
// Admin `?force=true` trigger still calls `svc.purge(Some(0))`
// directly — grace override doesn't fit the JobHandler shape.
let interval = svc.interval();
if let Err(e) = core
.job_registry
.register(svc.clone(), Some(interval), None)
.await
{
tracing::error!("Failed to register grant_cleanup job: {e}");
} else {
tracing::info!(
"Grant cleanup registered with scheduler (every {}h, grace = {}d)",
interval.as_secs() / 3600,
core.config.features.grant_cleanup.grace_days,
);
}
Some(svc) Some(svc)
} else { } else {
tracing::info!( tracing::info!(
@@ -2172,6 +2243,41 @@ impl AppServiceFactory {
} }
} }
// Recoverable-run engine crash recovery. Any row still marked
// Running or CancelRequested when the previous process died gets
// flipped to Paused with `error_message = 'server restart mid-run'`.
// We do NOT auto-resume — operators explicitly re-trigger via
// `POST /api/admin/jobs/{name}/trigger`, which resumes from the
// persisted cursor. Runs BEFORE the scheduler starts so a
// periodic-triggered recoverable job's first tick sees a clean
// slate. See `docs/plan/job-registry.md` Part 2.
use crate::infrastructure::scheduler::JobStoreProvider as _;
match app_state
.core
.job_store_provider
.boot_recovery_sweep()
.await
{
Ok(0) => tracing::debug!(
target: "oxicloud::scheduler",
event = "recoverable.boot_recovery",
flipped = 0,
"no orphaned recoverable runs found at boot"
),
Ok(n) => tracing::warn!(
target: "oxicloud::scheduler",
event = "recoverable.boot_recovery",
flipped = n,
"flipped {n} orphaned recoverable run(s) Running/CancelRequested → Paused (previous process died mid-run)"
),
Err(e) => tracing::error!(
target: "oxicloud::scheduler",
event = "recoverable.boot_recovery.failed",
error = %e,
"boot recovery sweep failed — orphaned runs may remain in Running state"
),
}
// Start the periodic-job scheduler AFTER every native service has // Start the periodic-job scheduler AFTER every native service has
// finished registering its jobs on `core.job_registry`. Starting // finished registering its jobs on `core.job_registry`. Starting
// it earlier would race the first tick against late registrations. // it earlier would race the first tick against late registrations.
@@ -2212,6 +2318,19 @@ pub struct CoreServices {
/// themselves here during their creation; `SchedulerEngine::start` /// themselves here during their creation; `SchedulerEngine::start`
/// spins up the supervisor loop at the end of `build_app_state`. /// spins up the supervisor loop at the end of `build_app_state`.
pub job_registry: Arc<JobRegistry>, pub job_registry: Arc<JobRegistry>,
/// PG-backed provider for `jobs.recoverable_runs`. Recoverable
/// tenants (storage migration, reextract, consistency checks —
/// Part 2 of `docs/plan/job-registry.md`) plug into this via
/// `svc.register_recoverable_job(&registry, &job_store_provider).await`.
/// Boot-time crash-recovery sweep is run in `build_app_state` right
/// after this provider is created.
pub job_store_provider: Arc<crate::infrastructure::scheduler::PgJobStoreProvider>,
/// Fully-decorated blob backend (retry → encryption → cache
/// stack applied). Exposed here so tenants outside
/// `create_core_services` — notably `blobs_consistency` in
/// `build_app_state` — can probe `blob_exists()` / re-hash bytes
/// through the same stack DedupService uses.
pub blob_backend: Arc<dyn BlobStorageBackend>,
} }
/// Container for repository services /// Container for repository services
+28 -6
View File
@@ -264,6 +264,10 @@ fn translate_join(
/// Distinct Ok/Err branches so the tracing macros pick up the fields at /// Distinct Ok/Err branches so the tracing macros pick up the fields at
/// compile time — `tracing` doesn't expand conditional field lists. /// compile time — `tracing` doesn't expand conditional field lists.
fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapsed_ms: u128) { fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapsed_ms: u128) {
// Also render elapsed inline in the human-readable message so
// `tail -f` operators see the duration without waiting on a
// structured log renderer to project the `elapsed_ms` field.
let elapsed = format_elapsed(elapsed_ms);
match outcome { match outcome {
JobOutcome::Ok { count, extra } => { JobOutcome::Ok { count, extra } => {
tracing::info!( tracing::info!(
@@ -274,8 +278,10 @@ fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapse
count = *count, count = *count,
elapsed_ms = elapsed_ms, elapsed_ms = elapsed_ms,
extra = %extra, extra = %extra,
"job {} ran", "job {} ran in {} — count={}",
name, name,
elapsed,
count,
); );
} }
JobOutcome::Err { message: msg } => { JobOutcome::Err { message: msg } => {
@@ -287,13 +293,31 @@ fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapse
cause = %cause.unwrap_or(ErrCause::Handler), cause = %cause.unwrap_or(ErrCause::Handler),
elapsed_ms = elapsed_ms, elapsed_ms = elapsed_ms,
error = %msg, error = %msg,
"job {} failed", "job {} failed after {} — {}",
name, name,
elapsed,
msg,
); );
} }
} }
} }
/// Human-friendly elapsed rendering — `12ms` / `340ms` / `1.4s` /
/// `12.3s` / `4m30s`. The structured `elapsed_ms` field still carries
/// the raw millisecond number for log aggregators.
fn format_elapsed(ms: u128) -> String {
if ms < 1000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", (ms as f64) / 1000.0)
} else {
let secs = ms / 1000;
let m = secs / 60;
let s = secs % 60;
format!("{}m{}s", m, s)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -361,8 +385,7 @@ mod tests {
let registry = Arc::new(JobRegistry::new()); let registry = Arc::new(JobRegistry::new());
registry registry
.register(handler, Some(Duration::from_millis(100)), None) .register(handler, Some(Duration::from_millis(100)), None)
.await .await;
.unwrap();
let entry = registry.get("overrun").await.unwrap(); let entry = registry.get("overrun").await.unwrap();
// Kick off dispatch 1 in the background — it holds the permit // Kick off dispatch 1 in the background — it holds the permit
@@ -402,8 +425,7 @@ mod tests {
Some(Duration::from_millis(100)), Some(Duration::from_millis(100)),
Some(Duration::from_millis(50)), Some(Duration::from_millis(50)),
) )
.await .await;
.unwrap();
let entry = registry.get("slow").await.unwrap(); let entry = registry.get("slow").await.unwrap();
dispatch("slow", entry.clone(), &JobRunArgs::default()).await; dispatch("slow", entry.clone(), &JobRunArgs::default()).await;
+8
View File
@@ -24,10 +24,18 @@
mod engine; mod engine;
mod handler; mod handler;
mod pg_job_store;
mod recoverable;
mod registry; mod registry;
mod types; mod types;
pub use engine::SchedulerEngine; pub use engine::SchedulerEngine;
pub use handler::JobHandler; pub use handler::JobHandler;
pub use pg_job_store::{PgJobStore, PgJobStoreProvider};
pub use recoverable::{
Finding, JobStore, JobStoreProvider, OpenedRun, ProgressKind, RecoverableAdapter,
RecoverableJobHandler, RunOutcome, RunProgress, RunStatus, RunSummary, derive_progress,
record_or_log, run_or_resume,
};
pub use registry::{JobEntry, JobRegistry, JobSummary, RegisterError}; pub use registry::{JobEntry, JobRegistry, JobSummary, RegisterError};
pub use types::{ErrCause, JobOutcome, JobRunArgs}; pub use types::{ErrCause, JobOutcome, JobRunArgs};
@@ -0,0 +1,713 @@
//! PostgreSQL adapter for the recoverable-run engine
//! ([`super::recoverable`]). Concrete impls of [`JobStore`] and
//! [`JobStoreProvider`] backed by `jobs.recoverable_runs`.
//!
//! Both types are cheap to construct (just an `Arc<PgPool>` plus, for
//! `PgJobStore`, the bound run's id + started_at). One `PgJobStoreProvider`
//! lives on `AppState.core.job_store_provider`; per-run `PgJobStore`
//! instances are built by `open_or_start`.
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::common::errors::DomainError;
use super::recoverable::{
Finding, JobStore, JobStoreProvider, OpenedRun, ProgressKind, RunStatus, RunSummary,
derive_progress,
};
// ─── PgJobStore — bound to one run ──────────────────────────────────────────
/// A `JobStore` bound to a specific `jobs.recoverable_runs.id`. Every
/// method issues one small UPDATE / SELECT against that row.
pub struct PgJobStore {
pool: Arc<PgPool>,
run_id: Uuid,
started_at: DateTime<Utc>,
}
impl PgJobStore {
/// Called only from [`PgJobStoreProvider::open_or_start`] and its
/// test helpers — implementors never construct one directly.
pub(super) fn new(pool: Arc<PgPool>, run_id: Uuid, started_at: DateTime<Utc>) -> Self {
Self {
pool,
run_id,
started_at,
}
}
}
fn map_sqlx_err(op: &'static str, e: sqlx::Error) -> DomainError {
DomainError::internal_error("JobStore", format!("{op}: {e}"))
}
#[async_trait]
impl JobStore for PgJobStore {
fn run_id(&self) -> Uuid {
self.run_id
}
fn started_at(&self) -> DateTime<Utc> {
self.started_at
}
async fn status(&self) -> Result<RunStatus, DomainError> {
let row: Option<(String,)> =
sqlx::query_as("SELECT status FROM jobs.recoverable_runs WHERE id = $1")
.bind(self.run_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("status", e))?;
let raw = row
.ok_or_else(|| {
DomainError::internal_error("JobStore", format!("run vanished: {}", self.run_id))
})?
.0;
RunStatus::parse(&raw).ok_or_else(|| {
DomainError::internal_error("JobStore", format!("unknown status value: {raw}"))
})
}
async fn checkpoint(&self, cursor: Vec<u8>, delta_count: u64) -> Result<(), DomainError> {
// stats.scanned_count += delta_count. jsonb_set expects the new
// value serialised as jsonb; the cast chain from bigint → text
// → jsonb is the standard way to bump a numeric counter without
// pulling the whole JSONB into Rust.
let delta = delta_count as i64;
sqlx::query(
r#"
UPDATE jobs.recoverable_runs
SET cursor = $2,
stats = jsonb_set(
stats,
'{scanned_count}',
((COALESCE(stats->>'scanned_count', '0')::bigint + $3)::text)::jsonb
),
last_progress_at = NOW()
WHERE id = $1
"#,
)
.bind(self.run_id)
.bind(&cursor[..])
.bind(delta)
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("checkpoint", e))?;
Ok(())
}
async fn record_finding(
&self,
kind: &str,
severity: &str,
resource_id: Option<Uuid>,
detail: serde_json::Value,
) -> Result<(), DomainError> {
// Two writes in one round-trip via CTE: INSERT the finding
// row + UPDATE stats.finding_count on the parent run. The
// counter stays a coarse UI hint — the source of truth is
// the `jobs.run_findings` table itself. Even if the counter
// drifts (crash mid-statement, hand-edited row), aggregations
// stay accurate. Bumping in the same statement avoids two
// round trips per finding on a hot scan; on a normal
// consistency run the ratio of findings-to-batches is low
// enough that a round trip either way is fine, but this shape
// scales to a bulk-finding tenant without change.
//
// `resource_id` is nullable in the schema; when None here we
// bind `Option::<Uuid>::None` and sqlx encodes it as SQL NULL.
let bumped = sqlx::query(
r#"
WITH inserted AS (
INSERT INTO jobs.run_findings
(run_id, kind, severity, resource_id, detail)
VALUES ($1, $2, $3, $4, $5)
RETURNING run_id
)
UPDATE jobs.recoverable_runs
SET stats = jsonb_set(
stats,
'{finding_count}',
((COALESCE(stats->>'finding_count', '0')::bigint + 1)::text)::jsonb
)
WHERE id = (SELECT run_id FROM inserted)
"#,
)
.bind(self.run_id)
.bind(kind)
.bind(severity)
.bind(resource_id)
.bind(&detail)
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("record_finding", e))?;
// A rows_affected == 0 on the UPDATE would mean the parent run
// row vanished between INSERT and UPDATE — theoretically
// impossible under our CASCADE FK (deleting the run drops the
// finding), so we don't error, but a debug log covers the
// defensive path.
if bumped.rows_affected() == 0 {
tracing::debug!(
target: "oxicloud::scheduler",
event = "record_finding.counter_bump_noop",
run_id = %self.run_id,
"record_finding: parent run row missing during counter bump"
);
}
Ok(())
}
async fn seed_progress_params(
&self,
total: u64,
kind: ProgressKind,
) -> Result<(), DomainError> {
// Stamp `params.total_rows` + `params.progress_kind` in one
// UPDATE. Two `jsonb_set` calls compose left-to-right so both
// keys land atomically. `bigint` cast handles the (theoretical)
// > 2^31 subject-row case.
let total_i64 = total as i64;
sqlx::query(
r#"
UPDATE jobs.recoverable_runs
SET params = jsonb_set(
jsonb_set(
params,
'{total_rows}',
to_jsonb($2::bigint)
),
'{progress_kind}',
to_jsonb($3::text)
)
WHERE id = $1
"#,
)
.bind(self.run_id)
.bind(total_i64)
.bind(kind.as_str())
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("seed_progress_params", e))?;
Ok(())
}
async fn mark_completed(&self) -> Result<(), DomainError> {
sqlx::query(
r#"
UPDATE jobs.recoverable_runs
SET status = 'Completed',
completed_at = NOW(),
last_progress_at = NOW()
WHERE id = $1
"#,
)
.bind(self.run_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("mark_completed", e))?;
Ok(())
}
async fn mark_paused(&self, cursor: Option<Vec<u8>>) -> Result<(), DomainError> {
// Two-query variant would be simpler but this preserves the
// final cursor value in one statement whether or not the
// handler advanced it since the last checkpoint.
if let Some(c) = cursor {
sqlx::query(
r#"
UPDATE jobs.recoverable_runs
SET status = 'Paused',
cursor = $2,
last_progress_at = NOW()
WHERE id = $1
"#,
)
.bind(self.run_id)
.bind(&c[..])
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("mark_paused", e))?;
} else {
sqlx::query(
r#"
UPDATE jobs.recoverable_runs
SET status = 'Paused',
last_progress_at = NOW()
WHERE id = $1
"#,
)
.bind(self.run_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("mark_paused", e))?;
}
Ok(())
}
async fn mark_failed(&self, message: &str) -> Result<(), DomainError> {
sqlx::query(
r#"
UPDATE jobs.recoverable_runs
SET status = 'Failed',
completed_at = NOW(),
last_progress_at = NOW(),
error_message = $2
WHERE id = $1
"#,
)
.bind(self.run_id)
.bind(message)
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("mark_failed", e))?;
Ok(())
}
}
// ─── PgJobStoreProvider — registry-level ops ────────────────────────────────
/// The `JobStoreProvider` PG-backed implementation. Constructs
/// `PgJobStore` handles via `open_or_start`, and drives the boot-time
/// crash-recovery sweep.
pub struct PgJobStoreProvider {
pool: Arc<PgPool>,
}
impl PgJobStoreProvider {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl JobStoreProvider for PgJobStoreProvider {
async fn open_or_start(&self, job_name: &str) -> Result<OpenedRun, DomainError> {
// Two-shot: look up the latest non-terminal row; if found,
// dispatch on its status; if not, INSERT a fresh Running row.
//
// Concurrent-insert race is caught by the partial unique index
// `one_active_run_per_job` — the losing INSERT falls back to
// re-querying and dispatching on whatever the winner wrote.
// We retry once because after the first losing insert, the
// winning row is guaranteed to exist and no third caller can
// race in ahead of us (they'd hit the same unique index).
for attempt in 0..2 {
match self.try_open_or_start(job_name).await {
Ok(opened) => return Ok(opened),
Err(OpenErr::Retry) => {
tracing::debug!(
target: "oxicloud::scheduler",
event = "recoverable.open_or_start.race",
job = job_name,
attempt = attempt,
"open_or_start lost to a concurrent INSERT; retrying"
);
continue;
}
Err(OpenErr::Fatal(e)) => return Err(e),
}
}
Err(DomainError::internal_error(
"JobStore",
format!("open_or_start({job_name}): retry budget exhausted"),
))
}
async fn boot_recovery_sweep(&self) -> Result<u64, DomainError> {
// Every row abandoned in Running / CancelRequested by the
// previous process flips to Paused with a synthetic
// error_message. We DO NOT auto-resume — operators trigger
// the resume explicitly per the trait doc.
let result = sqlx::query(
r#"
UPDATE jobs.recoverable_runs
SET status = 'Paused',
error_message = COALESCE(error_message, 'server restart mid-run')
WHERE status IN ('Running', 'CancelRequested')
"#,
)
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("boot_recovery_sweep", e))?;
Ok(result.rows_affected())
}
async fn list_runs(&self, job_name: &str, limit: u32) -> Result<Vec<RunSummary>, DomainError> {
// Cap the limit at 100 defensively — the API layer should
// also clamp, but a broken caller shouldn't tank the DB.
let capped = limit.min(100) as i64;
let rows: Vec<RunSummaryRow> = sqlx::query_as(RUN_SUMMARY_SELECT_LIST)
.bind(job_name)
.bind(capped)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("list_runs", e))?;
rows.into_iter().map(row_to_summary).collect()
}
async fn get_run_by_id(&self, run_id: Uuid) -> Result<Option<RunSummary>, DomainError> {
let row: Option<RunSummaryRow> = sqlx::query_as(RUN_SUMMARY_SELECT_BY_ID)
.bind(run_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("get_run_by_id", e))?;
row.map(row_to_summary).transpose()
}
async fn list_findings(
&self,
run_id: Uuid,
limit: u32,
offset: u32,
) -> Result<Vec<Finding>, DomainError> {
let capped = limit.min(500) as i64;
let off = offset as i64;
let rows: Vec<(
Uuid,
Uuid,
String,
String,
Option<Uuid>,
serde_json::Value,
DateTime<Utc>,
)> = sqlx::query_as(
r#"
SELECT id, run_id, kind, severity, resource_id, detail, created_at
FROM jobs.run_findings
WHERE run_id = $1
ORDER BY created_at, id
LIMIT $2 OFFSET $3
"#,
)
.bind(run_id)
.bind(capped)
.bind(off)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("list_findings", e))?;
Ok(rows
.into_iter()
.map(
|(id, run_id, kind, severity, resource_id, detail, created_at)| Finding {
id,
run_id,
kind,
severity,
resource_id,
detail,
created_at,
},
)
.collect())
}
async fn finding_severity_counts(
&self,
run_id: Uuid,
) -> Result<Vec<(String, u64)>, DomainError> {
let rows: Vec<(String, i64)> = sqlx::query_as(
r#"
SELECT severity, COUNT(*)::bigint
FROM jobs.run_findings
WHERE run_id = $1
GROUP BY severity
"#,
)
.bind(run_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("finding_severity_counts", e))?;
Ok(rows
.into_iter()
.map(|(sev, count)| (sev, count.max(0) as u64))
.collect())
}
async fn purge_terminal_runs(&self, retention_days: i32) -> Result<u64, DomainError> {
// Defensive floor — zero would eat just-completed runs;
// negative would eat the whole terminal history.
let days = retention_days.max(1);
// `ON DELETE CASCADE` on jobs.run_findings.run_id
// (migration 20260930000001) drops findings with their
// parent run. Non-terminal statuses (Running / Paused /
// CancelRequested) explicitly excluded to protect
// in-flight work.
let result = sqlx::query(
r#"
DELETE FROM jobs.recoverable_runs
WHERE status IN ('Completed', 'Failed')
AND completed_at IS NOT NULL
AND completed_at < NOW() - ($1 || ' days')::interval
"#,
)
.bind(days.to_string())
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("purge_terminal_runs", e))?;
Ok(result.rows_affected())
}
async fn request_cancel(&self, job_name: &str) -> Result<Option<Uuid>, DomainError> {
// Only Running → CancelRequested flips. `Paused` can be
// cancelled by not resuming — no need for a state change.
// `CancelRequested` already is what it is.
// Multiple Running rows shouldn't exist (partial unique index),
// but LIMIT 1 is defensive.
let flipped: Option<(Uuid,)> = sqlx::query_as(
r#"
UPDATE jobs.recoverable_runs
SET status = 'CancelRequested',
last_progress_at = NOW()
WHERE id = (
SELECT id FROM jobs.recoverable_runs
WHERE job_name = $1
AND status = 'Running'
ORDER BY started_at DESC
LIMIT 1
)
RETURNING id
"#,
)
.bind(job_name)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("request_cancel", e))?;
Ok(flipped.map(|(id,)| id))
}
}
// ─── Shared row → RunSummary decoder ────────────────────────────────────────
/// Row shape returned by the run-summary SELECTs. Kept as a distinct
/// type so both `list_runs` and `get_run_by_id` share the projection
/// (SQL column list + decoder). Order matches the SELECT below.
type RunSummaryRow = (
Uuid, // id
String, // job_name
String, // status
DateTime<Utc>, // started_at
DateTime<Utc>, // last_progress_at
Option<DateTime<Utc>>, // completed_at
Option<Vec<u8>>, // cursor
serde_json::Value, // stats
serde_json::Value, // params
Option<String>, // error_message
);
const RUN_SUMMARY_COLUMNS: &str = "id, job_name, status, started_at, last_progress_at, completed_at, cursor, stats, params, error_message";
// `format!` isn't const, but `concat!` gives us a &'static str at compile
// time — worth it so the SELECT strings show up in tracing / SQL logs
// as one contiguous line instead of a runtime string build.
const RUN_SUMMARY_SELECT_LIST: &str = concat!(
"SELECT id, job_name, status, started_at, last_progress_at, completed_at, cursor, stats, params, error_message ",
"FROM jobs.recoverable_runs ",
"WHERE job_name = $1 ",
"ORDER BY started_at DESC ",
"LIMIT $2"
);
const RUN_SUMMARY_SELECT_BY_ID: &str = concat!(
"SELECT id, job_name, status, started_at, last_progress_at, completed_at, cursor, stats, params, error_message ",
"FROM jobs.recoverable_runs ",
"WHERE id = $1"
);
fn row_to_summary(row: RunSummaryRow) -> Result<RunSummary, DomainError> {
let (
id,
job_name,
status_str,
started_at,
last_progress_at,
completed_at,
cursor,
stats,
params,
error_message,
) = row;
let status = RunStatus::parse(&status_str).ok_or_else(|| {
DomainError::internal_error("JobStore", format!("unknown status: {status_str}"))
})?;
// Derive progress from stats.scanned_count + params.total_rows +
// params.progress_kind. All three are optional — if the tenant
// didn't seed a total (no count_total override) the block is None
// and the UI hides the bar. `derive_progress` also guards against
// total = 0 (empty-subject run — bar would be meaningless).
let scanned = stats
.get("scanned_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let total = params.get("total_rows").and_then(|v| v.as_u64());
let kind = params
.get("progress_kind")
.and_then(|v| v.as_str())
.and_then(ProgressKind::parse);
let progress = derive_progress(scanned, total, kind);
Ok(RunSummary {
id,
job_name,
status,
started_at,
last_progress_at,
completed_at,
stats,
params,
cursor_hex: cursor.map(hex::encode),
error_message,
progress,
})
}
// Suppress the dead-code lint on the column list — kept as a
// human-readable constant even though the actual SELECTs currently
// inline it. Future rewrites of the SELECTs (e.g. adding stats
// projection) will use it.
#[allow(dead_code)]
const _RUN_SUMMARY_COLUMNS_UNUSED: &str = RUN_SUMMARY_COLUMNS;
/// Row shape returned by `open_or_start`'s SELECT — factored out
/// so clippy's `type_complexity` lint doesn't yell at the query.
type ExistingRun = (Uuid, String, DateTime<Utc>, Option<Vec<u8>>);
/// Internal error surface for the two-shot open_or_start retry loop.
enum OpenErr {
/// Lost to a concurrent INSERT — caller retries.
Retry,
/// Any other DB error — surfaces to caller unchanged.
Fatal(DomainError),
}
impl PgJobStoreProvider {
/// One attempt of open_or_start. Returns `Err(Retry)` on the
/// unique-index-conflict path so the outer loop re-queries.
async fn try_open_or_start(&self, job_name: &str) -> Result<OpenedRun, OpenErr> {
// Latest non-terminal row for this job_name, if any.
let existing: Option<ExistingRun> = sqlx::query_as(
r#"
SELECT id, status, started_at, cursor
FROM jobs.recoverable_runs
WHERE job_name = $1
AND status IN ('Running', 'Paused', 'CancelRequested')
ORDER BY started_at DESC
LIMIT 1
"#,
)
.bind(job_name)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| OpenErr::Fatal(map_sqlx_err("open_or_start.select", e)))?;
match existing {
Some((id, raw_status, _started_at, _cursor)) => {
let status = RunStatus::parse(&raw_status).ok_or_else(|| {
OpenErr::Fatal(DomainError::internal_error(
"JobStore",
format!("unknown status: {raw_status}"),
))
})?;
match status {
RunStatus::Running | RunStatus::CancelRequested => {
Ok(OpenedRun::AlreadyActive { run_id: id, status })
}
RunStatus::Paused => {
// Flip to Running and hand back the cursor.
// Race note: another concurrent caller could
// race the same UPDATE. Both would succeed
// (Paused → Running is idempotent), but only
// one caller's dispatch would then race the
// partial unique index on subsequent
// operations. Acceptable — the loser's
// handler will observe `status = Running`
// (via `store.status()`) and can early-exit.
// In practice this is a rare edge case that
// ONLY hits if two admin triggers land in
// the same microsecond.
let row: Option<(DateTime<Utc>, Option<Vec<u8>>)> = sqlx::query_as(
r#"
UPDATE jobs.recoverable_runs
SET status = 'Running',
last_progress_at = NOW()
WHERE id = $1
RETURNING started_at, cursor
"#,
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| OpenErr::Fatal(map_sqlx_err("open_or_start.resume", e)))?;
let (started_at, cursor_bytes) = row.ok_or_else(|| {
OpenErr::Fatal(DomainError::internal_error(
"JobStore",
format!("run vanished during resume: {id}"),
))
})?;
let store: Arc<dyn JobStore> =
Arc::new(PgJobStore::new(self.pool.clone(), id, started_at));
Ok(OpenedRun::Resumed {
store,
cursor: cursor_bytes.unwrap_or_default(),
})
}
// Terminal states shouldn't appear here (WHERE
// clause filters them). Defensive branch.
_ => Err(OpenErr::Fatal(DomainError::internal_error(
"JobStore",
format!("terminal status leaked into open_or_start: {status:?}"),
))),
}
}
None => {
// No non-terminal row → INSERT a fresh one. The
// partial unique index protects against a concurrent
// second INSERT; on conflict we retry.
let run_id = Uuid::new_v4();
let now = Utc::now();
// ON CONFLICT here infers the partial unique index by
// matching `(job_name)` + the WHERE predicate that
// matches `one_active_run_per_job`. We cannot use
// `ON CONFLICT ON CONSTRAINT one_active_run_per_job`
// because `CREATE UNIQUE INDEX` produces an index, not
// a named constraint from PG's perspective; that
// syntax is reserved for `ALTER TABLE ... ADD CONSTRAINT
// UNIQUE`. Inference form is equivalent and works with
// partial indexes.
let result = sqlx::query(
r#"
INSERT INTO jobs.recoverable_runs
(id, job_name, status, started_at, last_progress_at)
VALUES ($1, $2, 'Running', $3, $3)
ON CONFLICT (job_name)
WHERE status IN ('Running', 'Paused', 'CancelRequested')
DO NOTHING
"#,
)
.bind(run_id)
.bind(job_name)
.bind(now)
.execute(self.pool.as_ref())
.await
.map_err(|e| OpenErr::Fatal(map_sqlx_err("open_or_start.insert", e)))?;
if result.rows_affected() == 1 {
let store: Arc<dyn JobStore> =
Arc::new(PgJobStore::new(self.pool.clone(), run_id, now));
Ok(OpenedRun::Fresh { store })
} else {
// Someone raced us. Retry to pick up their row.
Err(OpenErr::Retry)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+72 -20
View File
@@ -69,10 +69,7 @@ impl JobRegistry {
} }
} }
/// Register a job. Returns an error if a job with the same name /// Register a job — production wiring path.
/// is already registered — names are the primary identifier
/// everywhere (logs, admin URLs, env vars) and collisions would
/// hide bugs.
/// ///
/// - `interval = Some(dur)` → **scheduled**. The supervisor fires /// - `interval = Some(dur)` → **scheduled**. The supervisor fires
/// the job every `dur`, starting `now + dur`. Registration does /// the job every `dur`, starting `now + dur`. Registration does
@@ -83,11 +80,59 @@ impl JobRegistry {
/// fires this job. Admin endpoint (or programmatic callers) can /// fires this job. Admin endpoint (or programmatic callers) can
/// still invoke it via [`JobRegistry::trigger`] — the dispatch /// still invoke it via [`JobRegistry::trigger`] — the dispatch
/// goes through the same panic/timeout/exclusivity gates. /// goes through the same panic/timeout/exclusivity gates.
///
/// **Panics on error.** Registration failure (duplicate name today)
/// is a DI-wiring bug — the server must not start with a mis-wired
/// scheduler. Emits a uniform `job.registered` log line on success
/// so callers don't reinvent the log message at every site.
///
/// For unit tests that need to assert the error path use
/// [`Self::try_register`] instead.
pub async fn register( pub async fn register(
&self, &self,
handler: Arc<dyn JobHandler>, handler: Arc<dyn JobHandler>,
interval: Option<Duration>, interval: Option<Duration>,
timeout: Option<Duration>, timeout: Option<Duration>,
) {
let name = handler.name().to_string();
match self.try_register(handler, interval, timeout).await {
Ok(()) => {
let cadence = match interval {
Some(dur) => {
let secs = dur.as_secs();
if secs % 3600 == 0 {
format!("every {} h", secs / 3600)
} else if secs % 60 == 0 {
format!("every {} min", secs / 60)
} else {
format!("every {} s", secs)
}
}
None => "on-demand".to_string(),
};
tracing::info!(
target: "oxicloud::scheduler",
event = "job.registered",
job = %name,
cadence = %cadence,
"job {} registered ({})",
name,
cadence,
);
}
Err(e) => panic!("JobRegistry::register({name}) failed — DI wiring bug: {e}"),
}
}
/// Fallible sibling of [`Self::register`]. Returns `Err` on
/// duplicate-name instead of panicking, and does NOT emit the
/// `job.registered` log line — for unit tests that need to
/// assert failure without triggering the boot panic path.
pub async fn try_register(
&self,
handler: Arc<dyn JobHandler>,
interval: Option<Duration>,
timeout: Option<Duration>,
) -> Result<(), RegisterError> { ) -> Result<(), RegisterError> {
let name = handler.name().to_string(); let name = handler.name().to_string();
let mut guard = self.entries.write().await; let mut guard = self.entries.write().await;
@@ -286,11 +331,9 @@ mod tests {
async fn register_and_pick_next() { async fn register_and_pick_next() {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
reg.register(handler("job_a"), Some(Duration::from_secs(60)), None) reg.register(handler("job_a"), Some(Duration::from_secs(60)), None)
.await .await;
.unwrap();
reg.register(handler("job_b"), Some(Duration::from_secs(10)), None) reg.register(handler("job_b"), Some(Duration::from_secs(10)), None)
.await .await;
.unwrap();
let (next_name, _) = reg.pick_next().await.expect("expected a due job"); let (next_name, _) = reg.pick_next().await.expect("expected a due job");
// job_b has the shorter interval → earlier next_run_at. // job_b has the shorter interval → earlier next_run_at.
@@ -300,16 +343,30 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn duplicate_registration_rejected() { async fn duplicate_registration_rejected() {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
reg.register(handler("job_x"), Some(Duration::from_secs(60)), None) // Use the fallible `try_register` here so we can assert the
// Err path without triggering `register`'s boot-time panic.
reg.try_register(handler("job_x"), Some(Duration::from_secs(60)), None)
.await .await
.unwrap(); .unwrap();
let err = reg let err = reg
.register(handler("job_x"), Some(Duration::from_secs(60)), None) .try_register(handler("job_x"), Some(Duration::from_secs(60)), None)
.await .await
.expect_err("duplicate name must be rejected"); .expect_err("duplicate name must be rejected");
assert!(matches!(err, RegisterError::DuplicateName(_))); assert!(matches!(err, RegisterError::DuplicateName(_)));
} }
#[tokio::test]
#[should_panic(expected = "DI wiring bug")]
async fn register_panics_on_duplicate() {
let reg = JobRegistry::new();
reg.register(handler("job_dup"), Some(Duration::from_secs(60)), None)
.await;
// Second register with same name — boot panic. Anything doing
// this outside a #[should_panic] test is a mis-wired DI.
reg.register(handler("job_dup"), Some(Duration::from_secs(60)), None)
.await;
}
#[tokio::test] #[tokio::test]
async fn empty_registry_picks_nothing() { async fn empty_registry_picks_nothing() {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
@@ -320,11 +377,9 @@ mod tests {
async fn snapshot_all_returns_every_entry() { async fn snapshot_all_returns_every_entry() {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
reg.register(handler("a"), Some(Duration::from_secs(1)), None) reg.register(handler("a"), Some(Duration::from_secs(1)), None)
.await .await;
.unwrap();
reg.register(handler("b"), Some(Duration::from_secs(1)), None) reg.register(handler("b"), Some(Duration::from_secs(1)), None)
.await .await;
.unwrap();
let all = reg.snapshot_all().await; let all = reg.snapshot_all().await;
assert_eq!(all.len(), 2); assert_eq!(all.len(), 2);
} }
@@ -334,12 +389,9 @@ mod tests {
let reg = JobRegistry::new(); let reg = JobRegistry::new();
// Scheduled job with a long interval. // Scheduled job with a long interval.
reg.register(handler("scheduled"), Some(Duration::from_secs(3600)), None) reg.register(handler("scheduled"), Some(Duration::from_secs(3600)), None)
.await .await;
.unwrap();
// On-demand job — supervisor must never pick it. // On-demand job — supervisor must never pick it.
reg.register(handler("on_demand"), None, None) reg.register(handler("on_demand"), None, None).await;
.await
.unwrap();
let (next_name, _) = reg.pick_next().await.expect("scheduled job due"); let (next_name, _) = reg.pick_next().await.expect("scheduled job due");
assert_eq!( assert_eq!(
@@ -351,7 +403,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn trigger_dispatches_on_demand_job() { async fn trigger_dispatches_on_demand_job() {
let reg = Arc::new(JobRegistry::new()); let reg = Arc::new(JobRegistry::new());
reg.register(handler("gc"), None, None).await.unwrap(); reg.register(handler("gc"), None, None).await;
let outcome = reg let outcome = reg
.trigger("gc", &JobRunArgs::default()) .trigger("gc", &JobRunArgs::default())
+10
View File
@@ -25,9 +25,19 @@ use serde::{Deserialize, Serialize};
/// - `dedup_gc` — skip the orphan grace window (grace = 0). /// - `dedup_gc` — skip the orphan grace window (grace = 0).
/// - `grant_cleanup` — grace = 0. /// - `grant_cleanup` — grace = 0.
/// - Others (trash_cleanup, storage_reconcile, …) — ignored. /// - Others (trash_cleanup, storage_reconcile, …) — ignored.
///
/// Semantics of `deep`, per job:
/// - `consistency_batch` — propagate to sub-jobs; only `storage_consistency`
/// currently respects it. Wraps the "run all consistency checks
/// including the slow ones" case behind the same job_name lock as
/// the normal batch (Ed's Option B, 2026-07-29).
/// - `storage_consistency` (future) — enables per-blob re-BLAKE3 (bitrot
/// detection) + mime sniff alongside the fast orphan check.
/// - Others — ignored.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct JobRunArgs { pub struct JobRunArgs {
pub force: bool, pub force: bool,
pub deep: bool,
} }
/// Uniform outcome the supervisor logs and stores for every job dispatch. /// Uniform outcome the supervisor logs and stores for every job dispatch.
@@ -389,4 +389,14 @@ impl BlobStorageBackend for AzureBlobBackend {
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> { fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
None None
} }
// TODO: implement `list_blob_hashes` via
// `container_client.list_blobs()` (`azure_storage_blobs`
// paginator). Same filter as local + S3 impls:
// `<xx>/<64-hex>.blob` naming. Currently inherits the trait
// default which returns `operation_not_supported` — the
// `backend_consistency` tenant handles that by emitting a
// single run-level `backend_unenumerable` finding and
// completing without per-blob probes. Ship as a follow-up once
// there's an Azure test environment to validate against.
} }
@@ -0,0 +1,369 @@
//! 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.
//!
//! ### Per-row check
//!
//! * `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).
//!
//! ### Run-level check
//!
//! * `backend_unenumerable` (severity `anomaly`) — the backend
//! returned `operation_not_supported` on the first
//! `list_blob_hashes` call. Currently this fires when a
//! `MigrationBlobBackend` is active (refuses enumeration
//! mid-migration by design) or on an Azure backend (Azure impl
//! deferred). Informational — operators know they can't rely on
//! this scan under that config.
//!
//! ### Grace window
//!
//! Skip orphans whose backend mtime is within the last hour. Same
//! shape as `blobs_consistency` + `dedup_gc`: matches the
//! durability-before-visibility gap in the write path.
//!
//! ### Cost profile
//!
//! Batched: fetch N hashes from the backend, do one
//! `WHERE hash = ANY($1)` DB probe per batch, set-difference in
//! Rust. Dedup savings: yes — a chunk shared by 5 files still
//! walks once. On local backend the walk is
//! `walkdir + fs::metadata` per file (fast). On S3 the walk is
//! `ListObjectsV2` (rate-limited but paginated). Progress bar
//! uses `COUNT(*) FROM storage.blobs` as the approximate
//! 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;
use chrono::{Duration, Utc};
use sqlx::PgPool;
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
use crate::infrastructure::scheduler::{
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, ProgressKind, RecoverableJobHandler,
RunOutcome, RunStatus, record_or_log,
};
pub const BACKEND_CONSISTENCY_JOB_NAME: &str = "backend_consistency";
/// Batch size for backend enumeration + DB probe. 500 is enough to
/// amortise the DB round-trip while keeping the cancel-poll cadence
/// sub-second (each batch = one backend list + one DB probe + Rust
/// set-difference). Larger batches on S3 hit ListObjectsV2's
/// per-request limit (1000) with wasted rows filtered client-side;
/// smaller batches over-poll the DB.
const BATCH_SIZE: usize = 500;
/// Grace window — orphans younger than this are skipped, since the
/// write path is durability-before-visibility: bytes hit disk before
/// the `storage.blobs` row is inserted. A scan catching a blob
/// mid-write would false-positive it as orphan. Matches
/// `blobs_consistency` + `dedup_gc`.
const CREATE_GRACE: Duration = Duration::hours(1);
/// Cap on affected-blob examples surfaced in the run-level
/// `backend_unenumerable` finding. Keeps the finding detail bounded.
const _MAX_EXAMPLES: usize = 5;
pub struct BackendConsistencyCheck {
pool: Arc<PgPool>,
backend: Arc<dyn BlobStorageBackend>,
}
impl BackendConsistencyCheck {
pub fn new(pool: Arc<PgPool>, backend: Arc<dyn BlobStorageBackend>) -> Self {
Self { pool, backend }
}
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
}
}
#[async_trait]
impl RecoverableJobHandler for BackendConsistencyCheck {
fn name(&self) -> &str {
BACKEND_CONSISTENCY_JOB_NAME
}
/// 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
/// end IS informative — a fraction of 1.05 means the backend
/// holds ~5% orphan bytes, which is exactly what this check
/// surfaces per-row.
async fn count_total(&self) -> Option<u64> {
let row: Result<(i64,), sqlx::Error> = sqlx::query_as("SELECT COUNT(*) FROM storage.blobs")
.fetch_one(self.pool.as_ref())
.await;
match row {
Ok((n,)) => Some(n.max(0) as u64),
Err(e) => {
tracing::debug!(
target: "oxicloud::consistency",
event = "backend_consistency.count_total_failed",
error = %e,
"count_total failed — run will not surface a progress bar"
);
None
}
}
}
fn progress_kind(&self) -> ProgressKind {
// Approximate — the denominator (DB count) is a proxy for
// the backend count. Deviation is meaningful (see the
// count_total doc).
ProgressKind::Approximate
}
async fn run_resumable(
&self,
store: &dyn JobStore,
_args: &JobRunArgs,
resume_cursor: Option<Vec<u8>>,
) -> RunOutcome {
// Cursor = opaque backend continuation token, UTF-8-encoded.
// Each backend defines its own format (local = shard/hash,
// S3 = ListObjectsV2 continuation token, Azure = list
// marker); the tenant just passes it through.
let mut cursor: Option<String> = match resume_cursor {
None => None,
Some(bytes) if bytes.is_empty() => None,
Some(bytes) => match String::from_utf8(bytes) {
Ok(s) => Some(s),
Err(e) => {
return RunOutcome::Failed {
message: format!("invalid cursor: not valid UTF-8: {e}"),
};
}
},
};
let mut finding_count = 0u64;
loop {
// Cancel poll between batches.
match store.status().await {
Ok(RunStatus::CancelRequested) => {
tracing::info!(
target: "oxicloud::consistency",
event = "backend_consistency.cancelled",
run_id = %store.run_id(),
finding_count = finding_count,
"backend_consistency cancelled cooperatively, pausing"
);
return RunOutcome::Paused {
cursor: cursor
.as_ref()
.map(|s| s.as_bytes().to_vec())
.unwrap_or_default(),
};
}
Ok(_) => {}
Err(e) => {
return RunOutcome::Failed {
message: format!("status poll: {e}"),
};
}
}
// Fetch next batch from the backend. `BlobListPage`
// splits canonical blobs (checked for orphan) from
// "unknown" entries (sidecar files, foreign namespaces —
// emitted as informational notices).
let page = match self
.backend
.list_blob_hashes(cursor.clone(), BATCH_SIZE)
.await
{
Ok(v) => v,
Err(e) => {
// Backend refuses / can't enumerate. First-batch
// failure = we emit ONE run-level anomaly and
// complete cleanly (the run stays useful — the
// operator learns why nothing was checked
// instead of getting a red error). Mid-scan
// failure = we fail the run.
let is_first_batch = cursor.is_none() && finding_count == 0;
if is_first_batch {
// No local increment — the local
// `finding_count` is only used for the
// completion log below, but this branch
// returns immediately. The finding IS
// persisted + counted in `stats.finding_count`
// by `record_or_log` → `store.record_finding`.
record_or_log(
store,
BACKEND_CONSISTENCY_JOB_NAME,
"backend_unenumerable",
"anomaly",
None,
serde_json::json!({
"backend": self.backend.backend_type(),
"error": format!("{e}"),
"note": "backend refused enumeration; no per-blob orphan probes attempted",
}),
)
.await;
tracing::info!(
target: "oxicloud::consistency",
event = "backend_consistency.unenumerable",
run_id = %store.run_id(),
backend = self.backend.backend_type(),
"backend refused enumeration (typical during migration or on backends without list support)"
);
return RunOutcome::Completed;
}
return RunOutcome::Failed {
message: format!("backend list failed mid-scan: {e}"),
};
}
};
let grace_cutoff = Utc::now() - CREATE_GRACE;
// Non-canonical files in the blob namespace — sidecars,
// wrong extensions, foreign namespaces. Informational
// only (severity `anomaly`, blue notice pill). Emitted
// BEFORE the blob orphan probes so operators see them
// grouped near the top of the findings list per batch.
// Grace-window filter applies here too — a temp file
// being written should not fire a notice.
for unknown in &page.unknowns {
if let Some(mtime) = unknown.mtime
&& mtime > grace_cutoff
{
continue;
}
finding_count += 1;
record_or_log(
store,
BACKEND_CONSISTENCY_JOB_NAME,
"unknown_backend_file",
"anomaly",
None,
serde_json::json!({
"path": unknown.path,
"mtime": unknown.mtime.map(|t| t.to_rfc3339()),
"backend": self.backend.backend_type(),
"note": "non-canonical file in blob namespace (sidecar / wrong extension); not managed by dedup",
}),
)
.await;
}
if page.blobs.is_empty() && page.next_cursor.is_none() {
// Nothing more to enumerate. Empty-blobs batches
// with unknowns still emitted above are fine — we
// fall through to completion.
tracing::info!(
target: "oxicloud::consistency",
event = "backend_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
"backend_consistency completed with {} finding(s)",
finding_count
);
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}"),
};
}
}
};
for entry in &page.blobs {
if db_present.contains(&entry.hash) {
continue;
}
if let Some(mtime) = entry.mtime
&& mtime > grace_cutoff
{
continue;
}
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": self.backend.backend_type(),
}),
)
.await;
}
// Advance cursor + checkpoint. Scanned count tracks
// both blobs and unknowns since we walked both.
let batch_len = (page.blobs.len() + page.unknowns.len()) as u64;
cursor = page.next_cursor;
let cursor_bytes = cursor
.as_ref()
.map(|s| s.as_bytes().to_vec())
.unwrap_or_default();
if let Err(e) = store.checkpoint(cursor_bytes, batch_len).await {
return RunOutcome::Failed {
message: format!("checkpoint: {e}"),
};
}
// Backend returned no next_cursor → enumeration
// complete. Emit the completion log and return.
if cursor.is_none() {
tracing::info!(
target: "oxicloud::consistency",
event = "backend_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
"backend_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
}
}
}
@@ -0,0 +1,494 @@
//! 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.
//!
//! Three per-row checks (subject-iteration principle in action —
//! one walk, multiple branches):
//!
//! * `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.
//!
//! * `refcount_mismatch` (severity `inconsistent`) —
//! `storage.blobs.ref_count` disagrees with the actual reference
//! count computed from `storage.files.blob_hash` +
//! `storage.chunk_manifests.chunk_hashes[]`. Under-count means
//! dedup GC could prematurely reap a live blob; over-count means
//! 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`
//!
//! `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.
//!
//! ### Not covered here
//!
//! * **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.
//! * **Manifest-level integrity** (`storage.chunk_manifests` rows
//! pointing at reaped chunks) — already covered by
//! `files_consistency::chunk_missing`.
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use sqlx::PgPool;
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
use crate::infrastructure::scheduler::{
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, RecoverableJobHandler, RunOutcome,
RunStatus, record_or_log,
};
pub const BLOBS_CONSISTENCY_JOB_NAME: &str = "blobs_consistency";
/// 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.
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>,
backend: Arc<dyn BlobStorageBackend>,
}
impl BlobsConsistencyCheck {
pub fn new(pool: Arc<PgPool>, backend: Arc<dyn BlobStorageBackend>) -> Self {
Self { pool, backend }
}
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
}
}
#[derive(Debug, sqlx::FromRow)]
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
/// stored counter) to detect drift.
actual_ref_count: i64,
}
#[async_trait]
impl RecoverableJobHandler for BlobsConsistencyCheck {
fn name(&self) -> &str {
BLOBS_CONSISTENCY_JOB_NAME
}
/// 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> {
let row: Result<(i64,), sqlx::Error> = sqlx::query_as("SELECT COUNT(*) FROM storage.blobs")
.fetch_one(self.pool.as_ref())
.await;
match row {
Ok((n,)) => Some(n.max(0) as u64),
Err(e) => {
tracing::debug!(
target: "oxicloud::consistency",
event = "blobs_consistency.count_total_failed",
error = %e,
"count_total failed — run will not surface a progress bar"
);
None
}
}
}
async fn run_resumable(
&self,
store: &dyn JobStore,
args: &JobRunArgs,
resume_cursor: Option<Vec<u8>>,
) -> RunOutcome {
// Cursor = the last-visited `hash` string, UTF-8-encoded. On
// resume, we walk `WHERE hash > $cursor` in ASC order. First
// batch: NULL cursor → start from the smallest hash.
let mut cursor: Option<String> = match resume_cursor {
None => None,
Some(bytes) if bytes.is_empty() => None,
Some(bytes) => match String::from_utf8(bytes) {
Ok(s) => Some(s),
Err(e) => {
return RunOutcome::Failed {
message: format!("invalid cursor: not valid UTF-8: {e}"),
};
}
},
};
// Per-run finding counter (feeds outer JobOutcome extras via
// stats.finding_count — actual persistence happens in
// `record_finding` on each emission).
let mut finding_count = 0u64;
// Deep mode = re-hash bytes for bit-rot detection. Logged
// once at run start so operators tailing tracing know why the
// scan is taking hours.
if args.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)"
);
}
loop {
// Cooperative cancel poll between batches.
match store.status().await {
Ok(RunStatus::CancelRequested) => {
tracing::info!(
target: "oxicloud::consistency",
event = "blobs_consistency.cancelled",
run_id = %store.run_id(),
finding_count = finding_count,
"blobs_consistency cancelled cooperatively, pausing"
);
return RunOutcome::Paused {
cursor: cursor
.as_ref()
.map(|s| s.as_bytes().to_vec())
.unwrap_or_default(),
};
}
Ok(_) => {}
Err(e) => {
return RunOutcome::Failed {
message: format!("status poll: {e}"),
};
}
}
// Fetch the next batch. Per-row `actual_ref_count`
// computed inline via correlated subqueries — one for
// legacy whole-file references (`files.blob_hash`), one
// for CDC chunk references (`chunk_manifests.chunk_hashes`).
// GIN index on `chunk_hashes` (migration
// 20260628000000_delta_upload_gin_index) makes the
// `= ANY(chunk_hashes)` probe cheap.
// `storage.blobs.ref_count` semantics — what the invariant
// dedup_service maintains actually is:
//
// ref_count = (number of chunk_manifests whose
// chunk_hashes[] contains this hash)
// + (number of files.blob_hash pointing at
// this hash on the LEGACY whole-file path
// — i.e. files with NO manifest for their
// blob_hash)
//
// Naively `COUNT(files) + COUNT(manifests referring)`
// double-counts single-chunk CDC files: for a file whose
// whole-file hash == its single chunk's hash (any file
// small enough to fit in one CDC chunk — under ~256 KB
// average), the file appears BOTH in `files.blob_hash`
// AND in the manifest's `chunk_hashes[]`. The `NOT
// EXISTS` clause below excludes CDC-path files from the
// legacy count so the two terms don't overlap.
let rows: Vec<BlobRow> = match sqlx::query_as(
r#"
SELECT
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 f
WHERE f.blob_hash = b.hash
AND NOT EXISTS (
SELECT 1 FROM storage.chunk_manifests m
WHERE m.file_hash = f.blob_hash
))
+ (SELECT COUNT(*) FROM storage.chunk_manifests m
WHERE b.hash = ANY(m.chunk_hashes))
)::bigint AS actual_ref_count
FROM storage.blobs b
WHERE ($1::text IS NULL OR b.hash > $1)
ORDER BY b.hash
LIMIT $2
"#,
)
.bind(cursor.as_deref())
.bind(BATCH_SIZE)
.fetch_all(self.pool.as_ref())
.await
{
Ok(r) => r,
Err(e) => {
return RunOutcome::Failed {
message: format!("batch fetch: {e}"),
};
}
};
if rows.is_empty() {
tracing::info!(
target: "oxicloud::consistency",
event = "blobs_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
deep = args.deep,
"blobs_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
let grace_cutoff = Utc::now() - CREATE_GRACE;
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;
record_or_log(
store,
BLOBS_CONSISTENCY_JOB_NAME,
"refcount_mismatch",
"inconsistent",
None, // hash isn't a UUID; resource identifier lives in detail
serde_json::json!({
"hash": row.hash,
"stored": row.ref_count,
"actual": row.actual_ref_count,
"delta": row.actual_ref_count - row.ref_count as i64,
"size": row.size,
"affected_files": affected,
}),
)
.await;
}
// 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 self.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 args.deep {
match recompute_hash(self.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) => {
tracing::warn!(
target: "oxicloud::consistency",
event = "blobs_consistency.recompute_hash_error",
run_id = %store.run_id(),
hash = %row.hash,
error = %e,
"recompute_hash failed; not a corruption signal on its own"
);
}
}
}
}
// Advance cursor + checkpoint.
let last_hash = rows.last().map(|r| r.hash.clone()).expect("non-empty rows");
cursor = Some(last_hash.clone());
let batch_len = rows.len() as u64;
if let Err(e) = store.checkpoint(last_hash.into_bytes(), batch_len).await {
return RunOutcome::Failed {
message: format!("checkpoint: {e}"),
};
}
if (rows.len() as i64) < BATCH_SIZE {
tracing::info!(
target: "oxicloud::consistency",
event = "blobs_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
deep = args.deep,
"blobs_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
}
}
}
/// 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())
}
@@ -412,6 +412,29 @@ impl BlobStorageBackend for CachedBlobBackend {
let path = self.cached_path(hash); let path = self.cached_path(hash);
if path.exists() { Some(path) } else { None } if path.exists() { Some(path) } else { None }
} }
/// Enumeration MUST delegate to the primary (inner) backend, not
/// the local cache. The cache is by definition a subset (only
/// recently-accessed blobs); walking the cache would look like
/// "most of my blobs are orphans" from the tenant's perspective.
/// The inner backend is the authoritative "what exists" source.
fn list_blob_hashes(
&self,
cursor: Option<String>,
limit: usize,
) -> Pin<
Box<
dyn std::future::Future<
Output = Result<
crate::application::ports::blob_storage_ports::BlobListPage,
DomainError,
>,
> + Send
+ '_,
>,
> {
self.inner.list_blob_hashes(cursor, limit)
}
} }
// ── Cache internals (miss path + population) ─────────────────────── // ── Cache internals (miss path + population) ───────────────────────
@@ -0,0 +1,186 @@
//! "Run all consistency checks" coordinator.
//!
//! A plain [`JobHandler`] (not [`RecoverableJobHandler`]) — it walks
//! nothing, holds no cursor. Its whole job is to snapshot the
//! registry, filter to names ending `_consistency`, and dispatch each
//! sequentially via `registry.trigger(name, args)`. Sub-jobs receive
//! the SAME `JobRunArgs` the batch was invoked with — so
//! `?deep=true` on the batch propagates to whichever tenants respect
//! it (currently future `storage_consistency`, but the plumbing is in
//! place).
//!
//! ### Why a wrapper instead of a bulk endpoint
//!
//! Operators want one click for "run all". Building a general-purpose
//! `POST /api/admin/jobs/*/trigger` group-endpoint would need its own
//! auth path, its own concurrency envelope, its own outcome shape.
//! A wrapper JobHandler reuses ALL of that infrastructure:
//!
//! - Same admin URL: `POST /api/admin/jobs/consistency_batch/trigger`.
//! - Same audit trail: one line per batch invocation.
//! - Same exclusivity primitive: the Part 1 per-job semaphore keeps
//! two `consistency_batch` runs from stomping each other. Two
//! batches (say, one `?deep=false` + one `?deep=true`) share the
//! same lock — an admin cannot accidentally start a deep pass
//! while a normal one is still walking.
//! - Same JSON outcome envelope — `per_check` lands under
//! `outcome.extra`, which the admin UI can drill into without
//! inventing a new response schema.
//!
//! ### Why the batch always returns `Ok`
//!
//! The batch's job is **dispatch**, not investigation. A child
//! failing means the child failed — not the batch. Failures surface
//! in `extra.per_check[<name>].outcome = "err"`; the operator drills
//! in. Reporting the batch itself as `Err` would confuse the metric
//! "did the batch run" with "did all children succeed", which are
//! genuinely different questions.
//!
//! ### Registration ordering
//!
//! `consistency_batch` MUST register AFTER every tenant it dispatches
//! — but only for a debug-affordance reason: the ordering of the
//! `GET /api/admin/jobs` response mirrors registration order, and
//! having the wrapper sit at the end of the consistency block reads
//! more naturally. Snapshot filtering happens at RUN time, so a
//! reversed order would still work; DI's ordering is aesthetic.
//!
//! ### Arc cycle avoidance
//!
//! [`ConsistencyBatch`] holds a `Weak<JobRegistry>` — the registry
//! owns an `Arc<dyn JobHandler>` for the batch, and the batch needs
//! access back to `trigger`. A strong `Arc<JobRegistry>` inside the
//! handler would leak the registry forever. Upgrading the weak on
//! each `run()` is cheap (one refcount bump) and gracefully surfaces
//! "registry dropped mid-shutdown" as an error rather than a hang.
use std::sync::{Arc, Weak};
use async_trait::async_trait;
use serde_json::json;
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
pub const CONSISTENCY_BATCH_JOB_NAME: &str = "consistency_batch";
pub struct ConsistencyBatch {
registry: Weak<JobRegistry>,
}
impl ConsistencyBatch {
pub fn new(registry: &Arc<JobRegistry>) -> Self {
Self {
registry: Arc::downgrade(registry),
}
}
/// Chainable self-registration — mirrors the per-tenant helpers.
/// On-demand only; there is no periodic tick (operators fire it
/// when they want to sweep, or the frontend "run all" button in
/// `/admin/jobs` triggers it once the UI ships).
pub async fn register_job(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
registry.register(self.clone(), None, None).await;
self
}
}
#[async_trait]
impl JobHandler for ConsistencyBatch {
fn name(&self) -> &str {
CONSISTENCY_BATCH_JOB_NAME
}
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,
// in which case the scheduler is winding down anyway.
let registry = match self.registry.upgrade() {
Some(r) => r,
None => {
return JobOutcome::err(
"consistency_batch: registry dropped (shutdown in progress?)",
);
}
};
// Snapshot + filter. `snapshot_all` would give us Arc<JobEntry>
// handles too, but we don't need them — `registry.trigger`
// does the lookup by name itself. `snapshot` returns the
// per-job public DTOs, which is exactly the shape we want.
let targets: Vec<String> = registry
.snapshot()
.await
.into_iter()
.filter(|s| s.name.ends_with("_consistency") && s.name != CONSISTENCY_BATCH_JOB_NAME)
.map(|s| s.name)
.collect();
let mut per_check = serde_json::Map::new();
let mut ok_count = 0u64;
let mut err_count = 0u64;
// Sequential dispatch. Parallel would give us tail-latency
// wins but also multiplies DB pressure — the maintenance pool
// is shared with the periodic sweeps that keep running while
// the batch runs. Sequential keeps memory + IO envelope
// predictable; the batch is a "run once in a while, take as
// long as it takes" workflow, not a hot path.
for name in &targets {
let child = registry.trigger(name, args).await;
match &child {
Some(JobOutcome::Ok { count, extra }) => {
ok_count += 1;
let mut entry = json!({
"outcome": "ok",
"count": count,
});
if !extra.is_null() {
// Preserve per-check `extra` (e.g.
// drives_consistency emits drift counts here
// once `run_findings` lands). Nested under
// its own key so operators reading
// `per_check[name]` see a stable shape.
entry["extra"] = extra.clone();
}
per_check.insert(name.clone(), entry);
}
Some(JobOutcome::Err { message }) => {
err_count += 1;
per_check.insert(
name.clone(),
json!({
"outcome": "err",
"message": message,
}),
);
}
None => {
// Race: the job disappeared between snapshot and
// trigger. In practice this only happens if some
// future code path deregisters a tenant at
// runtime. Report so operators see it in the
// batch outcome and can chase the cause.
err_count += 1;
per_check.insert(
name.clone(),
json!({
"outcome": "err",
"message": "job no longer registered (race with deregistration)",
}),
);
}
}
}
JobOutcome::ok_with(
targets.len() as u64,
json!({
"per_check": per_check,
"deep": args.deep,
"force": args.force,
"ok": ok_count,
"err": err_count,
}),
)
}
}
@@ -3127,6 +3127,29 @@ impl DedupPort for DedupService {
/// and admin URLs (`POST /api/admin/jobs/dedup_gc/trigger`). /// and admin URLs (`POST /api/admin/jobs/dedup_gc/trigger`).
pub const DEDUP_GC_JOB_NAME: &str = "dedup_gc"; pub const DEDUP_GC_JOB_NAME: &str = "dedup_gc";
impl DedupService {
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style chaining. **On-demand only** —
/// registered with `interval = None`. The periodic GC role
/// belongs to trash cleanup (which invokes `garbage_collect()`
/// inline as its tail step); a duplicate scheduled tick here
/// would double the reclamation work. Registration exists solely
/// to expose the admin trigger uniformly.
pub async fn register(
self: std::sync::Arc<Self>,
registry: &crate::infrastructure::scheduler::JobRegistry,
) -> std::sync::Arc<Self> {
registry
.register(
self.clone() as std::sync::Arc<dyn crate::infrastructure::scheduler::JobHandler>,
None, // on-demand
None, // no timeout
)
.await;
self
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl crate::infrastructure::scheduler::JobHandler for DedupService { impl crate::infrastructure::scheduler::JobHandler for DedupService {
fn name(&self) -> &str { fn name(&self) -> &str {
@@ -0,0 +1,689 @@
//! First tenant of Part 2 (recoverable-run engine).
//!
//! Iterates `storage.drives` and reports each drive whose cached
//! `used_bytes` differs from `SUM(files.size) WHERE NOT is_trashed`
//! for that drive. **Read-only** — reports drift as findings but does
//! NOT fix it. The existing `storage_reconcile` job (Part 1) is what
//! corrects the counter; this check surfaces WHEN drift happens so
//! operators can trace it back to root cause (missed delta call,
//! delta failed silently, race, etc.).
//!
//! One check today — `used_bytes` drift — but structured so more
//! checks can slot in as per-row branches (quota-vs-usage inversion,
//! `kind` vs `default_for_user` invariants, ...). See memory note
//! `project_consistency_jobs_landscape`.
//!
//! Findings are LOGGED to `target: "oxicloud::consistency"` for now.
//! Persistence to `jobs.run_findings` lands with the findings-table
//! migration (deferred; see the plan doc). Once landed, this handler
//! swaps its `tracing::warn!` finding calls for
//! `store.record_finding(...)` — nothing else changes.
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 DRIVES_CONSISTENCY_JOB_NAME: &str = "drives_consistency";
/// Rows per batch. Drives are few (dozens per install), so this only
/// matters for the cancel-poll cadence — smaller batch = more frequent
/// status polls but more DB round-trips. 100 is comfortably fast for
/// any realistic drive count.
const BATCH_SIZE: i64 = 100;
pub struct DrivesConsistencyCheck {
pool: Arc<PgPool>,
}
impl DrivesConsistencyCheck {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Register self with the periodic-job scheduler as a recoverable
/// job, on-demand only (no periodic tick). Follows the same
/// chainable pattern as Part 1 tenants' `register_job` — DI stays
/// one line.
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
}
}
#[async_trait]
impl RecoverableJobHandler for DrivesConsistencyCheck {
fn name(&self) -> &str {
DRIVES_CONSISTENCY_JOB_NAME
}
/// Definitive count — one row per drive, table is tiny (dozens per
/// install), COUNT(*) is trivially fast. Enables progress bar on
/// the admin UI.
async fn count_total(&self) -> Option<u64> {
let row: Result<(i64,), sqlx::Error> =
sqlx::query_as("SELECT COUNT(*) FROM storage.drives")
.fetch_one(self.pool.as_ref())
.await;
match row {
Ok((n,)) => Some(n.max(0) as u64),
Err(e) => {
tracing::debug!(
target: "oxicloud::consistency",
event = "drives_consistency.count_total_failed",
error = %e,
"count_total failed — run will not surface a progress bar"
);
None
}
}
}
async fn run_resumable(
&self,
store: &dyn JobStore,
_args: &JobRunArgs,
resume_cursor: Option<Vec<u8>>,
) -> RunOutcome {
// Decode cursor. Convention for this job: 16 raw UUID bytes,
// or empty/absent = start from the beginning.
let mut cursor: Option<Uuid> = match resume_cursor {
None => None,
Some(bytes) if bytes.is_empty() => None,
Some(bytes) if bytes.len() == 16 => {
let mut arr = [0u8; 16];
arr.copy_from_slice(&bytes);
Some(Uuid::from_bytes(arr))
}
Some(bytes) => {
return RunOutcome::Failed {
message: format!("invalid cursor: expected 16 bytes, got {}", bytes.len()),
};
}
};
let mut drift_count = 0u64;
loop {
// Cancel poll BETWEEN batches — the cooperative cancel
// contract (`RecoverableJobHandler` trait doc).
match store.status().await {
Ok(RunStatus::CancelRequested) => {
tracing::info!(
target: "oxicloud::consistency",
event = "drives_consistency.cancelled",
run_id = %store.run_id(),
drift_count = drift_count,
"drives_consistency cancelled cooperatively, pausing"
);
return RunOutcome::Paused {
cursor: cursor.map(|u| u.as_bytes().to_vec()).unwrap_or_default(),
};
}
Ok(_) => {}
Err(e) => {
return RunOutcome::Failed {
message: format!("status poll: {e}"),
};
}
}
// Fetch next batch of drives + their actual SUM in one
// query. LEFT JOIN via correlated subquery gets us both
// sides in one round-trip; the storage_reconcile sweep
// uses the same shape.
// Grace window: skip drives created within the last hour.
// A drive being created RIGHT NOW may still have its first
// upload's `used_bytes` counter not-yet-incremented while
// the `files` row is already visible — that would false-
// positive as `stale_used_bytes`. 1h matches the window
// `blobs_consistency` uses; same rationale (writes-in-flight).
// NOTE: `storage.drives` has no `name` column. The drive's
// display name lives on its root folder (see the schema
// comment on `drives.root_folder_id` — "The display name
// lives here"). LEFT JOIN storage.folders ON id =
// drive.root_folder_id and read `folders.name` as the
// drive's human identifier. `COALESCE` handles the
// (bug-only) case where root_folder_id is NULL.
let rows: Vec<(Uuid, String, i64, i64)> = match sqlx::query_as(
r#"
SELECT
d.id AS id,
COALESCE(rf.name, '?') AS name,
d.used_bytes AS used_bytes,
COALESCE((
SELECT SUM(size)::bigint
FROM storage.files
WHERE drive_id = d.id
AND NOT is_trashed
), 0) AS actual_bytes
FROM storage.drives d
LEFT JOIN storage.folders rf ON rf.id = d.root_folder_id
WHERE ($1::uuid IS NULL OR d.id > $1)
AND d.created_at < NOW() - INTERVAL '1 hour'
ORDER BY d.id
LIMIT $2
"#,
)
.bind(cursor)
.bind(BATCH_SIZE)
.fetch_all(self.pool.as_ref())
.await
{
Ok(r) => r,
Err(e) => {
return RunOutcome::Failed {
message: format!("batch fetch: {e}"),
};
}
};
if rows.is_empty() {
tracing::info!(
target: "oxicloud::consistency",
event = "drives_consistency.completed",
run_id = %store.run_id(),
drift_count = drift_count,
"drives_consistency completed with {} drift finding(s)",
drift_count
);
return RunOutcome::Completed;
}
// Per-row check: cached vs actual. This is the ONE check
// in v1 — more per-row branches (quota inversion, kind vs
// default_for_user, …) slot in here.
for (drive_id, drive_name, cached, actual) in &rows {
if *cached != *actual {
drift_count += 1;
// Persisted finding via the shared helper.
// `stale_used_bytes` + severity `inconsistent`
// (counters wrong, content intact — the
// reconciliation sweep will fix on its next tick).
record_or_log(
store,
DRIVES_CONSISTENCY_JOB_NAME,
"stale_used_bytes",
"inconsistent",
Some(*drive_id),
serde_json::json!({
"name": drive_name,
"cached": cached,
"actual": actual,
"delta": cached - actual,
}),
)
.await;
}
}
// Advance cursor to the last row's id + checkpoint.
let last_id = rows
.last()
.map(|(id, _, _, _)| *id)
.expect("non-empty rows");
cursor = Some(last_id);
let batch_len = rows.len() as u64;
if let Err(e) = store
.checkpoint(last_id.as_bytes().to_vec(), batch_len)
.await
{
return RunOutcome::Failed {
message: format!("checkpoint: {e}"),
};
}
// Short batch = drained the drives table.
if (rows.len() as i64) < BATCH_SIZE {
tracing::info!(
target: "oxicloud::consistency",
event = "drives_consistency.completed",
run_id = %store.run_id(),
drift_count = drift_count,
"drives_consistency completed with {} drift finding(s)",
drift_count
);
return RunOutcome::Completed;
}
}
}
}
// ─── Integration tests — real PG round-trip ─────────────────────────────────
//
// Gated on `--cfg integration_tests` (see `just test-integration`).
// Requires a running test PG on 5433 with `oxicloud_test` DB, schema
// applied via `tests/common/init-test-schema.sh`. Runs:
// just test-integration -- drives_consistency_service
//
// Tests exercise the full recoverable-run engine against real PG:
// - PgJobStoreProvider::open_or_start creates a run row.
// - Handler walks a seeded drive, checkpoints, marks Completed.
// - `stats.scanned_count` bumped, drive row untouched (read-only).
// - Drift is DETECTED — surfaced as a `consistency_finding` event
// on the `oxicloud::consistency` tracing target. Captured via a
// scoped subscriber.
#[cfg(integration_tests)]
#[allow(dead_code, unused_imports)] // items are exercised by #[tokio::test]
// fns; cargo check --lib doesn't see the
// test entry-point call graph.
mod integration_tests {
use super::*;
use crate::infrastructure::scheduler::{JobStoreProvider, OpenedRun, RunStatus};
use sqlx::Row;
use sqlx::postgres::PgPoolOptions;
async fn test_pool() -> Arc<sqlx::PgPool> {
let url = crate::integration_test_support::test_db_url();
let pool = PgPoolOptions::new()
.max_connections(4)
.connect(&url)
.await
.expect("connect to test DB — run tests/common/spawn-db.sh first");
Arc::new(pool)
}
/// Seed a personal drive with `used_bytes = cached` and one file
/// of size `actual` (post-D7 schema — no `user_id` on files /
/// folders, drives created via the circular-FK dance).
///
/// `default_for_user = NULL` on the drive so we don't collide
/// with the seeded user's real default (partial unique index).
/// Returns the drive id.
async fn seed_drift(pool: &sqlx::PgPool, cached: i64, actual: i64) -> Uuid {
let owner_id: Uuid = sqlx::query("SELECT id FROM auth.users LIMIT 1")
.fetch_one(pool)
.await
.expect("test DB must have at least one user")
.get(0);
// Steps 1-3 MUST run inside one transaction because
// `trg_no_orphan_root_folder` is DEFERRABLE INITIALLY DEFERRED
// (fires at COMMIT). Autocommit-per-statement would trip the
// trigger on the folder INSERT before the drive UPDATE gets a
// chance to close the FK. Mirrors `DrivePgRepository::
// create_personal_drive_atomic`.
let mut tx = pool.begin().await.expect("begin drive-create tx");
// 1. Drive with kind=personal, no default_for_user (avoids
// partial-unique conflict with owner's real default),
// used_bytes=0 for now — we set the fake value LAST.
let drive_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.drives
(kind, default_for_user, quota_bytes, used_bytes)
VALUES ('personal', NULL, NULL, 0)
RETURNING id
"#,
)
.fetch_one(&mut *tx)
.await
.expect("insert test drive");
// 2. Root folder for the drive (parent_id = NULL = drive root).
// Post-D7: only `name`, `parent_id`, `drive_id`, `created_by`,
// `updated_by` on the INSERT — `user_id`/`path`/`ltree` are
// dropped or derived.
let root_folder_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.folders
(name, parent_id, drive_id, created_by, updated_by)
VALUES ('drift-test-root', NULL, $1, $2, $2)
RETURNING id
"#,
)
.bind(drive_id)
.bind(owner_id)
.fetch_one(&mut *tx)
.await
.expect("insert root folder for test drive");
// 3. Close the circular FK: drive.root_folder_id points at
// the folder we just created.
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
.bind(root_folder_id)
.bind(drive_id)
.execute(&mut *tx)
.await
.expect("wire drive.root_folder_id");
tx.commit()
.await
.expect("commit drive-create tx (deferred trigger fires here)");
// 4. Optionally insert a file summing to `actual`. Also insert
// a matching `storage.blobs` row so `trg_files_decrement_blob_ref`
// stays happy on cleanup. Fake hash is 64-char hex derived
// from a UUID — plausible shape, unique per fixture invocation.
if actual > 0 {
let fake_hash = format!(
"{:032x}{:032x}",
Uuid::new_v4().as_u128(),
Uuid::new_v4().as_u128()
);
sqlx::query(
r#"
INSERT INTO storage.blobs (hash, size, ref_count, content_type)
VALUES ($1, $2, 1, 'application/octet-stream')
ON CONFLICT (hash) DO NOTHING
"#,
)
.bind(&fake_hash)
.bind(actual)
.execute(pool)
.await
.expect("insert fixture blob");
sqlx::query(
r#"
INSERT INTO storage.files
(name, folder_id, drive_id, blob_hash, size,
mime_type, is_trashed, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5,
'application/octet-stream', false, $6, $6)
"#,
)
.bind(format!("drift-fixture-{}.bin", Uuid::new_v4()))
.bind(root_folder_id)
.bind(drive_id)
.bind(&fake_hash)
.bind(actual)
.bind(owner_id)
.execute(pool)
.await
.expect("insert fixture file");
}
// 5. Set the artificially-wrong cached used_bytes. LAST, so
// no INSERT-side trigger overwrites our fake (there is no
// such trigger today, but ordering is cheap insurance).
// Also backdate `created_at` past the tenant's 1-hour
// grace window (`created_at < NOW() - INTERVAL '1 hour'`
// in the SQL) — freshly-seeded fixtures are by definition
// younger than that window and would otherwise be
// silently skipped by the scan, leaving `scanned_count`
// at 0 and the drift-detection assertions with nothing
// to compare against.
sqlx::query(
"UPDATE storage.drives \
SET used_bytes = $1, \
created_at = NOW() - INTERVAL '2 hours' \
WHERE id = $2",
)
.bind(cached)
.bind(drive_id)
.execute(pool)
.await
.expect("set fake used_bytes + backdate");
drive_id
}
async fn cleanup_test_drive(pool: &sqlx::PgPool, drive_id: Uuid) {
// Cascading FK on files.drive_id, folders.drive_id kicks in.
sqlx::query("DELETE FROM storage.files WHERE drive_id = $1")
.bind(drive_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM storage.folders WHERE drive_id = $1")
.bind(drive_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM storage.drives WHERE id = $1")
.bind(drive_id)
.execute(pool)
.await
.ok();
}
async fn cleanup_run(pool: &sqlx::PgPool, run_id: Uuid) {
sqlx::query("DELETE FROM jobs.recoverable_runs WHERE id = $1")
.bind(run_id)
.execute(pool)
.await
.ok();
}
// ─── Parallel-test serialization ───────────────────────────────────────
//
// Cargo runs `#[test]` fns in parallel; the three tests in this
// module all target `job_name = 'drives_consistency'` in
// `jobs.recoverable_runs`. Without serialization,
// `second_trigger_is_already_active` seeds a Running row that
// makes `detects_used_bytes_drift`'s `open_or_start` short-circuit
// with `AlreadyActive` — its handler never dispatches, no
// `consistency_finding` fires, and the drift assertion sees empty
// events. Holding `TEST_LOCK` for each test's full body prevents
// that interleaving; `wipe_our_runs` on entry defends against
// stale rows left by a crashed / cancelled prior run.
static TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn wipe_our_runs(pool: &sqlx::PgPool) {
sqlx::query("DELETE FROM jobs.recoverable_runs WHERE job_name = 'drives_consistency'")
.execute(pool)
.await
.ok();
}
// ─── The tests ─────────────────────────────────────────────────────────
#[tokio::test]
async fn drives_consistency_detects_used_bytes_drift() {
let _lock = TEST_LOCK.lock().await;
let pool = test_pool().await;
wipe_our_runs(pool.as_ref()).await;
// Cached = 999, actual = 200 → delta = 799 (positive = cached over-reports).
let drive_id = seed_drift(pool.as_ref(), 999, 200).await;
// Run end-to-end through the recoverable engine: PgJobStoreProvider
// creates a run row, run_or_resume dispatches DrivesConsistencyCheck,
// handler walks the drive, marks Completed. Findings land in
// `jobs.run_findings` via `store.record_finding()` — asserted
// via `provider.list_findings(run_id, ...)` below (post-Slice 7,
// no more tracing-event capture).
let provider: Arc<dyn JobStoreProvider> = Arc::new(
crate::infrastructure::scheduler::PgJobStoreProvider::new(pool.clone()),
);
let handler: Arc<dyn RecoverableJobHandler> =
Arc::new(DrivesConsistencyCheck::new(pool.clone()));
let outcome = crate::infrastructure::scheduler::run_or_resume(
handler,
provider.clone(),
&JobRunArgs::default(),
)
.await;
// Framework assertions.
assert!(outcome.is_ok(), "run must complete: {outcome:?}");
// Read-only invariant — drive's used_bytes is UNCHANGED by the check.
let post_cached: i64 = sqlx::query("SELECT used_bytes FROM storage.drives WHERE id = $1")
.bind(drive_id)
.fetch_one(pool.as_ref())
.await
.expect("drive still exists")
.get(0);
assert_eq!(post_cached, 999, "drives_consistency must be read-only");
// Find the run row that was created and verify its state.
let latest_run: Option<(Uuid, String, i64)> = sqlx::query_as(
r#"
SELECT id, status, COALESCE((stats->>'scanned_count')::bigint, 0)
FROM jobs.recoverable_runs
WHERE job_name = 'drives_consistency'
ORDER BY started_at DESC
LIMIT 1
"#,
)
.fetch_optional(pool.as_ref())
.await
.expect("query recoverable_runs");
let (run_id, status, scanned) = latest_run.expect("run row must exist after run_or_resume");
assert_eq!(status, "Completed", "run must be Completed");
assert!(
scanned >= 1,
"scanned_count must include at least our drive, got {scanned}"
);
// Drift-detection assertion — the finding for our seeded drive
// is now a persisted row. Query via the same interface the
// admin endpoint uses so the test also pins the read path.
let findings = provider
.list_findings(run_id, 500, 0)
.await
.expect("list_findings");
let ours = findings
.iter()
.find(|f| f.resource_id == Some(drive_id))
.unwrap_or_else(|| {
panic!("expected a persisted finding for drive {drive_id}, got: {findings:?}")
});
assert_eq!(ours.kind, "stale_used_bytes", "wrong kind: {ours:?}");
assert_eq!(ours.severity, "inconsistent", "wrong severity: {ours:?}");
assert_eq!(
ours.detail.get("cached").and_then(|v| v.as_i64()),
Some(999),
"cached mismatch in detail: {ours:?}"
);
assert_eq!(
ours.detail.get("actual").and_then(|v| v.as_i64()),
Some(200),
"actual mismatch in detail: {ours:?}"
);
assert_eq!(
ours.detail.get("delta").and_then(|v| v.as_i64()),
Some(799),
"delta mismatch in detail: {ours:?}"
);
// Counter mirror — record_finding also bumps stats.finding_count.
let stored_finding_count: i64 = sqlx::query(
"SELECT COALESCE((stats->>'finding_count')::bigint, 0) FROM jobs.recoverable_runs WHERE id = $1",
)
.bind(run_id)
.fetch_one(pool.as_ref())
.await
.expect("query stats.finding_count")
.get(0);
assert!(
stored_finding_count >= 1,
"finding_count must be bumped, got {stored_finding_count}"
);
// Cleanup — even on assertion failure the test panics before this,
// leaving the test DB slightly dirty. That's fine per session; the
// next spawn-db.sh reset clears everything.
cleanup_run(pool.as_ref(), run_id).await;
cleanup_test_drive(pool.as_ref(), drive_id).await;
}
#[tokio::test]
async fn drives_consistency_no_drift_emits_no_finding() {
let _lock = TEST_LOCK.lock().await;
let pool = test_pool().await;
wipe_our_runs(pool.as_ref()).await;
// cached == actual → no drift.
let drive_id = seed_drift(pool.as_ref(), 500, 500).await;
let provider: Arc<dyn JobStoreProvider> = Arc::new(
crate::infrastructure::scheduler::PgJobStoreProvider::new(pool.clone()),
);
let handler: Arc<dyn RecoverableJobHandler> =
Arc::new(DrivesConsistencyCheck::new(pool.clone()));
let outcome = crate::infrastructure::scheduler::run_or_resume(
handler,
provider.clone(),
&JobRunArgs::default(),
)
.await;
assert!(outcome.is_ok());
// Locate the run row we just wrote.
let latest_run: Option<(Uuid,)> = sqlx::query_as(
"SELECT id FROM jobs.recoverable_runs WHERE job_name='drives_consistency' ORDER BY started_at DESC LIMIT 1",
)
.fetch_optional(pool.as_ref())
.await
.expect("query recoverable_runs");
let (run_id,) = latest_run.expect("run row must exist");
// For THIS drive, no persisted finding. Other drives in the test
// DB may still surface findings (unrelated fixture data); we only
// assert the invariant scoped to our drive_id.
let findings = provider
.list_findings(run_id, 500, 0)
.await
.expect("list_findings");
let ours = findings
.iter()
.filter(|f| f.resource_id == Some(drive_id))
.count();
assert_eq!(
ours, 0,
"no drift on this drive, expected 0 findings, got {ours}: {findings:?}"
);
cleanup_run(pool.as_ref(), run_id).await;
cleanup_test_drive(pool.as_ref(), drive_id).await;
}
#[tokio::test]
async fn drives_consistency_second_trigger_is_already_active() {
let _lock = TEST_LOCK.lock().await;
let pool = test_pool().await;
wipe_our_runs(pool.as_ref()).await;
// Directly INSERT a Running row for drives_consistency to
// simulate an in-flight prior dispatch, then observe that
// open_or_start refuses to spawn a parallel run.
let seeded_run_id = Uuid::new_v4();
sqlx::query(
r#"
INSERT INTO jobs.recoverable_runs (id, job_name, status, started_at, last_progress_at)
VALUES ($1, 'drives_consistency', 'Running', NOW(), NOW())
ON CONFLICT (job_name) WHERE status IN ('Running', 'Paused', 'CancelRequested') DO NOTHING
"#,
)
.bind(seeded_run_id)
.execute(pool.as_ref())
.await
.expect("insert seed Running row");
let provider: Arc<dyn JobStoreProvider> = Arc::new(
crate::infrastructure::scheduler::PgJobStoreProvider::new(pool.clone()),
);
let opened = provider
.open_or_start("drives_consistency")
.await
.expect("open_or_start");
match opened {
OpenedRun::AlreadyActive { status, .. } => {
assert_eq!(status, RunStatus::Running);
}
_ => panic!("expected AlreadyActive for a job with a Running row"),
}
// Cleanup.
sqlx::query("DELETE FROM jobs.recoverable_runs WHERE job_name = 'drives_consistency'")
.execute(pool.as_ref())
.await
.ok();
}
}
@@ -351,6 +351,29 @@ impl BlobStorageBackend for EncryptedBlobBackend {
// Encrypted blobs cannot be served directly from disk // Encrypted blobs cannot be served directly from disk
None None
} }
/// Enumeration = plaintext hashes, same as the inner backend.
/// Encryption operates on payload bytes, not on the hash key:
/// blob objects on the inner backend are stored under the
/// PLAINTEXT hash so dedup works. Delegating list to the inner
/// backend therefore returns exactly the right identifiers.
fn list_blob_hashes(
&self,
cursor: Option<String>,
limit: usize,
) -> Pin<
Box<
dyn std::future::Future<
Output = Result<
crate::application::ports::blob_storage_ports::BlobListPage,
DomainError,
>,
> + Send
+ '_,
>,
> {
self.inner.list_blob_hashes(cursor, limit)
}
} }
/// Collect a byte stream into a single `Vec<u8>`. /// Collect a byte stream into a single `Vec<u8>`.
@@ -0,0 +1,497 @@
//! Third tenant of Part 2 (recoverable-run engine).
//!
//! Iterates `storage.files` and reports each row whose parent-folder
//! state, blob reference, or denormalised size has drifted from
//! what the join with `storage.folders` + `storage.blobs` says is
//! true. **Read-only** — the fix path is other jobs (trash cascade
//! repair, dedup GC, blob resurrection).
//!
//! Post-D7 files schema notable columns:
//!
//! * `folder_id` — nullable; `NULL` = file at drive root. Cascade FK
//! to `storage.folders`.
//! * `blob_hash` — `NOT NULL`; MUST reference a row in
//! `storage.blobs.hash`.
//! * `size` — denormalised copy of the blob's byte length; the
//! original source of truth is `storage.blobs.size` (upload path
//! sets both; a mismatch is drift).
//! * NO `path` column and NO `user_id` column (dropped in D7). The
//! memory note's "path matches parent chain" check from the
//! earlier taxonomy does NOT apply here — files carry no
//! materialised path.
//!
//! ### v1 checks (three per-row branches)
//!
//! * `parent_folder_trashed` — a live file under a soft-deleted
//! parent folder. FK cascade + trash cascade should make this
//! impossible; occurrence means the cascade missed the row.
//! Files at drive root (`folder_id IS NULL`) are exempt — there is
//! no parent to check.
//! * `missing_blob` — file's `blob_hash` has no row in
//! `storage.blobs`. **Severity `data_loss`**: the file record
//! points at bytes the blob table doesn't know about, so any read
//! attempt fails. Historically this happens when the dedup GC
//! reaped a blob whose ref-count decrement raced with a fresh
//! file INSERT — the two-phase mark/sweep is meant to prevent
//! this, but the check surfaces regressions immediately.
//! * `blob_size_mismatch` — `files.size != blobs.size`. Cheap
//! because the same LEFT JOIN already loads `blobs.size`. Would
//! indicate the denormalised copy was ever set by a code path that
//! didn't read the blob's real length — a bug we want to see fast.
//!
//! ### Room to grow (same self-join, one more `if`)
//!
//! * `drive_id_parent_mismatch` — `files.drive_id` differs from
//! `parent.drive_id`. The join already loads it; adding this once
//! drive-membership rules stabilise post-D7 costs one branch.
//! * `mime_type_reconciliation` — compare `files.mime_type` against
//! the blob's `content_type`. Requires deciding which is
//! authoritative first.
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 FILES_CONSISTENCY_JOB_NAME: &str = "files_consistency";
/// Rows per batch. Files can be very numerous — hundreds of
/// thousands on medium installs, millions on large — but the per-row
/// work is a couple of comparisons. 500 keeps the cancel-poll cadence
/// sub-second while amortising round-trip overhead.
const BATCH_SIZE: i64 = 500;
pub struct FilesConsistencyCheck {
pool: Arc<PgPool>,
}
impl FilesConsistencyCheck {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Chainable self-registration — mirrors the other consistency
/// tenants. On-demand only (operators trigger from
/// `POST /api/admin/jobs/files_consistency/trigger` or via
/// `consistency_batch`).
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
}
}
#[derive(Debug, sqlx::FromRow)]
struct FileRow {
id: Uuid,
/// File name (basename). Captured into finding `detail` so
/// operators see a human identifier next to the UUID.
name: String,
folder_id: Option<Uuid>,
is_trashed: bool,
size: i64,
blob_hash: String,
/// `None` when `folder_id IS NULL` (file at drive root) — the
/// LEFT JOIN yields no parent row.
parent_is_trashed: Option<bool>,
/// Parent folder's materialised `path` (post-D7 files carry no
/// path themselves). `None` for root files.
parent_path: Option<String>,
/// Legacy whole-file blob row size (pre-CDC). `None` when the
/// file was ingested via CDC (`chunk_manifests` path) OR when
/// the blob is truly missing — disambiguated by `manifest_size`.
blob_size: Option<i64>,
/// CDC manifest total size. `Some` when the file was ingested
/// via FastCDC (its bytes live as chunks referenced by
/// `storage.chunk_manifests.chunk_hashes`, not as one
/// `storage.blobs` row). `None` when there is no manifest for
/// this hash.
manifest_size: Option<i64>,
/// Total chunks the manifest claims. `None` when the file is
/// pre-CDC (whole-file blob path) or has no manifest.
manifest_chunk_count: Option<i32>,
/// Count of chunks referenced by the manifest that have NO
/// matching row in `storage.blobs`. `None` when there's no
/// manifest to check. `Some(n)` with `n > 0` means the manifest
/// points at reaped chunks — a real data-loss condition, more
/// precise than plain `missing_blob` (which only fires when the
/// whole-file registry entry is absent). This is a DB-registry
/// check; physical backend-existence checks belong in the
/// future `storage_consistency` tenant.
chunks_missing: Option<i64>,
}
/// Build the file's display path from its folder's `path` and its
/// own `name`. Root files just show the name. Trashed folder paths
/// still work (ltree keeps them intact under `is_trashed`).
fn display_path(folder_path: Option<&str>, name: &str) -> String {
match folder_path {
Some(p) if !p.is_empty() => format!("{p}/{name}"),
_ => name.to_string(),
}
}
#[async_trait]
impl RecoverableJobHandler for FilesConsistencyCheck {
fn name(&self) -> &str {
FILES_CONSISTENCY_JOB_NAME
}
/// 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
/// it — an operator staring at a running files_consistency scan
/// wants a bar, and a seconds-scale one-off at run start is
/// invisible compared to the multi-minute scan that follows.
async fn count_total(&self) -> Option<u64> {
let row: Result<(i64,), sqlx::Error> = sqlx::query_as("SELECT COUNT(*) FROM storage.files")
.fetch_one(self.pool.as_ref())
.await;
match row {
Ok((n,)) => Some(n.max(0) as u64),
Err(e) => {
tracing::debug!(
target: "oxicloud::consistency",
event = "files_consistency.count_total_failed",
error = %e,
"count_total failed — run will not surface a progress bar"
);
None
}
}
}
async fn run_resumable(
&self,
store: &dyn JobStore,
_args: &JobRunArgs,
resume_cursor: Option<Vec<u8>>,
) -> RunOutcome {
// Cursor: 16 raw UUID bytes, empty/absent = start from
// beginning. Same convention as the other UUID-cursor
// tenants so the resume path in `PgJobStoreProvider` is
// uniform.
let mut cursor: Option<Uuid> = match resume_cursor {
None => None,
Some(bytes) if bytes.is_empty() => None,
Some(bytes) if bytes.len() == 16 => {
let mut arr = [0u8; 16];
arr.copy_from_slice(&bytes);
Some(Uuid::from_bytes(arr))
}
Some(bytes) => {
return RunOutcome::Failed {
message: format!("invalid cursor: expected 16 bytes, got {}", bytes.len()),
};
}
};
let mut finding_count = 0u64;
loop {
// Cancel poll BETWEEN batches — the cooperative cancel
// contract (see `RecoverableJobHandler` trait doc).
match store.status().await {
Ok(RunStatus::CancelRequested) => {
tracing::info!(
target: "oxicloud::consistency",
event = "files_consistency.cancelled",
run_id = %store.run_id(),
finding_count = finding_count,
"files_consistency cancelled cooperatively, pausing"
);
return RunOutcome::Paused {
cursor: cursor.map(|u| u.as_bytes().to_vec()).unwrap_or_default(),
};
}
Ok(_) => {}
Err(e) => {
return RunOutcome::Failed {
message: format!("status poll: {e}"),
};
}
}
// One query, two LEFT JOINs: (parent folder) + (blob
// row). Left-joining the blob is what lets us detect
// `missing_blob` — a matched row has `blob.size`
// populated; a miss surfaces as NULL.
// Three LEFT JOINs — the blob-existence check has to
// handle BOTH storage paths OxiCloud uses:
//
// * `storage.chunk_manifests` (CDC / FastCDC) — the
// dominant path for anything ingested after Apr 2026.
// Whole-file hash lives here; actual bytes are chunks
// referenced by `chunk_hashes[]`.
// * `storage.blobs` (legacy pre-CDC whole-file blob) —
// still supported via the read path's fallback for
// pre-CDC uploads.
//
// A file is "missing_blob" ONLY when NEITHER row exists.
// Deep chunk validation (every chunk in `chunk_hashes[]`
// present in `storage.blobs`) is out of scope here — it
// belongs in the future `storage_consistency` tenant that
// walks the backend against the blob registry.
// Correlated subquery `chunks_missing` runs per-row over
// the manifest's chunk_hashes array. `hash` is indexed
// (PRIMARY KEY on storage.blobs), so each `NOT EXISTS`
// probe is O(log n). NULL (not zero) when the file is
// pre-CDC or has no manifest — the LEFT JOIN result on
// `m` is NULL and `unnest(NULL::text[])` yields zero rows.
let rows: Vec<FileRow> = match sqlx::query_as(
r#"
SELECT
f.id AS id,
f.name AS name,
f.folder_id AS folder_id,
f.is_trashed AS is_trashed,
f.size AS size,
f.blob_hash AS blob_hash,
parent.is_trashed AS parent_is_trashed,
parent.path AS parent_path,
b.size AS blob_size,
m.total_size AS manifest_size,
m.chunk_count AS manifest_chunk_count,
CASE WHEN m.chunk_hashes IS NULL THEN NULL
ELSE (
SELECT COUNT(*)::bigint
FROM unnest(m.chunk_hashes) AS ch(hash)
WHERE NOT EXISTS (
SELECT 1 FROM storage.blobs bb
WHERE bb.hash = ch.hash
)
)
END AS chunks_missing
FROM storage.files f
LEFT JOIN storage.folders parent ON parent.id = f.folder_id
LEFT JOIN storage.blobs b ON b.hash = f.blob_hash
LEFT JOIN storage.chunk_manifests m ON m.file_hash = f.blob_hash
WHERE ($1::uuid IS NULL OR f.id > $1)
-- Grace: skip files < 1h old. Delta-upload inserts
-- chunks with ref_count=0 BEFORE the commit that
-- inserts the file row + manifest, so the normal
-- path is race-free — but replace/overwrite flows
-- have narrow windows where a mid-transaction scan
-- could see `missing_blob` or `chunk_missing`.
-- Same grace shape as `blobs_consistency`.
AND f.created_at < NOW() - INTERVAL '1 hour'
ORDER BY f.id
LIMIT $2
"#,
)
.bind(cursor)
.bind(BATCH_SIZE)
.fetch_all(self.pool.as_ref())
.await
{
Ok(r) => r,
Err(e) => {
return RunOutcome::Failed {
message: format!("batch fetch: {e}"),
};
}
};
if rows.is_empty() {
tracing::info!(
target: "oxicloud::consistency",
event = "files_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
"files_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
for row in &rows {
// Human-readable path captured once per row and folded
// into every finding on this row. `name` is the raw
// basename (useful even when the parent is orphaned
// and `parent_path` is None).
let path = display_path(row.parent_path.as_deref(), &row.name);
// (1) parent_folder_trashed: live file under a
// soft-deleted folder. Root files (`folder_id IS
// NULL`) are exempt — `parent_is_trashed` is None
// there.
if !row.is_trashed && row.parent_is_trashed == Some(true) {
finding_count += 1;
record_or_log(
store,
FILES_CONSISTENCY_JOB_NAME,
"parent_folder_trashed",
"inconsistent",
Some(row.id),
serde_json::json!({
"name": row.name,
"path": path,
"folder_id": row.folder_id,
}),
)
.await;
}
// Content-bearing size for this file, in priority
// order: CDC manifest (dominant path — every file
// uploaded after Apr 2026), then legacy pre-CDC
// whole-file blob. `None` = no registry entry on
// either path → real `missing_blob`.
let content_size = row.manifest_size.or(row.blob_size);
// (2) missing_blob: NEITHER the CDC manifest nor the
// legacy blob row exists for this hash. Real data-loss
// indicator — the read path checks manifest first and
// falls back to blob; if both are missing, reading
// the file will fail. NOT a false positive for CDC
// files, because the manifest check catches them.
if content_size.is_none() {
finding_count += 1;
record_or_log(
store,
FILES_CONSISTENCY_JOB_NAME,
"missing_blob",
"data_loss",
Some(row.id),
serde_json::json!({
"name": row.name,
"path": path,
"blob_hash": row.blob_hash,
}),
)
.await;
// No point checking size when neither registry
// entry exists — skip (3) for this row.
continue;
}
// (2b) chunk_missing: the file's CDC manifest exists
// and points at N chunks, but K of them have no row
// in `storage.blobs`. Real data-loss condition — the
// read path will fail reassembly when it tries to
// fetch a reaped chunk. Typically caused by a dedup
// GC race (chunk reaped while a manifest still held
// a reference) or partial pg_dump/restore that
// dropped `storage.blobs` rows.
if let Some(missing) = row.chunks_missing
&& missing > 0
{
finding_count += 1;
record_or_log(
store,
FILES_CONSISTENCY_JOB_NAME,
"chunk_missing",
"data_loss",
Some(row.id),
serde_json::json!({
"name": row.name,
"path": path,
"blob_hash": row.blob_hash,
"chunks_missing": missing,
"chunks_total": row.manifest_chunk_count,
}),
)
.await;
// Deliberately DON'T `continue` — a
// chunk_missing finding does not preclude a
// size mismatch, and the two are independent
// signals worth surfacing separately.
}
// (3) blob_size_mismatch: denormalised size drifted
// from the content-registry's authoritative size.
// Prefers manifest.total_size when present (post-CDC
// ingest path); falls back to blob.size (legacy).
if let Some(bs) = content_size
&& bs != row.size
{
finding_count += 1;
record_or_log(
store,
FILES_CONSISTENCY_JOB_NAME,
"blob_size_mismatch",
"inconsistent",
Some(row.id),
serde_json::json!({
"name": row.name,
"path": path,
"blob_hash": row.blob_hash,
"stored": row.size,
"actual": bs,
"delta": row.size - bs,
"source": if row.manifest_size.is_some() { "manifest" } else { "blob" },
}),
)
.await;
}
// (4) legacy_uncdc_file: informational — this file
// is served via the pre-CDC whole-file blob fallback,
// not the modern chunk-manifest path. Not broken;
// just misses out on sub-file dedup benefits + never
// shares chunks with newer uploads. Severity
// `anomaly` (surprising state, no known impact) so
// the UI renders it as a notice, not a warning.
// Fires when the blob registry has a whole-file row
// for this hash but there is no manifest. Recovery
// path: `ReingestLegacy` (deferred, see
// `docs/plan/recovery.md`).
if row.blob_size.is_some() && row.manifest_size.is_none() {
finding_count += 1;
record_or_log(
store,
FILES_CONSISTENCY_JOB_NAME,
"legacy_uncdc_file",
"anomaly",
Some(row.id),
serde_json::json!({
"name": row.name,
"path": path,
"blob_hash": row.blob_hash,
"size": row.size,
"note": "pre-CDC whole-file blob; re-ingest for sub-file dedup",
}),
)
.await;
}
}
// Advance cursor + checkpoint. `batch_len` feeds
// `stats.scanned_count`.
let last_id = rows.last().map(|r| r.id).expect("non-empty rows");
cursor = Some(last_id);
let batch_len = rows.len() as u64;
if let Err(e) = store
.checkpoint(last_id.as_bytes().to_vec(), batch_len)
.await
{
return RunOutcome::Failed {
message: format!("checkpoint: {e}"),
};
}
if (rows.len() as i64) < BATCH_SIZE {
tracing::info!(
target: "oxicloud::consistency",
event = "files_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
"files_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
}
}
}
@@ -0,0 +1,359 @@
//! Second tenant of Part 2 (recoverable-run engine).
//!
//! Iterates `storage.folders` and reports each row whose maintained-
//! by-trigger materialised state has drifted from what walking its
//! `parent_id` chain would produce. **Read-only** — reports drift as
//! findings but does NOT rewrite anything; a curative sweep (or a
//! targeted repair endpoint) is a separate concern.
//!
//! Why this matters: the ltree cascade trigger `trg_folders_cascade_path`
//! is what keeps `path` and `lpath` in sync with `parent_id`. Any
//! bulk write path that bypasses the trigger (raw COPY, migration
//! backfill with FOR EACH STATEMENT triggers disabled, hand-rolled
//! UPDATE with `SET LOCAL session_replication_role = 'replica'`) can
//! leave the materialised columns wrong. Silent divergence breaks
//! every `WHERE lpath <@ ancestor` query — subtree list, breadcrumb
//! walk, recursive copy/move/delete. Detecting the drift is what
//! surfaces the underlying misuse.
//!
//! ### v1 checks (three per-row branches)
//!
//! * `parent_trashed_mismatch` — a non-trashed folder whose parent
//! IS trashed. FK enforcement + trash cascade should make this
//! impossible; when it does happen the cascade missed a row.
//! * `path_mismatch` — materialised `folders.path` differs from
//! the parent-chain reconstruction.
//! * `lpath_mismatch` — materialised `folders.lpath` differs from
//! the parent-chain reconstruction.
//!
//! Reconstruction convention (mirrors `storage.compute_folder_path`
//! from `20260307000000_initial_schema.sql`):
//!
//! ```text
//! my_label = replace(id::text, '-', '_')
//! root: path = name lpath = my_label
//! non-root: path = parent.path || '/' || name
//! lpath = parent.lpath || my_label
//! ```
//!
//! Findings are LOGGED to `target: "oxicloud::consistency"` for now,
//! same as `drives_consistency`. Persistence to `jobs.run_findings`
//! lands with the findings-table migration (deferred); at that point
//! this handler swaps its `tracing::warn!` calls for
//! `store.record_finding(...)` — nothing else changes.
//!
//! ### Room to grow (already-cheap branches deferred)
//!
//! * `drive_id_parent_mismatch` — parent + child in different drives.
//! The self-join already loads `parent.drive_id`; one more per-row
//! `if` when the drive-membership rules stabilise post-D7.
//! * `orphan_root` — a non-trashed `parent_id IS NULL` folder no
//! `drives.root_folder_id` points at. The `check_no_orphan_root_folder`
//! trigger from `20260803000000_*` blocks new orphans; a check here
//! would surface pre-trigger legacy rows.
//! * Name-vs-path terminal drift (`path` ending in the folder's `name`).
//! Redundant with `path_mismatch` unless we ever start storing them
//! independently.
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 FOLDERS_CONSISTENCY_JOB_NAME: &str = "folders_consistency";
/// Rows per batch. Folders can be numerous (millions on big
/// installs), each row is light. 500 keeps the cancel-poll cadence
/// sub-second on a warm cache while amortising round-trip overhead.
const BATCH_SIZE: i64 = 500;
pub struct FoldersConsistencyCheck {
pool: Arc<PgPool>,
}
impl FoldersConsistencyCheck {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Chainable self-registration — mirrors `DrivesConsistencyCheck`.
/// On-demand only (no periodic tick); operators fire it from
/// `POST /api/admin/jobs/folders_consistency/trigger`.
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
}
}
#[derive(Debug, sqlx::FromRow)]
struct FolderRow {
id: Uuid,
/// Folder basename — surfaced in finding `detail` so operators
/// see a human identifier next to the UUID.
name: String,
parent_id: Option<Uuid>,
is_trashed: bool,
path: String,
lpath_text: String,
parent_is_trashed: Option<bool>,
parent_path: Option<String>,
parent_lpath_text: Option<String>,
expected_path: String,
expected_lpath_text: String,
}
#[async_trait]
impl RecoverableJobHandler for FoldersConsistencyCheck {
fn name(&self) -> &str {
FOLDERS_CONSISTENCY_JOB_NAME
}
/// 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
/// progress bar is only rendered when the operator is watching.
async fn count_total(&self) -> Option<u64> {
let row: Result<(i64,), sqlx::Error> =
sqlx::query_as("SELECT COUNT(*) FROM storage.folders")
.fetch_one(self.pool.as_ref())
.await;
match row {
Ok((n,)) => Some(n.max(0) as u64),
Err(e) => {
tracing::debug!(
target: "oxicloud::consistency",
event = "folders_consistency.count_total_failed",
error = %e,
"count_total failed — run will not surface a progress bar"
);
None
}
}
}
async fn run_resumable(
&self,
store: &dyn JobStore,
_args: &JobRunArgs,
resume_cursor: Option<Vec<u8>>,
) -> RunOutcome {
// Cursor: 16 raw UUID bytes, empty/absent = start from beginning.
// Same convention as `drives_consistency` so the resume path
// in `PgJobStoreProvider` treats every UUID-cursor tenant the
// same way.
let mut cursor: Option<Uuid> = match resume_cursor {
None => None,
Some(bytes) if bytes.is_empty() => None,
Some(bytes) if bytes.len() == 16 => {
let mut arr = [0u8; 16];
arr.copy_from_slice(&bytes);
Some(Uuid::from_bytes(arr))
}
Some(bytes) => {
return RunOutcome::Failed {
message: format!("invalid cursor: expected 16 bytes, got {}", bytes.len()),
};
}
};
let mut finding_count = 0u64;
loop {
// Cancel poll BETWEEN batches — the cooperative cancel
// contract (see `RecoverableJobHandler` trait doc).
match store.status().await {
Ok(RunStatus::CancelRequested) => {
tracing::info!(
target: "oxicloud::consistency",
event = "folders_consistency.cancelled",
run_id = %store.run_id(),
finding_count = finding_count,
"folders_consistency cancelled cooperatively, pausing"
);
return RunOutcome::Paused {
cursor: cursor.map(|u| u.as_bytes().to_vec()).unwrap_or_default(),
};
}
Ok(_) => {}
Err(e) => {
return RunOutcome::Failed {
message: format!("status poll: {e}"),
};
}
}
// Fetch a batch of folders + reconstruct expected
// path/lpath in SQL via LEFT JOIN on parent. The
// reconstruction formula mirrors `compute_folder_path`
// 1:1; if that trigger's convention ever changes both
// must move together.
let rows: Vec<FolderRow> = match sqlx::query_as(
r#"
SELECT
f.id AS id,
f.name AS name,
f.parent_id AS parent_id,
f.is_trashed AS is_trashed,
f.path AS path,
f.lpath::text AS lpath_text,
parent.is_trashed AS parent_is_trashed,
parent.path AS parent_path,
parent.lpath::text AS parent_lpath_text,
CASE
WHEN f.parent_id IS NULL THEN f.name
ELSE parent.path || '/' || f.name
END AS expected_path,
CASE
WHEN f.parent_id IS NULL THEN replace(f.id::text, '-', '_')
ELSE parent.lpath::text || '.' || replace(f.id::text, '-', '_')
END AS expected_lpath_text
FROM storage.folders f
LEFT JOIN storage.folders parent ON parent.id = f.parent_id
WHERE ($1::uuid IS NULL OR f.id > $1)
-- Grace: skip folders < 1h old. The
-- `trg_folders_cascade_path` trigger runs on the
-- writer's transaction, so a folder created RIGHT
-- NOW could momentarily show a `path_mismatch`
-- window before the cascade lands. Same grace
-- shape as `blobs_consistency`.
AND f.created_at < NOW() - INTERVAL '1 hour'
ORDER BY f.id
LIMIT $2
"#,
)
.bind(cursor)
.bind(BATCH_SIZE)
.fetch_all(self.pool.as_ref())
.await
{
Ok(r) => r,
Err(e) => {
return RunOutcome::Failed {
message: format!("batch fetch: {e}"),
};
}
};
if rows.is_empty() {
tracing::info!(
target: "oxicloud::consistency",
event = "folders_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
"folders_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
// Per-row branches. Add new ones here — same pattern as
// `drives_consistency`. Emit at most one finding per
// (row, kind); multiple different kinds for the same row
// are fine and independent.
for row in &rows {
// (1) parent_trashed_mismatch: a live folder under a
// soft-deleted parent. Cascade missed.
if !row.is_trashed && row.parent_is_trashed == Some(true) {
finding_count += 1;
record_or_log(
store,
FOLDERS_CONSISTENCY_JOB_NAME,
"parent_trashed_mismatch",
"inconsistent",
Some(row.id),
serde_json::json!({
"name": row.name,
"path": row.path,
"parent_id": row.parent_id,
}),
)
.await;
}
// (2) path_mismatch: materialised path drifted from
// the parent-chain reconstruction.
if row.path != row.expected_path {
finding_count += 1;
record_or_log(
store,
FOLDERS_CONSISTENCY_JOB_NAME,
"path_mismatch",
"inconsistent",
Some(row.id),
serde_json::json!({
"name": row.name,
"stored": row.path,
"expected": row.expected_path,
"parent_path": row.parent_path,
}),
)
.await;
}
// (3) lpath_mismatch: materialised lpath drifted from
// the parent-chain reconstruction. Independent of (2)
// — either can be wrong without the other, and both
// silently break different query shapes.
if row.lpath_text != row.expected_lpath_text {
finding_count += 1;
record_or_log(
store,
FOLDERS_CONSISTENCY_JOB_NAME,
"lpath_mismatch",
"inconsistent",
Some(row.id),
serde_json::json!({
"name": row.name,
"path": row.path,
"stored": row.lpath_text,
"expected": row.expected_lpath_text,
"parent_lpath": row.parent_lpath_text,
}),
)
.await;
}
}
// Advance cursor + checkpoint. Batch length is what we
// report to `stats.scanned_count`; findings are separate
// (they arrive via the tracing subscriber / eventually
// `run_findings`).
let last_id = rows.last().map(|r| r.id).expect("non-empty rows");
cursor = Some(last_id);
let batch_len = rows.len() as u64;
if let Err(e) = store
.checkpoint(last_id.as_bytes().to_vec(), batch_len)
.await
{
return RunOutcome::Failed {
message: format!("checkpoint: {e}"),
};
}
// Short batch = drained the folders table.
if (rows.len() as i64) < BATCH_SIZE {
tracing::info!(
target: "oxicloud::consistency",
event = "folders_consistency.completed",
run_id = %store.run_id(),
finding_count = finding_count,
"folders_consistency completed with {} finding(s)",
finding_count
);
return RunOutcome::Completed;
}
}
}
}
@@ -23,7 +23,7 @@ use tracing::{error, info};
use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRunArgs}; use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use async_trait::async_trait; use async_trait::async_trait;
@@ -57,12 +57,22 @@ impl GrantCleanupService {
self.grace_days self.grace_days
} }
/// Cadence exposed as `Duration` so DI passes a sanitised value /// Cadence exposed as `Duration`. Internal helper used by
/// (post-`.max(1)`) to `JobRegistry::register`. /// [`Self::register`]; kept `pub` for tests.
pub fn interval(&self) -> Duration { pub fn interval(&self) -> Duration {
Duration::from_secs(self.interval_hours * 3600) Duration::from_secs(self.interval_hours * 3600)
} }
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style chaining. Scheduled tenant with
/// interval = `self.interval()`, no timeout. See
/// `docs/plan/job-registry.md` Part 1.
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
let interval = self.interval();
registry.register(self.clone(), Some(interval), None).await;
self
}
/// Run one purge pass. /// Run one purge pass.
/// ///
/// `grace_override`: /// `grace_override`:
@@ -10,6 +10,7 @@ use tokio::io::{AsyncSeekExt, AsyncWriteExt};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use bytes::Bytes; use bytes::Bytes;
use chrono::{DateTime, Utc};
use crate::application::ports::blob_storage_ports::{ use crate::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus, BlobStorageBackend, BlobStream, StorageHealthStatus,
@@ -607,6 +608,165 @@ impl BlobStorageBackend for LocalBlobBackend {
fn read_prefetch(&self) -> usize { fn read_prefetch(&self) -> usize {
self.read_prefetch self.read_prefetch
} }
/// Enumerate `.blob` files under `.blobs/<xx>/`. Cursor format:
///
/// * `None` — start from the first shard (`00`) at file offset 0
/// * `Some("<shard>/<hash>")` — resume: skip shards `< shard`
/// entirely, and within `shard` skip files whose hash `≤ hash`.
///
/// Ordering: shards ascending (00–ff), files within a shard
/// ascending by hash. Stable across calls given the sorting.
///
/// Filter: basename must be exactly 64 hex chars + `.blob`. This
/// excludes `.tmp` staging files, `.orig`/`.lost`/`.corrupt`
/// sidecars from manual admin work, and any other non-canonical
/// artefacts. Backend consistency scans the DB-registered
/// content-addressable set only.
fn list_blob_hashes(
&self,
cursor: Option<String>,
limit: usize,
) -> Pin<
Box<
dyn std::future::Future<
Output = Result<
crate::application::ports::blob_storage_ports::BlobListPage,
DomainError,
>,
> + Send
+ '_,
>,
> {
use crate::application::ports::blob_storage_ports::{
BackendBlobEntry, BackendUnknownEntry, BlobListPage,
};
let blob_root = self.blob_root.clone();
Box::pin(async move {
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),
},
};
let mut blobs: Vec<BackendBlobEntry> = Vec::with_capacity(limit);
let mut unknowns: Vec<BackendUnknownEntry> = Vec::new();
let mut next_cursor: Option<String> = None;
for prefix in &HEX_PREFIXES {
let prefix = *prefix;
if prefix < start_shard.as_str() {
continue;
}
let shard_dir = blob_root.join(prefix);
let mut entries = match fs::read_dir(&shard_dir).await {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(DomainError::new(
ErrorKind::InternalError,
"Blob",
format!("read shard {prefix}: {e}"),
));
}
};
// Collect canonical blobs + unknowns for this shard.
// The distinction is filename shape: `<64-hex>.blob`
// → canonical blob; anything else → unknown sidecar.
// Unknowns are captured with their full basename so
// the tenant can surface them to operators as
// informational notices (severity `anomaly`).
let mut shard_blobs: Vec<(String, Option<DateTime<Utc>>)> = Vec::new();
let mut shard_unknowns: Vec<(String, Option<DateTime<Utc>>)> = Vec::new();
while let Some(dirent) = entries.next_entry().await.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"Blob",
format!("read shard {prefix} entry: {e}"),
)
})? {
let name = dirent.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue, // non-UTF8 filename — skip entirely
};
// Skip directories — the shard dir itself
// shouldn't contain any, but defensively.
if dirent
.file_type()
.await
.map(|t| t.is_dir())
.unwrap_or(false)
{
continue;
}
let mtime = dirent
.metadata()
.await
.ok()
.and_then(|m| m.modified().ok())
.map(DateTime::<Utc>::from);
// Canonical shape check: `<64-hex>.blob`.
let canonical = name_str
.strip_suffix(".blob")
.filter(|stem| {
stem.len() == 64 && stem.chars().all(|c| c.is_ascii_hexdigit())
})
.map(|s| s.to_string());
match canonical {
Some(hash) => shard_blobs.push((hash, mtime)),
None => shard_unknowns.push((name_str.to_string(), mtime)),
}
}
shard_blobs.sort_by(|a, b| a.0.cmp(&b.0));
// Unknowns don't need cursor-precise ordering — they
// ride alongside the blobs batch. Sort just for
// stable operator-facing output.
shard_unknowns.sort_by(|a, b| a.0.cmp(&b.0));
for (name, mtime) in shard_unknowns {
unknowns.push(BackendUnknownEntry {
path: format!("{prefix}/{name}"),
mtime,
});
}
for (hash, mtime) in shard_blobs {
if prefix == start_shard.as_str()
&& let Some(ref after) = start_after_hash
&& hash.as_str() <= after.as_str()
{
continue;
}
if blobs.len() >= limit {
next_cursor = Some(format!(
"{}/{}",
prefix,
blobs.last().map(|e| e.hash.as_str()).unwrap_or("")
));
return Ok(BlobListPage {
blobs,
unknowns,
next_cursor,
});
}
blobs.push(BackendBlobEntry { hash, mtime });
}
}
Ok(BlobListPage {
blobs,
unknowns,
next_cursor,
})
})
}
} }
#[cfg(test)] #[cfg(test)]
@@ -229,4 +229,45 @@ impl BlobStorageBackend for MigrationBlobBackend {
.local_blob_path(hash) .local_blob_path(hash)
.or_else(|| self.source.local_blob_path(hash)) .or_else(|| self.source.local_blob_path(hash))
} }
/// Enumeration during migration is intentionally REFUSED. Both
/// source and target legitimately hold bytes concurrently
/// mid-migration: a blob copied to target but not yet deleted
/// from source would be reported "twice"; a blob in-flight from
/// source to target could be flagged as orphan on whichever
/// side the consistency scan doesn't walk. There's no single
/// authoritative "what's on the backend" answer while a
/// migration is running.
///
/// Operators wanting to run `backend_consistency` during a
/// migration should either wait for the migration to complete
/// (target becomes authoritative) or cancel it. The
/// `operation_not_supported` error is surfaced by the tenant as
/// a single run-level `backend_unenumerable` finding — no
/// per-blob probes attempted.
fn list_blob_hashes(
&self,
_cursor: Option<String>,
_limit: usize,
) -> Pin<
Box<
dyn std::future::Future<
Output = Result<
crate::application::ports::blob_storage_ports::BlobListPage,
DomainError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Err(DomainError::operation_not_supported(
"list_blob_hashes",
"backend_consistency cannot enumerate while a storage \
migration is in progress — source and target hold bytes \
concurrently; wait for migration completion or cancel it \
before running the scan",
))
})
}
} }
+6
View File
@@ -1,10 +1,14 @@
pub mod audio_metadata_service; pub mod audio_metadata_service;
pub mod azure_blob_backend; pub mod azure_blob_backend;
pub mod backend_consistency_service;
pub mod blobs_consistency_service;
pub mod cached_blob_backend; pub mod cached_blob_backend;
pub mod chunked_upload_service; pub mod chunked_upload_service;
pub mod compression_service; pub mod compression_service;
pub mod consistency_batch_service;
pub mod db_pool_monitor; pub mod db_pool_monitor;
pub mod dedup_service; pub mod dedup_service;
pub mod drives_consistency_service;
pub mod encrypted_blob_backend; pub mod encrypted_blob_backend;
pub mod exif_service; pub mod exif_service;
pub mod face_geometry; pub mod face_geometry;
@@ -12,6 +16,8 @@ pub mod face_indexing_service;
pub mod ffmpeg_video_frame_service; pub mod ffmpeg_video_frame_service;
pub mod file_content_cache; pub mod file_content_cache;
pub mod file_system_i18n_service; pub mod file_system_i18n_service;
pub mod files_consistency_service;
pub mod folders_consistency_service;
pub mod grant_cleanup_service; pub mod grant_cleanup_service;
pub mod image_transcode_service; pub mod image_transcode_service;
pub mod jwt_service; pub mod jwt_service;
@@ -355,4 +355,26 @@ impl BlobStorageBackend for RetryBlobBackend {
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> { fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
self.inner.local_blob_path(hash) self.inner.local_blob_path(hash)
} }
/// Enumeration delegates to inner. Retry semantics apply per
/// call, not per batch — a single list call that fails after
/// exhausting retries surfaces the error to the tenant, which
/// treats it as a transient backend issue and skips the batch.
fn list_blob_hashes(
&self,
cursor: Option<String>,
limit: usize,
) -> Pin<
Box<
dyn std::future::Future<
Output = Result<
crate::application::ports::blob_storage_ports::BlobListPage,
DomainError,
>,
> + Send
+ '_,
>,
> {
self.inner.list_blob_hashes(cursor, limit)
}
} }
@@ -421,4 +421,97 @@ impl BlobStorageBackend for S3BlobBackend {
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> { fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
None // Remote backend — no local path 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.
fn list_blob_hashes(
&self,
cursor: Option<String>,
limit: usize,
) -> Pin<
Box<
dyn std::future::Future<
Output = Result<
crate::application::ports::blob_storage_ports::BlobListPage,
DomainError,
>,
> + Send
+ '_,
>,
> {
use crate::application::ports::blob_storage_ports::{
BackendBlobEntry, BackendUnknownEntry, BlobListPage,
};
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);
}
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 unknowns: Vec<BackendUnknownEntry> = Vec::new();
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)
});
// 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;
}
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 }),
}
}
let next_cursor = if resp.is_truncated.unwrap_or(false) {
resp.next_continuation_token
} else {
None
};
Ok(BlobListPage {
blobs,
unknowns,
next_cursor,
})
})
}
} }
@@ -6,7 +6,7 @@ use tracing::{debug, error, info, instrument};
use crate::common::errors::Result; use crate::common::errors::Result;
use crate::domain::repositories::trash_repository::TrashRepository; use crate::domain::repositories::trash_repository::TrashRepository;
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository; use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRunArgs}; use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::dedup_service::DedupService;
use async_trait::async_trait; use async_trait::async_trait;
@@ -43,12 +43,31 @@ impl TrashCleanupService {
} }
} }
/// Registered interval as a `Duration` — helper for DI wiring so /// Registered interval as a `Duration`. Internal helper used by
/// the composition root doesn't reinvent the `hours × 3600` cast. /// [`Self::register`]; kept `pub` in case a test wants to assert
/// the clamped value.
pub fn interval(&self) -> Duration { pub fn interval(&self) -> Duration {
Duration::from_secs(self.cleanup_interval_hours * 3600) Duration::from_secs(self.cleanup_interval_hours * 3600)
} }
/// Register self with the periodic-job scheduler and return the
/// same `Arc<Self>` for DI-style method chaining:
///
/// ```ignore
/// let svc = Arc::new(TrashCleanupService::new(...))
/// .register(&core.job_registry)
/// .await;
/// ```
///
/// Scheduled tenant — interval reads from
/// `self.cleanup_interval_hours`, no timeout. See
/// `docs/plan/job-registry.md` Part 1 §Contract.
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
let interval = self.interval();
registry.register(self.clone(), Some(interval), None).await;
self
}
/// Starts the periodic cleanup job /// Starts the periodic cleanup job
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn start_cleanup_job(&self) { pub async fn start_cleanup_job(&self) {
+322 -2
View File
@@ -147,8 +147,26 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
// audit-logged. See `docs/plan/job-registry.md` §Cross-cutting. // audit-logged. See `docs/plan/job-registry.md` §Cross-cutting.
// Retired the `/internal/trigger-sweep|gc|grant-cleanup` shims // Retired the `/internal/trigger-sweep|gc|grant-cleanup` shims
// that used to sit here (Stage 2 of the job-registry rollout). // that used to sit here (Stage 2 of the job-registry rollout).
//
// `/jobs` + `/jobs/{name}/trigger` cover every registered
// JobHandler (periodic + recoverable — recoverable ones slot
// in through `RecoverableAdapter`). The `/cancel` + `/runs`
// + `/runs/{id}` triplet is recoverable-only — hitting them
// on a stateless job silently gets an empty list / no-op
// cancel, since no rows in `jobs.recoverable_runs` match.
// See `docs/plan/job-registry.md` Part 2.
.route("/jobs", get(list_jobs)) .route("/jobs", get(list_jobs))
.route("/jobs/{name}/trigger", post(trigger_job)) .route("/jobs/{name}/trigger", post(trigger_job))
.route("/jobs/{name}/cancel", post(cancel_job))
.route("/jobs/{name}/runs", get(list_job_runs))
.route("/jobs/{name}/runs/{id}", get(get_job_run))
.route(
"/jobs/{name}/runs/{id}/findings",
get(list_job_run_findings),
)
// Retention cleanup — operator-triggered, not periodic.
// See `purge_job_runs` docstring for the semantics.
.route("/jobs/runs/purge", post(purge_job_runs))
// Drives — admin-wide view (distinct from `/api/drives` which // Drives — admin-wide view (distinct from `/api/drives` which
// is filtered to the caller's role grants). // is filtered to the caller's role grants).
.route("/drives", get(list_all_drives)) .route("/drives", get(list_all_drives))
@@ -2084,10 +2102,16 @@ pub async fn list_jobs(State(state): State<Arc<AppState>>) -> impl IntoResponse
/// support it (dedup_gc → grace = 0, grant_cleanup → grace = 0). /// support it (dedup_gc → grace = 0, grant_cleanup → grace = 0).
/// Silently ignored by handlers that don't (trash_cleanup, /// Silently ignored by handlers that don't (trash_cleanup,
/// storage_reconcile). /// storage_reconcile).
///
/// `deep=true` opts into slow variants — `consistency_batch` fans it
/// out to sub-jobs; `storage_consistency` (when implemented) will
/// re-BLAKE3 each blob for bitrot detection. See `JobRunArgs.deep`.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct TriggerJobQuery { pub struct TriggerJobQuery {
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
#[serde(default)]
pub deep: bool,
} }
/// `POST /api/admin/jobs/{name}/trigger` — dispatch one run off-schedule. /// `POST /api/admin/jobs/{name}/trigger` — dispatch one run off-schedule.
@@ -2125,11 +2149,16 @@ pub async fn trigger_job(
event = "job.trigger", event = "job.trigger",
job = %name, job = %name,
force = query.force, force = query.force,
"👮🏻‍♂️ Admin triggered job {} (force={})", deep = query.deep,
"👮🏻‍♂️ Admin triggered job {} (force={}, deep={})",
name, name,
query.force, query.force,
query.deep,
); );
let args = JobRunArgs { force: query.force }; let args = JobRunArgs {
force: query.force,
deep: query.deep,
};
match state.core.job_registry.trigger(&name, &args).await { match state.core.job_registry.trigger(&name, &args).await {
Some(outcome) => ( Some(outcome) => (
StatusCode::OK, StatusCode::OK,
@@ -2146,3 +2175,294 @@ pub async fn trigger_job(
.into_response(), .into_response(),
} }
} }
/// `POST /api/admin/jobs/{name}/cancel` — cooperative cancel of the
/// currently-running recoverable run for `{name}`.
///
/// Flips the row's `status` from `Running` → `CancelRequested`. The
/// handler is responsible for polling `store.status()` between batches
/// and returning `RunOutcome::Paused` at the next safe boundary; if it
/// doesn't, the cancel is a no-op until the run completes naturally.
///
/// Returns 200 with the run_id when a Running row was flipped, 200 with
/// `cancelled: false` when nothing was running (either no runs exist,
/// or the latest is Paused / Completed / Failed / already CancelRequested).
/// Never 404 on "no active run" — the job name is registered and the
/// endpoint just reports the truth.
#[utoipa::path(
post,
path = "/api/admin/jobs/{name}/cancel",
params(("name" = String, Path, description = "Registered job name")),
responses(
(status = 200, description = "Cancel signalled (or no-op if nothing was running)"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 500, description = "DB error"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn cancel_job(
State(state): State<Arc<AppState>>,
axum::extract::Path(name): axum::extract::Path<String>,
) -> impl IntoResponse {
use crate::infrastructure::scheduler::JobStoreProvider as _;
tracing::info!(
target: "audit",
event = "job.cancel_requested",
job = %name,
"👮🏻‍♂️ Admin requested cancel for job {}",
name,
);
match state.core.job_store_provider.request_cancel(&name).await {
Ok(Some(run_id)) => (
StatusCode::OK,
Json(serde_json::json!({
"cancelled": true,
"run_id": run_id.to_string(),
"status": "CancelRequested",
})),
)
.into_response(),
Ok(None) => (
StatusCode::OK,
Json(serde_json::json!({
"cancelled": false,
"reason": "no running run for this job",
})),
)
.into_response(),
Err(e) => AppError::internal_error(format!("cancel failed: {e}")).into_response(),
}
}
/// Query parameters for `GET /api/admin/jobs/{name}/runs`.
#[derive(serde::Deserialize)]
pub struct ListRunsQuery {
/// Cap on returned rows. Server-side clamps to 100 defensively.
#[serde(default = "default_runs_limit")]
pub limit: u32,
}
fn default_runs_limit() -> u32 {
20
}
/// `GET /api/admin/jobs/{name}/runs?limit=N` — history of recoverable
/// runs for a registered job, newest first. Includes terminal +
/// non-terminal rows.
///
/// Read-only, no audit line — standard admin-middleware auth is enough.
#[utoipa::path(
get,
path = "/api/admin/jobs/{name}/runs",
params(
("name" = String, Path, description = "Registered job name"),
("limit" = Option<u32>, Query, description = "Max rows to return (default 20, capped at 100)"),
),
responses(
(status = 200, description = "Runs listed (may be empty)"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 500, description = "DB error"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn list_job_runs(
State(state): State<Arc<AppState>>,
axum::extract::Path(name): axum::extract::Path<String>,
axum::extract::Query(query): axum::extract::Query<ListRunsQuery>,
) -> impl IntoResponse {
use crate::infrastructure::scheduler::JobStoreProvider as _;
let limit = query.limit.clamp(1, 100);
match state.core.job_store_provider.list_runs(&name, limit).await {
Ok(runs) => (StatusCode::OK, Json(runs)).into_response(),
Err(e) => AppError::internal_error(format!("list_runs failed: {e}")).into_response(),
}
}
/// `GET /api/admin/jobs/{name}/runs/{id}` — single-run detail.
///
/// Returns 404 when the id doesn't exist. `{name}` is not validated
/// against the run's `job_name` — the id is globally unique — but
/// keeping the name in the URL path lets operators build stable
/// per-job history links without knowing individual run ids upfront.
#[utoipa::path(
get,
path = "/api/admin/jobs/{name}/runs/{id}",
params(
("name" = String, Path, description = "Registered job name"),
("id" = String, Path, description = "Run UUID"),
),
responses(
(status = 200, description = "Run detail"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Run not found"),
(status = 500, description = "DB error"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn get_job_run(
State(state): State<Arc<AppState>>,
axum::extract::Path((_name, id)): axum::extract::Path<(String, uuid::Uuid)>,
) -> impl IntoResponse {
use crate::infrastructure::scheduler::JobStoreProvider as _;
match state.core.job_store_provider.get_run_by_id(id).await {
Ok(Some(run)) => (StatusCode::OK, Json(run)).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "run not found", "id": id.to_string() })),
)
.into_response(),
Err(e) => AppError::internal_error(format!("get_run failed: {e}")).into_response(),
}
}
/// Query parameters for `GET /api/admin/jobs/{name}/runs/{id}/findings`.
#[derive(serde::Deserialize)]
pub struct ListFindingsQuery {
/// Page size — server clamps to 500 defensively.
#[serde(default = "default_findings_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_findings_limit() -> u32 {
100
}
/// `GET /api/admin/jobs/{name}/runs/{id}/findings?limit=N&offset=M` —
/// paginated findings emitted by a specific run of a recoverable job.
///
/// 404 when the run id doesn't exist (anti-enum: caller knew the id
/// somehow; we don't leak whether it was pruned vs never-existed).
/// Read-only, no audit — standard admin-middleware auth is enough.
///
/// `{name}` is not validated against the run's `job_name` (the id is
/// globally unique) but keeps the URL path consistent with the other
/// per-run endpoints for stable per-job history links.
#[utoipa::path(
get,
path = "/api/admin/jobs/{name}/runs/{id}/findings",
params(
("name" = String, Path, description = "Registered job name"),
("id" = String, Path, description = "Run UUID"),
("limit" = Option<u32>, Query, description = "Max rows (default 100, capped at 500)"),
("offset" = Option<u32>, Query, description = "Rows to skip (default 0)"),
),
responses(
(status = 200, description = "Findings listed (may be empty)"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Run not found"),
(status = 500, description = "DB error"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn list_job_run_findings(
State(state): State<Arc<AppState>>,
axum::extract::Path((_name, id)): axum::extract::Path<(String, uuid::Uuid)>,
axum::extract::Query(query): axum::extract::Query<ListFindingsQuery>,
) -> impl IntoResponse {
use crate::infrastructure::scheduler::JobStoreProvider as _;
// Existence check first — otherwise a paged listing of a
// nonexistent run returns 200 [] which is indistinguishable from
// "run exists, no findings" and breaks operator drill-down.
match state.core.job_store_provider.get_run_by_id(id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "run not found", "id": id.to_string() })),
)
.into_response();
}
Err(e) => return AppError::internal_error(format!("get_run failed: {e}")).into_response(),
}
let limit = query.limit.clamp(1, 500);
match state
.core
.job_store_provider
.list_findings(id, limit, query.offset)
.await
{
Ok(findings) => (StatusCode::OK, Json(findings)).into_response(),
Err(e) => AppError::internal_error(format!("list_findings failed: {e}")).into_response(),
}
}
/// Query parameters for `POST /api/admin/jobs/runs/purge`.
///
/// `days` — retention window. Terminal runs (`Completed`, `Failed`)
/// with `completed_at` older than this many days ago are deleted
/// (with their findings via CASCADE). Default 30. Minimum enforced
/// at 1 by the provider — zero would eat runs completed seconds
/// ago. Non-terminal runs are ALWAYS preserved regardless of age.
#[derive(serde::Deserialize)]
pub struct PurgeJobRunsQuery {
#[serde(default = "default_purge_days")]
pub days: i32,
}
fn default_purge_days() -> i32 {
30
}
/// `POST /api/admin/jobs/runs/purge?days=N` — operator-triggered
/// cleanup of old terminal runs + their findings. Not periodic;
/// admins fire this when they want to reclaim `jobs.*` history
/// space. Delegates entirely to
/// `JobStoreProvider::purge_terminal_runs` — no SQL in the handler
/// (see `AGENTS.md` § handler thinness).
#[utoipa::path(
post,
path = "/api/admin/jobs/runs/purge",
params(
("days" = Option<i32>, Query, description = "Retention window in days (default 30, minimum 1). Terminal runs older than this are deleted with their findings; non-terminal runs are always preserved."),
),
responses(
(status = 200, description = "Purge complete"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 500, description = "DB error"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn purge_job_runs(
State(state): State<Arc<AppState>>,
axum::extract::Query(query): axum::extract::Query<PurgeJobRunsQuery>,
) -> impl IntoResponse {
use crate::infrastructure::scheduler::JobStoreProvider as _;
let retention_days = query.days.max(1);
match state
.core
.job_store_provider
.purge_terminal_runs(retention_days)
.await
{
Ok(purged) => {
tracing::info!(
target: "audit",
event = "jobs.runs_purged",
purged = purged,
retention_days = retention_days,
"👮🏻‍♂️ admin purged {purged} terminal recoverable-run row(s) past {retention_days} day retention (findings cascaded)",
);
(
StatusCode::OK,
Json(serde_json::json!({
"purged": purged,
"retention_days": retention_days,
})),
)
.into_response()
}
Err(e) => AppError::internal_error(format!("purge failed: {e}")).into_response(),
}
}
+3
View File
@@ -234,6 +234,9 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
// (docs/plan/job-registry.md §Cross-cutting). // (docs/plan/job-registry.md §Cross-cutting).
handlers::admin_handler::list_jobs, handlers::admin_handler::list_jobs,
handlers::admin_handler::trigger_job, handlers::admin_handler::trigger_job,
handlers::admin_handler::cancel_job,
handlers::admin_handler::list_job_runs,
handlers::admin_handler::get_job_run,
// Grant / ReBAC handlers (free functions) // Grant / ReBAC handlers (free functions)
handlers::grant_handler::create_grant, handlers::grant_handler::create_grant,
handlers::grant_handler::revoke_grant, handlers::grant_handler::revoke_grant,
+116 -1
View File
@@ -89,7 +89,19 @@ jsonpath "$..interval_ms" contains 600000
jsonpath "$..interval_ms" count == 3 jsonpath "$..interval_ms" count == 3
# Every entry carries a `running` bool — same aggregate primitive. # Every entry carries a `running` bool — same aggregate primitive.
jsonpath "$..running" count == 4 # Count matches the registered-tenant count: 4 Part 1 periodics
# (trash_cleanup, storage_reconcile, dedup_gc, grant_cleanup) + 5
# Part 2 recoverables (drives_consistency, folders_consistency,
# files_consistency, blobs_consistency, backend_consistency —
# wrapped by RecoverableAdapter so they appear here alongside the
# periodics) + 1 coordinator (consistency_batch — a plain
# JobHandler that dispatches every registered `*_consistency`).
# Bump when a new tenant registers.
jsonpath "$..running" count == 10
jsonpath "$[*].name" contains "drives_consistency"
jsonpath "$[*].name" contains "folders_consistency"
jsonpath "$[*].name" contains "files_consistency"
jsonpath "$[*].name" contains "consistency_batch"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -126,6 +138,109 @@ HTTP 200
jsonpath "$..last_outcome.outcome" contains "ok" jsonpath "$..last_outcome.outcome" contains "ok"
# ─────────────────────────────────────────────────────────────
# Step 4b — Trigger `folders_consistency`. Second Part 2
# recoverable tenant. Goes through the same
# RecoverableAdapter → PgJobStoreProvider path as
# drives_consistency (opens a run row, walks the
# `storage.folders` cursor, marks Completed). Success
# envelope shape identical.
#
# Captures the run_id so Step 4b-findings can pin the
# `GET /runs/{id}/findings` endpoint.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/jobs/folders_consistency/trigger
Authorization: Bearer {{admin_token}}
HTTP 200
[Captures]
folders_run_id: jsonpath "$.outcome.extra.run_id"
[Asserts]
jsonpath "$.ok" == true
jsonpath "$.outcome.outcome" == "ok"
jsonpath "$.outcome.count" exists
jsonpath "$.outcome.extra.completed" == true
jsonpath "$.outcome.extra.run_id" exists
# ─────────────────────────────────────────────────────────────
# Step 4b-findings — List findings for the run we just kicked.
# The response body is a JSON array (possibly empty on a clean
# test DB — the fresh Hurl DB has no folder-tree drift). Assert:
# * 200 on a real run_id.
# * response is an array (isCollection covers both empty + non-
# empty). Structural shape of individual finding rows is pinned
# by the drives_consistency_service integration test which seeds
# drift and asserts kind/severity/detail — not repeated here.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/jobs/folders_consistency/runs/{{folders_run_id}}/findings
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
# ─────────────────────────────────────────────────────────────
# Step 4b-findings-404 — Findings for a run_id that doesn't
# exist. Endpoint returns 404, not 200 [] — otherwise a broken
# link from the admin UI would look like "no findings" instead
# of "run missing".
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/jobs/folders_consistency/runs/00000000-0000-0000-0000-000000000000/findings
Authorization: Bearer {{admin_token}}
HTTP 404
[Asserts]
jsonpath "$.error" == "run not found"
# ─────────────────────────────────────────────────────────────
# Step 4b2 — Trigger `files_consistency`. Third Part 2 recoverable
# tenant. Iterates `storage.files` and self-joins folder
# + blob. Same envelope shape as the earlier consistency
# tenants.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/jobs/files_consistency/trigger
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ok" == true
jsonpath "$.outcome.outcome" == "ok"
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 (5 as
# of Slice 10: drives + folders + files + blobs +
# 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`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/jobs/consistency_batch/trigger?deep=true
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ok" == true
jsonpath "$.outcome.outcome" == "ok"
jsonpath "$.outcome.count" == 5
jsonpath "$.outcome.extra.deep" == true
jsonpath "$.outcome.extra.ok" == 5
jsonpath "$.outcome.extra.err" == 0
# per_check is keyed by child job name.
jsonpath "$.outcome.extra.per_check.drives_consistency.outcome" == "ok"
jsonpath "$.outcome.extra.per_check.folders_consistency.outcome" == "ok"
jsonpath "$.outcome.extra.per_check.files_consistency.outcome" == "ok"
jsonpath "$.outcome.extra.per_check.blobs_consistency.outcome" == "ok"
jsonpath "$.outcome.extra.per_check.backend_consistency.outcome" == "ok"
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Step 5 — Trigger a job that doesn't exist. 404 anti-enum on # Step 5 — Trigger a job that doesn't exist. 404 anti-enum on
# `JobRegistry::trigger` returning `None`. # `JobRegistry::trigger` returning `None`.
+193
View File
@@ -0,0 +1,193 @@
# =============================================================
# OxiCloud — Recoverable-run admin surface
# =============================================================
# Pins the Part 2 (recoverable-run engine) admin endpoints:
# * POST /api/admin/jobs/{name}/trigger (RecoverableJobHandler
# path via RecoverableAdapter)
# * POST /api/admin/jobs/{name}/cancel
# * GET /api/admin/jobs/{name}/runs
# * GET /api/admin/jobs/{name}/runs/{id}
#
# Uses `drives_consistency` — the first recoverable tenant, on-demand
# only. Verifies:
# 1. Registered job appears in `GET /api/admin/jobs` with no
# interval (on-demand only).
# 2. Triggering creates a fresh row in `jobs.recoverable_runs`,
# handler completes, run terminates as Completed.
# 3. History endpoint returns the just-completed run.
# 4. Single-run detail endpoint returns the same row.
# 5. Cancel-on-idle is a no-op with `cancelled: false` (nothing
# running to cancel).
# 6. Unknown run id → 404 on the single-run endpoint.
# 7. Non-admin caller → 403 from the admin middleware on every
# recoverable endpoint (no bespoke role check in the handlers).
#
# Drift-finding assertions land alongside the `jobs.run_findings`
# migration — the current build LOGS findings to
# `oxicloud::consistency` without persisting them. Log-tail
# assertions from Hurl are fragile so we defer them.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login + rjobs_bob (non-admin) provisioning
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# Anti-enum registration.
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "rjobs_bob",
"email": "rjobs_bob@example.com",
"password": "RjobsBobPassword1!"
}
HTTP 200
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "rjobs_bob", "password": "RjobsBobPassword1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 1 — `drives_consistency` is registered on-demand only.
# Appears in the listing without an `interval_ms`.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/jobs
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$[*].name" contains "drives_consistency"
# On-demand → no interval_ms (`skip_serializing_if = Option::is_none`).
jsonpath "$[?(@.name=='drives_consistency')].interval_ms" not exists
# ─────────────────────────────────────────────────────────────
# Step 2 — Trigger the check. Handler dispatches through
# `RecoverableAdapter` → `run_or_resume`, which INSERTs
# a fresh `jobs.recoverable_runs` row, runs the scan,
# marks it Completed. Response envelope:
# { ok, outcome: { outcome: "ok",
# extra: { completed: true, run_id: "..." } } }
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/jobs/drives_consistency/trigger
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.ok" == true
jsonpath "$.outcome.outcome" == "ok"
jsonpath "$.outcome.extra.completed" == true
[Captures]
run_id: jsonpath "$.outcome.extra.run_id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Run history returns at least the just-triggered
# run, newest first. Response is a JSON array of
# RunSummary; the top entry must be the run_id we
# captured above with status='Completed'.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/jobs/drives_consistency/runs
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
jsonpath "$[0].id" == "{{run_id}}"
jsonpath "$[0].job_name" == "drives_consistency"
jsonpath "$[0].status" == "Completed"
# ─────────────────────────────────────────────────────────────
# Step 4 — Single-run detail. Returns the same row shape as
# the listing but for one id.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/jobs/drives_consistency/runs/{{run_id}}
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{run_id}}"
jsonpath "$.job_name" == "drives_consistency"
jsonpath "$.status" == "Completed"
# `stats` is always present (JSONB NOT NULL DEFAULT '{}'); its
# per-key shape is job-specific. `scanned_count` is bumped by the
# handler's `checkpoint()` call, but the drives handler only
# checkpoints when it processes a batch — a run that finds zero
# rows in the first batch (e.g. all fixture drives sit inside the
# 1h grace window on `storage.drives.created_at`) completes
# without ever calling checkpoint, so `scanned_count` may be
# absent. Pin `stats` existence; leave the counter unpinned.
jsonpath "$.stats" exists
# ─────────────────────────────────────────────────────────────
# Step 5 — Cancel-on-idle is a no-op. No Running row means no
# Running→CancelRequested flip. Response is 200 with
# `cancelled: false` (NOT a 404 — the job name is
# registered, cancel just found nothing to cancel).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/jobs/drives_consistency/cancel
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.cancelled" == false
jsonpath "$.reason" == "no running run for this job"
# ─────────────────────────────────────────────────────────────
# Step 6 — Unknown run id → 404. UUID shape is valid; the id
# just isn't in `jobs.recoverable_runs`.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/jobs/drives_consistency/runs/00000000-0000-0000-0000-000000000000
Authorization: Bearer {{admin_token}}
HTTP 404
[Asserts]
jsonpath "$.error" == "run not found"
# ─────────────────────────────────────────────────────────────
# Step 7 — Non-admin caller is denied on every endpoint by
# the `/api/admin/*` middleware layer. Handlers have
# no bespoke role check — reaching them at all means
# the caller is admin.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/jobs/drives_consistency/trigger
Authorization: Bearer {{bob_token}}
HTTP 403
POST {{base_url}}/api/admin/jobs/drives_consistency/cancel
Authorization: Bearer {{bob_token}}
HTTP 403
GET {{base_url}}/api/admin/jobs/drives_consistency/runs
Authorization: Bearer {{bob_token}}
HTTP 403
GET {{base_url}}/api/admin/jobs/drives_consistency/runs/{{run_id}}
Authorization: Bearer {{bob_token}}
HTTP 403
+1
View File
@@ -167,6 +167,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/dedup_blob_cleanup.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \
"$API_DIR/dedup_admin_gate.hurl" \ "$API_DIR/dedup_admin_gate.hurl" \
"$API_DIR/admin_jobs.hurl" \ "$API_DIR/admin_jobs.hurl" \
"$API_DIR/recoverable_jobs.hurl" \
"$API_DIR/default_caldav_carddav.hurl" \ "$API_DIR/default_caldav_carddav.hurl" \
"$API_DIR/dav_error_mapping.hurl" \ "$API_DIR/dav_error_mapping.hurl" \
"$API_DIR/carddav_vcard_properties.hurl" \ "$API_DIR/carddav_vcard_properties.hurl" \
+31 -39
View File
@@ -15,44 +15,47 @@ test.beforeEach(async ({ page }) => {
test('walk every admin tab', async ({ page }) => { test('walk every admin tab', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin');
await expect(page.getByTestId('admin-dashboard-tab')).toBeVisible({ timeout: 15_000 }); // Dashboard is the default section on `/admin` (bare path).
// Dashboard is the default tab. // Assert on the sidebar entry (now the source of admin navigation)
// and on content that only renders when Dashboard is active.
await expect(page.getByTestId('appshell-nav-admin-dashboard-link')).toBeVisible({
timeout: 15_000
});
await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible(); await expect(page.getByTestId('admin-dashboard-registration-checkbox')).toBeVisible();
await page.getByTestId('admin-users-tab').click(); await page.goto('/admin/users');
await expect(page.getByTestId('admin-users-create-btn')).toBeVisible(); await expect(page.getByTestId('admin-users-create-btn')).toBeVisible();
await page.getByTestId('admin-oidc-tab').click(); await page.goto('/admin/oidc');
await expect(page.getByTestId('admin-oidc-form')).toBeVisible(); await expect(page.getByTestId('admin-oidc-form')).toBeVisible();
await page.getByTestId('admin-storage-tab').click(); await page.goto('/admin/storage');
await expect(page.getByTestId('admin-storage-form')).toBeVisible(); await expect(page.getByTestId('admin-storage-form')).toBeVisible();
await page.getByTestId('admin-smtp-tab').click(); await page.goto('/admin/smtp');
await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible(); await expect(page.getByTestId('admin-smtp-send-btn')).toBeVisible();
await page.getByTestId('admin-plugins-tab').click(); await page.goto('/admin/plugins');
// Plugins panel content is conditional; assert the tab became active. // Plugins panel content is conditional; assert the URL landed
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true'); // on the plugins section (proves routing worked; no content
// guarantee).
await expect(page).toHaveURL(/\/admin\/plugins$/);
}); });
test('open the create-user form', async ({ page }) => { test('open the create-user form', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/users');
await page.getByTestId('admin-users-tab').click();
await page.getByTestId('admin-users-create-btn').click(); await page.getByTestId('admin-users-create-btn').click();
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
}); });
test('storage tab: change backend select', async ({ page }) => { test('storage tab: change backend select', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/storage');
await page.getByTestId('admin-storage-tab').click();
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-storage-backend-select').selectOption({ index: 1 }).catch(() => {}); await page.getByTestId('admin-storage-backend-select').selectOption({ index: 1 }).catch(() => {});
}); });
test('storage tab: save the local backend settings', async ({ page }) => { test('storage tab: save the local backend settings', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/storage');
await page.getByTestId('admin-storage-tab').click();
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
// Keep the (safe) local backend and save — exercises the save handler without // Keep the (safe) local backend and save — exercises the save handler without
// reconfiguring storage to a remote backend. // reconfiguring storage to a remote backend.
@@ -62,8 +65,7 @@ test('storage tab: save the local backend settings', async ({ page }) => {
}); });
test('oidc tab: toggle enabled and fill issuer', async ({ page }) => { test('oidc tab: toggle enabled and fill issuer', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/oidc');
await page.getByTestId('admin-oidc-tab').click();
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-oidc-enabled-checkbox').check().catch(() => {}); await page.getByTestId('admin-oidc-enabled-checkbox').check().catch(() => {});
await page await page
@@ -84,8 +86,7 @@ async function createUserRow(
page: import('@playwright/test').Page, page: import('@playwright/test').Page,
uname: string, uname: string,
): Promise<ReturnType<import('@playwright/test').Page['locator']>> { ): Promise<ReturnType<import('@playwright/test').Page['locator']>> {
await page.goto('/admin'); await page.goto('/admin/users');
await page.getByTestId('admin-users-tab').click();
await page.getByTestId('admin-users-create-btn').click(); await page.getByTestId('admin-users-create-btn').click();
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-create-user-username-input').fill(uname); await page.getByTestId('admin-create-user-username-input').fill(uname);
@@ -165,8 +166,7 @@ test('save a user quota and deactivate the user', async ({ page }) => {
}); });
test('save oidc settings', async ({ page }) => { test('save oidc settings', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/oidc');
await page.getByTestId('admin-oidc-tab').click();
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-oidc-issuer-input').fill('https://example.test/issuer').catch(() => {}); await page.getByTestId('admin-oidc-issuer-input').fill('https://example.test/issuer').catch(() => {});
await page.getByTestId('admin-oidc-client-id-input').fill('client-123').catch(() => {}); await page.getByTestId('admin-oidc-client-id-input').fill('client-123').catch(() => {});
@@ -175,9 +175,8 @@ test('save oidc settings', async ({ page }) => {
}); });
test('install, toggle, and delete a plugin', async ({ page }) => { test('install, toggle, and delete a plugin', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/plugins');
await page.getByTestId('admin-plugins-tab').click(); await expect(page).toHaveURL(/\/admin\/plugins$/);
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
// Install the example hello plugin (plugins are enabled in the coverage env). // Install the example hello plugin (plugins are enabled in the coverage env).
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP); await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
@@ -200,8 +199,7 @@ test('install, toggle, and delete a plugin', async ({ page }) => {
}); });
test('view plugin logs and details', async ({ page }) => { test('view plugin logs and details', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/plugins');
await page.getByTestId('admin-plugins-tab').click();
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP); await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
await expect(page.locator('[data-testid^="admin-plugin-details-"]').first()).toBeVisible({ await expect(page.locator('[data-testid^="admin-plugin-details-"]').first()).toBeVisible({
timeout: 20_000, timeout: 20_000,
@@ -223,9 +221,8 @@ test('view plugin logs and details', async ({ page }) => {
}); });
test('plugins tab: save retention settings', async ({ page }) => { test('plugins tab: save retention settings', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/plugins');
await page.getByTestId('admin-plugins-tab').click(); await expect(page).toHaveURL(/\/admin\/plugins$/);
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
// The retention form is conditional; fill + save it when present. // The retention form is conditional; fill + save it when present.
const retention = page.getByTestId('admin-plugin-retention-form'); const retention = page.getByTestId('admin-plugin-retention-form');
if (await retention.isVisible().catch(() => false)) { if (await retention.isVisible().catch(() => false)) {
@@ -243,8 +240,7 @@ test('toggle the dashboard registration setting', async ({ page }) => {
}); });
test('send a test email from the smtp tab', async ({ page }) => { test('send a test email from the smtp tab', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/smtp');
await page.getByTestId('admin-smtp-tab').click();
await expect(page.getByTestId('admin-smtp-to-input')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-smtp-to-input')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-smtp-to-input').fill('test@example.test'); await page.getByTestId('admin-smtp-to-input').fill('test@example.test');
await page.getByTestId('admin-smtp-send-btn').click(); await page.getByTestId('admin-smtp-send-btn').click();
@@ -252,8 +248,7 @@ test('send a test email from the smtp tab', async ({ page }) => {
}); });
test('storage tab: fill the S3 backend fields', async ({ page }) => { test('storage tab: fill the S3 backend fields', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/storage');
await page.getByTestId('admin-storage-tab').click();
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
// Switch to S3 to reveal + fill the conditional fields (no save — that would // Switch to S3 to reveal + fill the conditional fields (no save — that would
@@ -270,8 +265,7 @@ test('storage tab: fill the S3 backend fields', async ({ page }) => {
}); });
test('oidc tab: run discovery against a bogus issuer', async ({ page }) => { test('oidc tab: run discovery against a bogus issuer', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/oidc');
await page.getByTestId('admin-oidc-tab').click();
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-oidc-issuer-input').fill('https://idp.example.test').catch(() => {}); await page.getByTestId('admin-oidc-issuer-input').fill('https://idp.example.test').catch(() => {});
// Discovery fails (no real IdP) — exercises the discover + error path. // Discovery fails (no real IdP) — exercises the discover + error path.
@@ -286,8 +280,7 @@ test('users tab: paginate the user list', async ({ page }) => {
for (let i = 0; i < 26; i++) { for (let i = 0; i < 26; i++) {
await apiAdminCreateUser(page, `pageu${Date.now()}${i}`); await apiAdminCreateUser(page, `pageu${Date.now()}${i}`);
} }
await page.goto('/admin'); await page.goto('/admin/users');
await page.getByTestId('admin-users-tab').click();
await expect(page.getByTestId('admin-users-pager-next-btn')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('admin-users-pager-next-btn')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('admin-users-pager-next-btn').click(); await page.getByTestId('admin-users-pager-next-btn').click();
await page.waitForTimeout(400); await page.waitForTimeout(400);
@@ -295,8 +288,7 @@ test('users tab: paginate the user list', async ({ page }) => {
}); });
test('plugins tab: install then save retention settings', async ({ page }) => { test('plugins tab: install then save retention settings', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin/plugins');
await page.getByTestId('admin-plugins-tab').click();
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP); await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
await expect(page.locator('[data-testid^="admin-plugin-delete-"]').first()).toBeVisible({ await expect(page.locator('[data-testid^="admin-plugin-delete-"]').first()).toBeVisible({
timeout: 20_000, timeout: 20_000,