Merge pull request #651 from EdouardVanbelle/feat/recoverable-jobs
feat(recoverable jobs): add engine + consistency jobs
This commit is contained in:
@@ -172,6 +172,7 @@ export default defineConfig({
|
||||
{ text: "User lifecycle", link: "/architecture/user-lifecycle" },
|
||||
{ text: "Authentication model", link: "/architecture/auth-model" },
|
||||
{ text: "Magic-link auth", link: "/architecture/magic-link-auth" },
|
||||
{ text: "Background jobs", link: "/architecture/jobs" },
|
||||
],
|
||||
},
|
||||
{ text: "FAQ", link: "/faq" },
|
||||
|
||||
@@ -74,3 +74,4 @@ src/
|
||||
- [Caching Architecture →](/architecture/caching)
|
||||
- [Resource Listing API →](/architecture/resource-listing)
|
||||
- [Storage Quotas →](/architecture/storage-quotas)
|
||||
- [Background Jobs →](/architecture/jobs)
|
||||
|
||||
@@ -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(®).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)
|
||||
@@ -1,5 +1,25 @@
|
||||
# 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
|
||||
|
||||
OxiCloud persists state in several independent subsystems: content-addressable
|
||||
@@ -38,14 +58,14 @@ This plan lands:
|
||||
compiles without declaring its consistency contract.
|
||||
3. An **educational surface** in trait doc-comments — decision axes
|
||||
(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)`.
|
||||
5. A **first check** — `BlobConsistencyCheck` (both directions,
|
||||
blob-keyed cursor, severity split).
|
||||
|
||||
**Layer boundary — the runtime is not in this plan.** The resumable
|
||||
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`
|
||||
Part 2. This plan describes what `ConsistencyCheck` implementors
|
||||
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.
|
||||
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
|
||||
are admin-triggered v1, becoming periodic-triggered when a
|
||||
`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
|
||||
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,
|
||||
`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.
|
||||
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
|
||||
alongside every other background job.
|
||||
- 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
|
||||
`serde_json::from_value(store.params()?)`.
|
||||
- `background_runs.stats` accumulates `{"scanned_count": …,
|
||||
- `recoverable_runs.stats` accumulates `{"scanned_count": …,
|
||||
"findings_this_run": …}`; readers call
|
||||
`(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:
|
||||
|
||||
```sql
|
||||
CREATE TABLE admin.consistency_findings (
|
||||
CREATE TABLE jobs.run_findings (
|
||||
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 / ...
|
||||
severity TEXT NOT NULL, -- DataLoss / Reclaimable / ...
|
||||
resource_id TEXT NOT NULL,
|
||||
@@ -193,15 +213,15 @@ CREATE TABLE admin.consistency_findings (
|
||||
found_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
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
|
||||
is pruned by a future retention job.
|
||||
|
||||
`admin.*` is a NEW schema, created by Part 2's migration — keep it
|
||||
distinct from `auth.*` / `storage.*` so operational tables don't
|
||||
`jobs.*` is a NEW schema, created by Part 2's migration — keep it
|
||||
distinct from `auth.*` / `storage.*` / `admin.*` so operational tables don't
|
||||
pollute domain schemas.
|
||||
|
||||
### Non-obvious traps
|
||||
@@ -223,7 +243,7 @@ learned the hard way in similar systems:
|
||||
transitioned (was `MissingBlob`, blob has since landed → drop the
|
||||
finding, not the whole run).
|
||||
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
|
||||
`status='Paused'` + current cursor before returning.
|
||||
5. **Crash recovery on boot.** Any `status='Running'` at server start =
|
||||
@@ -484,7 +504,7 @@ impl ConsistencyRegistry {
|
||||
|
||||
## 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
|
||||
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
|
||||
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.
|
||||
|
||||
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`
|
||||
- `ConsistencyRegistry` (data structure only).
|
||||
- `PgCheckStore` — impl of `CheckStore` reading/writing
|
||||
`admin.background_runs` (filtered to `job_name LIKE 'consistency_%'`)
|
||||
+ `admin.consistency_findings`.
|
||||
`jobs.recoverable_runs` (filtered to `job_name LIKE 'consistency_%'`)
|
||||
+ `jobs.run_findings`.
|
||||
- `run_check(check, cursor, store)` — the runner that calls
|
||||
`run_resumable`, applies timeout, records outcome.
|
||||
|
||||
### 2. Schema migration
|
||||
|
||||
`migrations/YYYYMMDDHHMMSS_background_runs_admin_schema.sql` — creates
|
||||
the merged `admin.background_runs` table shared with the JobRegistry
|
||||
plan. Consistency checks own the `admin.consistency_findings` table
|
||||
alone and reference `background_runs.id` via FK.
|
||||
the merged `jobs.recoverable_runs` table shared with the JobRegistry
|
||||
plan. Consistency checks own the `jobs.run_findings` table
|
||||
alone and reference `recoverable_runs.id` via FK.
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS admin;
|
||||
|
||||
CREATE TABLE admin.background_runs (
|
||||
CREATE TABLE jobs.recoverable_runs (
|
||||
id UUID PRIMARY KEY,
|
||||
job_name TEXT NOT NULL, -- 'consistency_blobs', 'storage_migration', 'reextract_audio', ...
|
||||
status TEXT NOT NULL, -- Running / Paused / Completed / Failed / CancelRequested
|
||||
@@ -556,13 +576,13 @@ CREATE TABLE admin.background_runs (
|
||||
error_message TEXT
|
||||
);
|
||||
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');
|
||||
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,
|
||||
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,
|
||||
severity TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
@@ -570,7 +590,7 @@ CREATE TABLE admin.consistency_findings (
|
||||
found_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
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
|
||||
@@ -631,7 +651,7 @@ Ship this PR without the actual checks. Grep `TODO(consistency)` = punch list.
|
||||
`src/interfaces/api/handlers/admin_handler.rs`
|
||||
|
||||
- `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`,
|
||||
return `run_id`. Concurrent triggers hit the partial unique index
|
||||
and short-circuit to returning the surviving row.
|
||||
@@ -651,7 +671,7 @@ In `AppServiceFactory` init, after DB pool is up:
|
||||
|
||||
```rust
|
||||
sqlx::query!(
|
||||
"UPDATE admin.background_runs
|
||||
"UPDATE jobs.recoverable_runs
|
||||
SET status = 'Paused',
|
||||
error_message = COALESCE(error_message, 'server restart mid-run')
|
||||
WHERE job_name LIKE 'consistency_%'
|
||||
@@ -660,7 +680,7 @@ sqlx::query!(
|
||||
```
|
||||
|
||||
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
|
||||
boot-time hook. The JobRegistry supervisor's boot check may
|
||||
generalise this into a single scheduler-wide sweep — until then, one
|
||||
|
||||
+185
-47
@@ -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 +
|
||||
grant-cleanup + tree-etag flush + content-index. High mechanical
|
||||
payoff, zero new schema, minimal review surface.
|
||||
2. **Part 2 lands next** — introduces `admin.background_runs` schema
|
||||
+ `RecoverableJob` trait + `JobStore` port + `run_or_resume`. On
|
||||
2. **Part 2 lands next** — introduces `jobs.recoverable_runs` schema
|
||||
+ `RecoverableJobHandler` trait + `JobStore` port + `run_or_resume`. On
|
||||
its own PR (schema change deserves independent review).
|
||||
3. **Consistency-check framework (`docs/plan/consistency-check.md`)**
|
||||
lands third, consuming Part 2 as its runtime.
|
||||
@@ -432,16 +432,16 @@ into the scheduler.
|
||||
|
||||
## Part 2 — Recoverable-Run Engine
|
||||
|
||||
### Contract — `RecoverableJob` trait
|
||||
### Contract — `RecoverableJobHandler` trait
|
||||
|
||||
Sibling to `JobHandler`, NOT a subtrait. A stateless job that only
|
||||
implements `JobHandler` never needs to know Part 2 exists.
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait RecoverableJob: Send + Sync {
|
||||
pub trait RecoverableJobHandler: Send + Sync {
|
||||
/// Stable snake_case identifier — matches the `job_name` column
|
||||
/// in `admin.background_runs`.
|
||||
/// in `jobs.recoverable_runs`.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Long-running, cooperative scan. The store is the job's ONLY
|
||||
@@ -479,13 +479,13 @@ pub enum RunOutcome {
|
||||
### `JobStore` trait
|
||||
|
||||
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
|
||||
#[async_trait]
|
||||
pub trait JobStore: Send + Sync {
|
||||
/// 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;
|
||||
|
||||
/// 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
|
||||
Layer C in the consistency-check plan, not the engine's concern.
|
||||
|
||||
### Schema — `admin.background_runs`
|
||||
### Schema — `jobs.recoverable_runs`
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS admin;
|
||||
|
||||
CREATE TABLE admin.background_runs (
|
||||
CREATE TABLE jobs.recoverable_runs (
|
||||
id UUID PRIMARY KEY,
|
||||
job_name TEXT NOT NULL,
|
||||
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
|
||||
ON admin.background_runs (job_name)
|
||||
ON jobs.recoverable_runs (job_name)
|
||||
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';
|
||||
```
|
||||
|
||||
@@ -549,9 +549,9 @@ so it survives concurrent triggers, admin-vs-scheduler races, and
|
||||
transaction interleavings. The `CancelRequested` inclusion prevents
|
||||
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
|
||||
checks own their own `admin.consistency_findings` in the same
|
||||
checks own their own `jobs.run_findings` in the same
|
||||
schema.
|
||||
|
||||
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):
|
||||
|
||||
```sql
|
||||
UPDATE admin.background_runs
|
||||
UPDATE jobs.recoverable_runs
|
||||
SET cursor = $2,
|
||||
stats = jsonb_set(
|
||||
stats,
|
||||
@@ -618,7 +618,7 @@ that's a success. Log lines stay meaningful (`outcome=ok`,
|
||||
The engine module exposes:
|
||||
|
||||
```rust
|
||||
pub async fn run_or_resume<J: RecoverableJob + ?Sized>(
|
||||
pub async fn run_or_resume<J: RecoverableJobHandler + ?Sized>(
|
||||
job: Arc<J>,
|
||||
store_factory: &dyn JobStoreFactory,
|
||||
) -> JobOutcome
|
||||
@@ -671,7 +671,7 @@ At `AppServiceFactory` init, after DB pool is up:
|
||||
|
||||
```rust
|
||||
sqlx::query!(
|
||||
"UPDATE admin.background_runs
|
||||
"UPDATE jobs.recoverable_runs
|
||||
SET status = 'Paused',
|
||||
error_message = COALESCE(error_message, 'server restart mid-run')
|
||||
WHERE status IN ('Running', 'CancelRequested')"
|
||||
@@ -703,18 +703,28 @@ GET /api/admin/jobs/{name}/runs/{id}
|
||||
|
||||
### Native tenants (Part 2)
|
||||
|
||||
- **Blob storage backend migration.** `migration_job.rs` becomes a
|
||||
`RecoverableJob` impl. Cursor = last processed blob hash. Retires
|
||||
the `Arc<RwLock<MigrationState>>` in-memory struct.
|
||||
- **Reextract audio metadata.** Currently synchronous inside the
|
||||
admin HTTP request. Becomes a `RecoverableJob` iterating audio
|
||||
files by `file_id`.
|
||||
- **Reextract image/video capture dates.** Same as above.
|
||||
- **Consistency-check runs.** Every `ConsistencyCheck` impl gets
|
||||
wrapped by a `RecoverableJob` adapter; the wrapper writes to
|
||||
`admin.background_runs` via `JobStore`, and separately writes
|
||||
findings to `admin.consistency_findings` via a check-specific
|
||||
extension trait. See `docs/plan/consistency-check.md`.
|
||||
Consistency checks are organized **by the subject they iterate**, not
|
||||
by the concern they check. Cursor = row PK of that subject. Adding a
|
||||
new check = adding a per-row branch inside the job that walks that
|
||||
subject. See memory `project_consistency_jobs_landscape` for the full
|
||||
rationale + the merges/separations that fall out of the rule.
|
||||
|
||||
| Tenant | Iterates | Cursor | v1 checks | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `drives_consistency` | `storage.drives` | drive UUID | `used_bytes` drift (drive + user envelope) | Shipped Slice 3. |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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)
|
||||
|
||||
@@ -733,7 +743,7 @@ GET /api/admin/jobs/{name}/runs/{id}
|
||||
6. **Idempotent replay:** for consistency-check specifically, verify
|
||||
that re-processing the last unpersisted batch does NOT double-record
|
||||
findings (`UNIQUE (run_id, kind, resource_id)` on
|
||||
`admin.consistency_findings`).
|
||||
`jobs.run_findings`).
|
||||
7. **`RunOutcome` bridge log lines:** completed run logs
|
||||
`outcome=ok, extra.completed=true`; paused logs
|
||||
`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
|
||||
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
|
||||
|
||||
Canonical form for every job (Part 1 or Part 2 alike, AND for core
|
||||
workers even though they don't register with the scheduler):
|
||||
**No new convention.** Each service keeps its natural per-service
|
||||
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.
|
||||
|
||||
```
|
||||
OXICLOUD_JOB_<NAME>_ENABLED
|
||||
OXICLOUD_JOB_<NAME>_INTERVAL_HOURS # or _INTERVAL_SECS for sub-hour cadences
|
||||
OXICLOUD_JOB_<NAME>_<CUSTOM>... # e.g. _GRACE_HOURS, _BATCH_SIZE
|
||||
```
|
||||
Earlier drafts proposed a uniform `OXICLOUD_JOB_<NAME>_INTERVAL_*`
|
||||
convention, with legacy names as warned aliases. Killed 2026-07-28
|
||||
(Ed): normalising only the interval knob while leaving domain-specific
|
||||
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
|
||||
(e.g. `OXICLOUD_JOB_TREE_ETAG_FLUSH_INTERVAL_MS`) — the convention is
|
||||
what operators grep for; whether the loop is scheduler-driven or a
|
||||
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.
|
||||
The one real gap is **trash_cleanup has no env var today** (hardcoded
|
||||
24h in DI). Adding `OXICLOUD_TRASH_CLEANUP_INTERVAL_HOURS` when we
|
||||
need it uses the natural prefix — no new convention needed.
|
||||
|
||||
### Logging schema
|
||||
|
||||
@@ -874,7 +1012,7 @@ precludes it.
|
||||
|
||||
### 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) …`
|
||||
query. Deeper history (retention window, per-run drill-down UI) is
|
||||
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
|
||||
graph.
|
||||
- **Retention pruning of terminal `background_runs` rows.** Deferred
|
||||
- **Retention pruning of terminal `recoverable_runs` rows.** Deferred
|
||||
until the volume warrants a policy.
|
||||
- **Prometheus / OpenMetrics export.** Log-only for now.
|
||||
- **Distributed scheduling.** Single-process. If OxiCloud ever runs
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
@@ -522,3 +522,102 @@ export interface FolderAncestorsResponse {
|
||||
ancestors: FolderAncestor[];
|
||||
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
@@ -32,15 +32,12 @@
|
||||
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
|
||||
|
||||
interface NavLink {
|
||||
href:
|
||||
| '/files'
|
||||
| '/shared'
|
||||
| '/shared-with-me'
|
||||
| '/recent'
|
||||
| '/favorites'
|
||||
| '/photos'
|
||||
| '/music'
|
||||
| '/trash';
|
||||
/**
|
||||
* String rather than a literal union so admin links (which
|
||||
* include a dynamic path segment) can share the same shape.
|
||||
* `resolve()` accepts any string, so no type-level cost.
|
||||
*/
|
||||
href: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
/** 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' }
|
||||
];
|
||||
|
||||
// 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');
|
||||
|
||||
// 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 {
|
||||
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 ─────────────────────────────────────────────────
|
||||
// The row-drag on `/files` (and other resource surfaces) sets a
|
||||
// `application/x-oxi-item` MIME with a JSON array of `{ id, name, kind }`.
|
||||
@@ -467,16 +555,50 @@
|
||||
<div class="app-name">OxiCloud</div>
|
||||
</a>
|
||||
|
||||
<nav class="nav-menu" aria-label={t('nav.primary', 'Primary')}>
|
||||
{#each LINKS as link (link.href)}
|
||||
{@const isDropTarget = link.href === '/favorites' || link.href === '/trash'}
|
||||
<nav
|
||||
class="nav-menu"
|
||||
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
|
||||
class="nav-item"
|
||||
class:active={active(link.href)}
|
||||
class:active={activeLink(link.href)}
|
||||
class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href}
|
||||
href={resolve(link.href)}
|
||||
href={navHref(link.href)}
|
||||
data-section={link.section}
|
||||
data-testid={`appshell-nav-${link.href.replace(/^\//, '')}-link`}
|
||||
data-testid={`appshell-nav-${link.section}-link`}
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
ondragover={isDropTarget ? (e) => sidebarOnDragOver(e, link.href) : undefined}
|
||||
ondragleave={isDropTarget ? () => sidebarOnDragLeave(link.href) : undefined}
|
||||
@@ -485,10 +607,11 @@
|
||||
<Icon name={link.icon} />
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
{#if link.href === '/files' && !session.isExternalUser}
|
||||
{#if !isAdminSection && link.href === '/files' && !session.isExternalUser}
|
||||
<DrivePicker onnavigate={() => (sidebarOpen = false)} />
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
</nav>
|
||||
|
||||
{#if session.user}
|
||||
|
||||
+85
-100
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||
import { dateTimeFormatFor } from '$lib/utils/display';
|
||||
import {
|
||||
@@ -76,6 +77,7 @@
|
||||
DrivePoliciesPartial,
|
||||
User
|
||||
} from '$lib/api/types';
|
||||
import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte';
|
||||
@@ -172,8 +174,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
type Tab = 'dashboard' | 'users' | 'drives' | 'mounts' | 'plugins' | 'oidc' | 'storage' | 'smtp';
|
||||
let tab = $state<Tab>('dashboard');
|
||||
type Tab =
|
||||
| '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
|
||||
let dashboard = $state<AdminDashboard | null>(null);
|
||||
@@ -1462,7 +1525,8 @@
|
||||
plugins: false,
|
||||
oidc: false,
|
||||
storage: false,
|
||||
smtp: false
|
||||
smtp: false,
|
||||
jobs: false
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -1493,7 +1557,9 @@
|
||||
});
|
||||
</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)}
|
||||
{#if on}
|
||||
@@ -1503,83 +1569,20 @@
|
||||
{/if}
|
||||
{/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">
|
||||
<h1>{t('admin.title', 'Admin')}</h1>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="admin-dashboard-tab"
|
||||
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>
|
||||
<!--
|
||||
H1 shows the current section since the sidebar is what
|
||||
communicates which admin area we're in — the plain "Admin"
|
||||
h1 was informationless once the tab bar moved out.
|
||||
-->
|
||||
<h1>{tabLabel}</h1>
|
||||
|
||||
{#if tab === 'dashboard'}
|
||||
{#if dashboardError}
|
||||
@@ -2787,6 +2790,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{:else if tab === 'jobs'}
|
||||
<AdminJobsPanel />
|
||||
{:else if !pluginsAvailable}
|
||||
<p class="status">{t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}</p>
|
||||
{:else if pluginsError}
|
||||
@@ -4092,26 +4097,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
+53
-15
@@ -1,12 +1,46 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
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' } },
|
||||
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/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', () => ({
|
||||
clearPluginLogs: vi.fn(),
|
||||
createExternalMount: vi.fn(),
|
||||
@@ -87,6 +121,10 @@ const mount = {
|
||||
|
||||
beforeEach(() => {
|
||||
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.listUsers).mockResolvedValue({ total: 1, users: [user] });
|
||||
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 () => {
|
||||
m(admin.createUser).mockResolvedValue(undefined);
|
||||
setTab('users');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-users-tab'));
|
||||
await waitFor(() => expect(admin.listUsers).toHaveBeenCalled());
|
||||
await fireEvent.click(await screen.findByTestId('admin-users-create-btn'));
|
||||
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 () => {
|
||||
setTab('oidc');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
|
||||
await waitFor(() => expect(admin.getOidcSettings).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads storage + migration when the storage tab is opened', async () => {
|
||||
setTab('storage');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
|
||||
await waitFor(() => expect(admin.getStorageSettings).toHaveBeenCalled());
|
||||
await waitFor(() => expect(admin.getMigration).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads SMTP info when the SMTP tab is opened', async () => {
|
||||
setTab('smtp');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
|
||||
await waitFor(() => expect(admin.getSmtpInfo).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads plugins when the plugins tab is opened', async () => {
|
||||
setTab('plugins');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-plugins-tab'));
|
||||
await waitFor(() => expect(admin.listPlugins).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads external mounts when the mounts tab is opened and lists them', async () => {
|
||||
setTab('mounts');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-mounts-tab'));
|
||||
await waitFor(() => expect(admin.listExternalMounts).toHaveBeenCalled());
|
||||
// The configured mount is rendered in the table.
|
||||
expect(await screen.findByText('Media')).toBeTruthy();
|
||||
@@ -194,8 +232,8 @@ it('creates a mount from the mounts form', async () => {
|
||||
mount_path: 'Personal/Photos',
|
||||
config: { path: '/srv/photos', read_only: false }
|
||||
});
|
||||
setTab('mounts');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-mounts-tab'));
|
||||
await fireEvent.input(await screen.findByTestId('mount-name'), {
|
||||
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 () => {
|
||||
m(admin.deleteExternalMount).mockResolvedValue(undefined);
|
||||
setTab('mounts');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-mounts-tab'));
|
||||
await fireEvent.click(await screen.findByTestId('mount-delete'));
|
||||
// deleteMount() gates on the styled confirm modal.
|
||||
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 () => {
|
||||
m(admin.setUserRole).mockResolvedValue(undefined);
|
||||
setTab('users');
|
||||
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-confirm-ok-btn'));
|
||||
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 () => {
|
||||
m(admin.setUserActive).mockResolvedValue(undefined);
|
||||
setTab('users');
|
||||
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-confirm-ok-btn'));
|
||||
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 () => {
|
||||
m(admin.saveOidc).mockResolvedValue(undefined);
|
||||
setTab('oidc');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-oidc-tab'));
|
||||
await fireEvent.input(await screen.findByTestId('admin-oidc-issuer-input'), {
|
||||
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 () => {
|
||||
m(admin.sendSmtpTest).mockResolvedValue({ ok: true } as never);
|
||||
setTab('smtp');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-smtp-tab'));
|
||||
await fireEvent.input(await screen.findByTestId('admin-smtp-to-input'), {
|
||||
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 () => {
|
||||
m(admin.saveStorage).mockResolvedValue(undefined);
|
||||
m(admin.migrationAction).mockResolvedValue(undefined);
|
||||
setTab('storage');
|
||||
render(AdminPage);
|
||||
await fireEvent.click(await screen.findByTestId('admin-storage-tab'));
|
||||
await fireEvent.submit(await screen.findByTestId('admin-storage-form'));
|
||||
await waitFor(() => expect(admin.saveStorage).toHaveBeenCalled());
|
||||
await fireEvent.click(await screen.findByTestId('admin-migration-start-btn'));
|
||||
@@ -806,7 +806,7 @@
|
||||
|
||||
<style>
|
||||
/* 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
|
||||
with the elements they style below.
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
"trash": "Trash",
|
||||
"groups": "Groups",
|
||||
"primary": "Primary",
|
||||
"admin_sections": "Admin sections",
|
||||
"back_to_app": "Back to OxiCloud",
|
||||
"profile": "Profile",
|
||||
"shared_with_me": "Shared with me",
|
||||
"toggle": "Toggle navigation menu"
|
||||
@@ -1191,7 +1193,74 @@
|
||||
"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_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": {
|
||||
"page_title": "Profile",
|
||||
|
||||
@@ -71,14 +71,14 @@ coverage-integration filter='mount':
|
||||
# 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`
|
||||
# URLs as defence in depth.
|
||||
test-integration:
|
||||
test-integration filter='':
|
||||
bash tests/common/spawn-db.sh
|
||||
PGHOST=localhost PGPORT=5433 PGUSER=oxicloud_test PGPASSWORD=oxicloud_test \
|
||||
PGDATABASE=oxicloud_test \
|
||||
bash tests/common/init-test-schema.sh
|
||||
DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \
|
||||
RUSTFLAGS='--cfg integration_tests' \
|
||||
cargo test --workspace --tests
|
||||
cargo test --workspace --tests {{filter}}
|
||||
bash tests/common/stop-db.sh
|
||||
|
||||
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.
|
||||
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use std::future::Future;
|
||||
@@ -18,6 +19,53 @@ use std::pin::Pin;
|
||||
|
||||
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.
|
||||
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 {
|
||||
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";
|
||||
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRunArgs};
|
||||
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs};
|
||||
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]
|
||||
impl JobHandler for StorageUsageService {
|
||||
fn name(&self) -> &str {
|
||||
|
||||
+196
-77
@@ -322,6 +322,13 @@ impl AppServiceFactory {
|
||||
let blob_lifecycle =
|
||||
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)
|
||||
let dedup_service = Arc::new(
|
||||
crate::infrastructure::services::dedup_service::DedupService::new(
|
||||
@@ -436,6 +443,15 @@ impl AppServiceFactory {
|
||||
// have landed.
|
||||
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 {
|
||||
path_service,
|
||||
file_content_cache,
|
||||
@@ -449,6 +465,8 @@ impl AppServiceFactory {
|
||||
zip_service: None, // Placeholder - replaced after app services init
|
||||
config: self.config.clone(),
|
||||
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
|
||||
// tokio interval loop. `SchedulerEngine::start` fires the actual
|
||||
// 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(®)` 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(),
|
||||
core.dedup_service.clone(),
|
||||
24, // Run cleanup every 24 hours
|
||||
));
|
||||
let interval = cleanup_service.interval();
|
||||
if let Err(e) = core
|
||||
.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
|
||||
);
|
||||
}
|
||||
))
|
||||
.register(&core.job_registry)
|
||||
.await;
|
||||
|
||||
Some(service as Arc<TrashService>)
|
||||
}
|
||||
@@ -1125,7 +1134,12 @@ impl AppServiceFactory {
|
||||
// and invalidation would be a no-op observed by nobody —
|
||||
// this is the trap that regressed the used_bytes freshness
|
||||
// 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(
|
||||
maintenance_pool.clone(),
|
||||
user_repository,
|
||||
@@ -1134,28 +1148,9 @@ impl AppServiceFactory {
|
||||
drive_repo
|
||||
as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
),
|
||||
);
|
||||
// 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).
|
||||
// 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
|
||||
{
|
||||
tracing::error!("Failed to register storage_reconcile job: {e}");
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Storage-usage reconciliation registered with scheduler (interval {}s)",
|
||||
interval.as_secs()
|
||||
);
|
||||
}
|
||||
service
|
||||
)
|
||||
.register(&core.job_registry, self.config.storage.usage_reconcile_secs)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Starts the tree-ETag flush job (requires database).
|
||||
@@ -1279,22 +1274,112 @@ impl AppServiceFactory {
|
||||
// Register on-demand-only jobs whose owning service lives on
|
||||
// CoreServices. Dedup GC has NO periodic tick — trash cleanup's
|
||||
// sweep already runs GC as its tail step, so a periodic dedup
|
||||
// schedule would double the work. Registering with `interval =
|
||||
// None` keeps it admin-triggerable through the uniform scheduler
|
||||
// surface (`POST /api/admin/jobs/dedup_gc/trigger`).
|
||||
if let Err(e) = core
|
||||
.job_registry
|
||||
.register(
|
||||
core.dedup_service.clone() as Arc<dyn crate::infrastructure::scheduler::JobHandler>,
|
||||
None, // on-demand only
|
||||
None, // no timeout
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to register dedup_gc job with scheduler: {e}");
|
||||
} else {
|
||||
tracing::info!("Dedup GC registered with scheduler (on-demand only)");
|
||||
}
|
||||
// schedule would double the work. The `register()` method
|
||||
// encapsulates the on-demand shape.
|
||||
let _ = core
|
||||
.dedup_service
|
||||
.clone()
|
||||
.register(&core.job_registry)
|
||||
.await;
|
||||
|
||||
// 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(),
|
||||
),
|
||||
)
|
||||
.register_recoverable_job(&core.job_registry, &job_store_provider_dyn)
|
||||
.await;
|
||||
|
||||
// Second recoverable-run tenant. Iterates `storage.folders`
|
||||
// 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)
|
||||
let repos = self.create_repository_services(&core, &pool);
|
||||
@@ -1484,32 +1569,18 @@ impl AppServiceFactory {
|
||||
self.start_content_index_job(&maintenance_pool, &core, content_index);
|
||||
|
||||
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(
|
||||
crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new(
|
||||
authorization.clone(),
|
||||
core.config.features.grant_cleanup.grace_days,
|
||||
core.config.features.grant_cleanup.interval_hours,
|
||||
),
|
||||
);
|
||||
// Registered with the periodic-job scheduler
|
||||
// (`docs/plan/job-registry.md` Part 1); the retired
|
||||
// `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,
|
||||
);
|
||||
}
|
||||
)
|
||||
.register(&core.job_registry)
|
||||
.await;
|
||||
Some(svc)
|
||||
} else {
|
||||
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
|
||||
// finished registering its jobs on `core.job_registry`. Starting
|
||||
// it earlier would race the first tick against late registrations.
|
||||
@@ -2212,6 +2318,19 @@ pub struct CoreServices {
|
||||
/// themselves here during their creation; `SchedulerEngine::start`
|
||||
/// spins up the supervisor loop at the end of `build_app_state`.
|
||||
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(®istry, &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
|
||||
|
||||
@@ -264,6 +264,10 @@ fn translate_join(
|
||||
/// Distinct Ok/Err branches so the tracing macros pick up the fields at
|
||||
/// compile time — `tracing` doesn't expand conditional field lists.
|
||||
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 {
|
||||
JobOutcome::Ok { count, extra } => {
|
||||
tracing::info!(
|
||||
@@ -274,8 +278,10 @@ fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapse
|
||||
count = *count,
|
||||
elapsed_ms = elapsed_ms,
|
||||
extra = %extra,
|
||||
"job {} ran",
|
||||
"job {} ran in {} — count={}",
|
||||
name,
|
||||
elapsed,
|
||||
count,
|
||||
);
|
||||
}
|
||||
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),
|
||||
elapsed_ms = elapsed_ms,
|
||||
error = %msg,
|
||||
"job {} failed",
|
||||
"job {} failed after {} — {}",
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -361,8 +385,7 @@ mod tests {
|
||||
let registry = Arc::new(JobRegistry::new());
|
||||
registry
|
||||
.register(handler, Some(Duration::from_millis(100)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
let entry = registry.get("overrun").await.unwrap();
|
||||
|
||||
// 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(50)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
let entry = registry.get("slow").await.unwrap();
|
||||
|
||||
dispatch("slow", entry.clone(), &JobRunArgs::default()).await;
|
||||
|
||||
@@ -24,10 +24,18 @@
|
||||
|
||||
mod engine;
|
||||
mod handler;
|
||||
mod pg_job_store;
|
||||
mod recoverable;
|
||||
mod registry;
|
||||
mod types;
|
||||
|
||||
pub use engine::SchedulerEngine;
|
||||
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 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
@@ -69,10 +69,7 @@ impl JobRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a job. Returns an error if a job with the same name
|
||||
/// is already registered — names are the primary identifier
|
||||
/// everywhere (logs, admin URLs, env vars) and collisions would
|
||||
/// hide bugs.
|
||||
/// Register a job — production wiring path.
|
||||
///
|
||||
/// - `interval = Some(dur)` → **scheduled**. The supervisor fires
|
||||
/// the job every `dur`, starting `now + dur`. Registration does
|
||||
@@ -83,11 +80,59 @@ impl JobRegistry {
|
||||
/// fires this job. Admin endpoint (or programmatic callers) can
|
||||
/// still invoke it via [`JobRegistry::trigger`] — the dispatch
|
||||
/// 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(
|
||||
&self,
|
||||
handler: Arc<dyn JobHandler>,
|
||||
interval: 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> {
|
||||
let name = handler.name().to_string();
|
||||
let mut guard = self.entries.write().await;
|
||||
@@ -286,11 +331,9 @@ mod tests {
|
||||
async fn register_and_pick_next() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("job_a"), Some(Duration::from_secs(60)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
reg.register(handler("job_b"), Some(Duration::from_secs(10)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
|
||||
let (next_name, _) = reg.pick_next().await.expect("expected a due job");
|
||||
// job_b has the shorter interval → earlier next_run_at.
|
||||
@@ -300,16 +343,30 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn duplicate_registration_rejected() {
|
||||
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
|
||||
.unwrap();
|
||||
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
|
||||
.expect_err("duplicate name must be rejected");
|
||||
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]
|
||||
async fn empty_registry_picks_nothing() {
|
||||
let reg = JobRegistry::new();
|
||||
@@ -320,11 +377,9 @@ mod tests {
|
||||
async fn snapshot_all_returns_every_entry() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("a"), Some(Duration::from_secs(1)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
reg.register(handler("b"), Some(Duration::from_secs(1)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
let all = reg.snapshot_all().await;
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
@@ -334,12 +389,9 @@ mod tests {
|
||||
let reg = JobRegistry::new();
|
||||
// Scheduled job with a long interval.
|
||||
reg.register(handler("scheduled"), Some(Duration::from_secs(3600)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
// On-demand job — supervisor must never pick it.
|
||||
reg.register(handler("on_demand"), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
reg.register(handler("on_demand"), None, None).await;
|
||||
|
||||
let (next_name, _) = reg.pick_next().await.expect("scheduled job due");
|
||||
assert_eq!(
|
||||
@@ -351,7 +403,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn trigger_dispatches_on_demand_job() {
|
||||
let reg = Arc::new(JobRegistry::new());
|
||||
reg.register(handler("gc"), None, None).await.unwrap();
|
||||
reg.register(handler("gc"), None, None).await;
|
||||
|
||||
let outcome = reg
|
||||
.trigger("gc", &JobRunArgs::default())
|
||||
|
||||
@@ -25,9 +25,19 @@ use serde::{Deserialize, Serialize};
|
||||
/// - `dedup_gc` — skip the orphan grace window (grace = 0).
|
||||
/// - `grant_cleanup` — grace = 0.
|
||||
/// - 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)]
|
||||
pub struct JobRunArgs {
|
||||
pub force: bool,
|
||||
pub deep: bool,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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);
|
||||
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) ───────────────────────
|
||||
|
||||
@@ -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`).
|
||||
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]
|
||||
impl crate::infrastructure::scheduler::JobHandler for DedupService {
|
||||
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
|
||||
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>`.
|
||||
|
||||
@@ -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::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 async_trait::async_trait;
|
||||
|
||||
@@ -57,12 +57,22 @@ impl GrantCleanupService {
|
||||
self.grace_days
|
||||
}
|
||||
|
||||
/// Cadence exposed as `Duration` so DI passes a sanitised value
|
||||
/// (post-`.max(1)`) to `JobRegistry::register`.
|
||||
/// Cadence exposed as `Duration`. Internal helper used by
|
||||
/// [`Self::register`]; kept `pub` for tests.
|
||||
pub fn interval(&self) -> Duration {
|
||||
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.
|
||||
///
|
||||
/// `grace_override`:
|
||||
|
||||
@@ -10,6 +10,7 @@ use tokio::io::{AsyncSeekExt, AsyncWriteExt};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::application::ports::blob_storage_ports::{
|
||||
BlobStorageBackend, BlobStream, StorageHealthStatus,
|
||||
@@ -607,6 +608,165 @@ impl BlobStorageBackend for LocalBlobBackend {
|
||||
fn read_prefetch(&self) -> usize {
|
||||
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)]
|
||||
|
||||
@@ -229,4 +229,45 @@ impl BlobStorageBackend for MigrationBlobBackend {
|
||||
.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",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
pub mod audio_metadata_service;
|
||||
pub mod azure_blob_backend;
|
||||
pub mod backend_consistency_service;
|
||||
pub mod blobs_consistency_service;
|
||||
pub mod cached_blob_backend;
|
||||
pub mod chunked_upload_service;
|
||||
pub mod compression_service;
|
||||
pub mod consistency_batch_service;
|
||||
pub mod db_pool_monitor;
|
||||
pub mod dedup_service;
|
||||
pub mod drives_consistency_service;
|
||||
pub mod encrypted_blob_backend;
|
||||
pub mod exif_service;
|
||||
pub mod face_geometry;
|
||||
@@ -12,6 +16,8 @@ pub mod face_indexing_service;
|
||||
pub mod ffmpeg_video_frame_service;
|
||||
pub mod file_content_cache;
|
||||
pub mod file_system_i18n_service;
|
||||
pub mod files_consistency_service;
|
||||
pub mod folders_consistency_service;
|
||||
pub mod grant_cleanup_service;
|
||||
pub mod image_transcode_service;
|
||||
pub mod jwt_service;
|
||||
|
||||
@@ -355,4 +355,26 @@ impl BlobStorageBackend for RetryBlobBackend {
|
||||
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
|
||||
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> {
|
||||
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::domain::repositories::trash_repository::TrashRepository;
|
||||
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 async_trait::async_trait;
|
||||
|
||||
@@ -43,12 +43,31 @@ impl TrashCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registered interval as a `Duration` — helper for DI wiring so
|
||||
/// the composition root doesn't reinvent the `hours × 3600` cast.
|
||||
/// Registered interval as a `Duration`. Internal helper used by
|
||||
/// [`Self::register`]; kept `pub` in case a test wants to assert
|
||||
/// the clamped value.
|
||||
pub fn interval(&self) -> Duration {
|
||||
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
|
||||
#[instrument(skip(self))]
|
||||
pub async fn start_cleanup_job(&self) {
|
||||
|
||||
@@ -147,8 +147,26 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
// audit-logged. See `docs/plan/job-registry.md` §Cross-cutting.
|
||||
// Retired the `/internal/trigger-sweep|gc|grant-cleanup` shims
|
||||
// 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/{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
|
||||
// is filtered to the caller's role grants).
|
||||
.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).
|
||||
/// Silently ignored by handlers that don't (trash_cleanup,
|
||||
/// 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)]
|
||||
pub struct TriggerJobQuery {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
#[serde(default)]
|
||||
pub deep: bool,
|
||||
}
|
||||
|
||||
/// `POST /api/admin/jobs/{name}/trigger` — dispatch one run off-schedule.
|
||||
@@ -2125,11 +2149,16 @@ pub async fn trigger_job(
|
||||
event = "job.trigger",
|
||||
job = %name,
|
||||
force = query.force,
|
||||
"👮🏻♂️ Admin triggered job {} (force={})",
|
||||
deep = query.deep,
|
||||
"👮🏻♂️ Admin triggered job {} (force={}, deep={})",
|
||||
name,
|
||||
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 {
|
||||
Some(outcome) => (
|
||||
StatusCode::OK,
|
||||
@@ -2146,3 +2175,294 @@ pub async fn trigger_job(
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,9 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
// (docs/plan/job-registry.md §Cross-cutting).
|
||||
handlers::admin_handler::list_jobs,
|
||||
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)
|
||||
handlers::grant_handler::create_grant,
|
||||
handlers::grant_handler::revoke_grant,
|
||||
|
||||
+116
-1
@@ -89,7 +89,19 @@ jsonpath "$..interval_ms" contains 600000
|
||||
jsonpath "$..interval_ms" count == 3
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# `JobRegistry::trigger` returning `None`.
|
||||
|
||||
@@ -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
|
||||
@@ -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_admin_gate.hurl" \
|
||||
"$API_DIR/admin_jobs.hurl" \
|
||||
"$API_DIR/recoverable_jobs.hurl" \
|
||||
"$API_DIR/default_caldav_carddav.hurl" \
|
||||
"$API_DIR/dav_error_mapping.hurl" \
|
||||
"$API_DIR/carddav_vcard_properties.hurl" \
|
||||
|
||||
+31
-39
@@ -15,44 +15,47 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
test('walk every admin tab', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await expect(page.getByTestId('admin-dashboard-tab')).toBeVisible({ timeout: 15_000 });
|
||||
// Dashboard is the default tab.
|
||||
// Dashboard is the default section on `/admin` (bare path).
|
||||
// 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 page.getByTestId('admin-users-tab').click();
|
||||
await page.goto('/admin/users');
|
||||
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 page.getByTestId('admin-storage-tab').click();
|
||||
await page.goto('/admin/storage');
|
||||
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 page.getByTestId('admin-plugins-tab').click();
|
||||
// Plugins panel content is conditional; assert the tab became active.
|
||||
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
||||
await page.goto('/admin/plugins');
|
||||
// Plugins panel content is conditional; assert the URL landed
|
||||
// on the plugins section (proves routing worked; no content
|
||||
// guarantee).
|
||||
await expect(page).toHaveURL(/\/admin\/plugins$/);
|
||||
});
|
||||
|
||||
test('open the create-user form', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-users-tab').click();
|
||||
await page.goto('/admin/users');
|
||||
await page.getByTestId('admin-users-create-btn').click();
|
||||
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('storage tab: change backend select', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-storage-tab').click();
|
||||
await page.goto('/admin/storage');
|
||||
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-storage-backend-select').selectOption({ index: 1 }).catch(() => {});
|
||||
});
|
||||
|
||||
test('storage tab: save the local backend settings', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-storage-tab').click();
|
||||
await page.goto('/admin/storage');
|
||||
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
||||
// Keep the (safe) local backend and save — exercises the save handler without
|
||||
// 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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-oidc-tab').click();
|
||||
await page.goto('/admin/oidc');
|
||||
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-oidc-enabled-checkbox').check().catch(() => {});
|
||||
await page
|
||||
@@ -84,8 +86,7 @@ async function createUserRow(
|
||||
page: import('@playwright/test').Page,
|
||||
uname: string,
|
||||
): Promise<ReturnType<import('@playwright/test').Page['locator']>> {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-users-tab').click();
|
||||
await page.goto('/admin/users');
|
||||
await page.getByTestId('admin-users-create-btn').click();
|
||||
await expect(page.getByTestId('admin-create-user-form')).toBeVisible({ timeout: 15_000 });
|
||||
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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-oidc-tab').click();
|
||||
await page.goto('/admin/oidc');
|
||||
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-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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
||||
await page.goto('/admin/plugins');
|
||||
await expect(page).toHaveURL(/\/admin\/plugins$/);
|
||||
|
||||
// Install the example hello plugin (plugins are enabled in the coverage env).
|
||||
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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
await page.goto('/admin/plugins');
|
||||
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
|
||||
await expect(page.locator('[data-testid^="admin-plugin-details-"]').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
@@ -223,9 +221,8 @@ test('view plugin logs and details', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('plugins tab: save retention settings', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
await expect(page.getByTestId('admin-plugins-tab')).toHaveAttribute('aria-selected', 'true');
|
||||
await page.goto('/admin/plugins');
|
||||
await expect(page).toHaveURL(/\/admin\/plugins$/);
|
||||
// The retention form is conditional; fill + save it when present.
|
||||
const retention = page.getByTestId('admin-plugin-retention-form');
|
||||
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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-smtp-tab').click();
|
||||
await page.goto('/admin/smtp');
|
||||
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-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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-storage-tab').click();
|
||||
await page.goto('/admin/storage');
|
||||
await expect(page.getByTestId('admin-storage-form')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-oidc-tab').click();
|
||||
await page.goto('/admin/oidc');
|
||||
await expect(page.getByTestId('admin-oidc-form')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-oidc-issuer-input').fill('https://idp.example.test').catch(() => {});
|
||||
// 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++) {
|
||||
await apiAdminCreateUser(page, `pageu${Date.now()}${i}`);
|
||||
}
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-users-tab').click();
|
||||
await page.goto('/admin/users');
|
||||
await expect(page.getByTestId('admin-users-pager-next-btn')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('admin-users-pager-next-btn').click();
|
||||
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 }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByTestId('admin-plugins-tab').click();
|
||||
await page.goto('/admin/plugins');
|
||||
await page.getByTestId('admin-plugins-install-input').setInputFiles(PLUGIN_ZIP);
|
||||
await expect(page.locator('[data-testid^="admin-plugin-delete-"]').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
|
||||
Reference in New Issue
Block a user