feat(recoverable-job): add findings

This commit is contained in:
Edouard Vanbelle
2026-07-29 01:50:26 +02:00
parent 41d83b3053
commit e1556e3d36
10 changed files with 864 additions and 234 deletions
+134
View File
@@ -209,6 +209,37 @@ pub trait JobStore: Send + Sync {
/// batches — the run's heartbeat.
async fn checkpoint(&self, cursor: Vec<u8>, delta_count: u64) -> Result<(), DomainError>;
/// Persist one finding to `jobs.run_findings` and bump
/// `stats.finding_count` on the parent run. Consistency handlers
/// call this in place of the transitional
/// `tracing::warn!(event = "consistency_finding", …)` — see
/// `docs/plan/job-registry.md` Part 2 §Findings.
///
/// `kind` — stable machine-readable enum-style key (e.g.
/// `"stale_used_bytes"`, `"missing_blob"`). Never rename across
/// releases; new failure modes get new values.
///
/// `severity` — one of `"data_loss"`, `"inconsistent"`, `"anomaly"`.
///
/// `resource_id` — the file / folder / drive / blob the finding
/// pertains to. `None` for run-wide findings (e.g. "backend
/// enumeration truncated at 1M keys").
///
/// `detail` — per-tenant per-kind JSON blob. Consumers key off
/// `kind` to know the shape (cached/actual/delta for
/// `stale_used_bytes`, blob_hash for `missing_blob`, etc.).
///
/// Failure surfaces to the caller as `Err`. Handlers should
/// log-and-continue rather than fail the whole run — a lost
/// finding is bad but not worse than aborting the walk.
async fn record_finding(
&self,
kind: &str,
severity: &str,
resource_id: Option<Uuid>,
detail: serde_json::Value,
) -> Result<(), DomainError>;
// ─── Terminal writes — engine-only. Do not call from handler code.
/// Engine-only. Called by [`run_or_resume`] on
@@ -278,6 +309,33 @@ pub trait JobStoreProvider: Send + Sync {
/// the handler doesn't poll, cancel is a no-op until the run
/// completes naturally.
async fn request_cancel(&self, job_name: &str) -> Result<Option<Uuid>, DomainError>;
/// Findings for a specific run, newest-last, paginated.
/// Powers `GET /api/admin/jobs/{name}/runs/{id}/findings`.
/// `limit` caps rows; the API layer clamps it too. `offset` is
/// simple integer paging — findings-per-run is typically small
/// enough that cursor pagination is overkill.
async fn list_findings(
&self,
run_id: Uuid,
limit: u32,
offset: u32,
) -> Result<Vec<Finding>, DomainError>;
}
/// Serialisable snapshot of one `jobs.run_findings` row, returned by
/// `GET /api/admin/jobs/{name}/runs/{id}/findings`. Consumers key off
/// `kind` to know the shape of `detail`.
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
pub id: Uuid,
pub run_id: Uuid,
pub kind: String,
pub severity: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_id: Option<Uuid>,
pub detail: serde_json::Value,
pub created_at: DateTime<Utc>,
}
/// Serialisable snapshot of one `jobs.recoverable_runs` row, returned
@@ -402,6 +460,40 @@ fn log_terminal_write_err(op: &str, run_id: Uuid, res: Result<(), DomainError>)
}
}
// ─── Recording helper — used by every consistency tenant ───────────────────
/// Persist a finding via `store.record_finding` and, if the write
/// fails, drop a `record_finding.failed` line to the tenant's
/// tracing target so operators don't lose the event silently.
///
/// Exists because every consistency tenant needs the same
/// log-and-continue shape — extracting it here keeps each tenant's
/// per-row branch a single call.
pub async fn record_or_log(
store: &dyn JobStore,
job: &str,
kind: &str,
severity: &str,
resource_id: Option<Uuid>,
detail: serde_json::Value,
) {
if let Err(e) = store
.record_finding(kind, severity, resource_id, detail)
.await
{
tracing::warn!(
target: "oxicloud::consistency",
event = "record_finding.failed",
run_id = %store.run_id(),
job = job,
kind = kind,
resource_id = ?resource_id,
error = %e,
"failed to persist finding; dropped (walk continues)"
);
}
}
// ─── Adapter — bridge to Part 1's JobHandler ────────────────────────────────
/// Wraps a `RecoverableJobHandler` behind a `JobHandler` face so it
@@ -488,6 +580,7 @@ mod tests {
cursor: Option<Vec<u8>>,
scanned_count: u64,
error_message: Option<String>,
findings: Vec<Finding>,
}
#[async_trait]
@@ -507,6 +600,25 @@ mod tests {
s.scanned_count += delta_count;
Ok(())
}
async fn record_finding(
&self,
kind: &str,
severity: &str,
resource_id: Option<Uuid>,
detail: serde_json::Value,
) -> Result<(), DomainError> {
let mut s = self.state.lock().unwrap();
s.findings.push(Finding {
id: Uuid::new_v4(),
run_id: self.run_id,
kind: kind.to_string(),
severity: severity.to_string(),
resource_id,
detail,
created_at: Utc::now(),
});
Ok(())
}
async fn mark_completed(&self) -> Result<(), DomainError> {
self.state.lock().unwrap().status = RunStatus::Completed;
Ok(())
@@ -555,6 +667,7 @@ mod tests {
cursor: None,
scanned_count: 0,
error_message: None,
findings: Vec::new(),
}),
});
let id = store.run_id;
@@ -610,6 +723,7 @@ mod tests {
cursor: None,
scanned_count: 0,
error_message: None,
findings: Vec::new(),
}),
});
stores.push(store.clone());
@@ -683,6 +797,26 @@ mod tests {
}))
}
async fn list_findings(
&self,
run_id: Uuid,
limit: u32,
offset: u32,
) -> Result<Vec<Finding>, DomainError> {
let stores = self.stores.lock().unwrap();
let Some(store) = stores.iter().find(|s| s.run_id == run_id) else {
return Ok(Vec::new());
};
let state = store.state.lock().unwrap();
Ok(state
.findings
.iter()
.skip(offset as usize)
.take(limit as usize)
.cloned()
.collect())
}
async fn request_cancel(&self, _job_name: &str) -> Result<Option<Uuid>, DomainError> {
let stores = self.stores.lock().unwrap();
if let Some(s) = stores.last() {