tidaldb/.sdlc/features/m9-purge-rematerialization/design.md
jordan 6f26d03c77 feat(m9): implement purge re-materialization engine
Adds the M9 purge re-materialization feature: a WAL-replay background
engine that rebuilds community cohort aggregates for a (user, community)
pair after retroactive signal purge, restoring ranking correctness without
modifying the immutable WAL.

Key additions:
- cohort::rematerialization module: PurgeJobQueue, RematerializationEngine,
  WAL replay, atomic CohortSignalLedger swap, BLAKE3 audit log, metrics counters
- TidalDb::{submit_purge_job, purge_job_status, rematerialization_metrics}
  public API (db/rematerialization.rs)
- Engine auto-starts in persistent mode; clean shutdown before WAL teardown
- 6 integration tests in tests/m9_purge_remat.rs covering ephemeral and
  persistent modes, job lifecycle, and multi-job independence
- Split oversized files to comply with 600-line limit: db/mod.rs →
  db/from_parts.rs, entities/revocation.rs → revocation/{mod,tests}.rs,
  schema/validation/builders.rs → builders/{mod,tests}.rs,
  signals/warm.rs → warm/{mod,tests,proptests}.rs
- Fix pre-existing bootstrap errors: export AuditKind from session module,
  add overrides_rejected to SessionSnapshot deserialization
2026-03-03 19:18:16 -07:00

95 lines
3.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design: Re-materialization after Purge
## Module Layout
All re-materialization code lives under `tidal/src/cohort/rematerialization/`:
```
cohort/
rematerialization/
mod.rs — RematerializationConfig, RematerializationHandle, start(), worker_loop
job.rs — PurgeJob, PurgeJobQueue, PurgeJobStatus, JobId
replay.rs — filtered_replay() — WAL scan + scratch CohortSignalLedger build
swap.rs — atomic_swap() — drain scratch into live ledger
audit.rs — AuditLogWriter, PurgeAuditEntry, BLAKE3-framed binary format
metrics.rs — RematerializationMetrics, RematerializationMetricsSnapshot
```
## Data Structures
### PurgeJob
```rust
pub struct PurgeJob {
pub job_id: JobId, // [u8; 16] — timestamp+counter hash
pub user_id: u64,
pub community_id: String,
pub signal_type_filter: Option<Vec<String>>,
pub submitted_at_ns: u64,
pub status: PurgeJobStatus,
pub attempt_count: u8,
pub last_heartbeat_ns: Option<u64>,
}
```
### PurgeJobQueue
- `DashMap<JobId, PurgeJob>` for O(1) lookup.
- `Arc<Mutex<Vec<JobId>>>` for FIFO submission order.
- Methods: `submit`, `claim_pending`, `update_status`, `requeue_interrupted`, `requeue_failed`, `list_jobs`, `job_status`, `pending_count`.
### RematerializationConfig
Passed to `start()` at open time:
- `cohort_ledger: Arc<CohortSignalLedger>`
- `contribution_log: Arc<CohortContributionLog>`
- `schema: Schema`
- `wal_dir: PathBuf`
- `audit_log_path: PathBuf`
- `job_queue: Arc<Mutex<PurgeJobQueue>>`
- `metrics: Arc<RematerializationMetrics>`
## WAL Replay Algorithm (`filtered_replay`)
1. Read `CohortContributionLog::drain_for(user_id, community_id)` to obtain the set of `ContributionRecord`s to exclude.
2. Build an `ExclusionSet`: `HashSet<(entity_id, signal_type_id, weight_bits, timestamp_ns)>` — exact 4-tuple match.
3. Scan WAL segments in order (using the existing WAL reader).
4. For each WAL event matching `community_id`:
- If the event's 4-tuple is in the `ExclusionSet`, skip it (`events_excluded++`).
- Otherwise, `record()` the event into a scratch `CohortSignalLedger`.
5. Return `ReplayResult { ledger: scratch, stats: ReplayStats }`.
## Atomic Ledger Swap (`atomic_swap`)
After replay, the scratch ledger is swapped into the live ledger for the target community:
1. Collect all `live` entries for `community_id` (the "before" set).
2. Call `scratch.drain_community_into(live, community_id)` — uses `DashMap::remove + insert` to take ownership of each entry without cloning.
3. For entries in the "before" set but absent from scratch (zero retained events), call `live.remove_entry(community_id, entity_id, type_id)`.
## Audit Log Format
Binary file, one entry per job:
```
[magic: 4 bytes "PRGT"]
[body_len: 4 bytes LE u32]
[body: JSON of PurgeAuditEntry]
[checksum: 4 bytes — first 4 bytes of BLAKE3(body)]
```
`PurgeAuditEntry` contains: `job_id`, `user_id`, `community_id`, `filter`, `submitted_at_ns`, `completed_at_ns`, `events_replayed`, `events_excluded`, `entries_updated`, `verification_checksum`, `success`, `error_message`.
The `verification_checksum` is a BLAKE3 hash of all `(entity_id, type_id, score_bits)` tuples from the live ledger for `community_id`, sorted for determinism.
## TidalDb Integration
New fields on `TidalDb`:
- `purge_job_queue: Arc<Mutex<PurgeJobQueue>>`
- `rematerialization_metrics: Arc<RematerializationMetrics>`
- `rematerialization_handle: Mutex<Option<RematerializationHandle>>`
Engine started in `from_parts()` when `config.data_dir` is `Some` (persistent mode). Stopped in `shutdown_inner()` before WAL shutdown. Interrupted jobs are requeued before start.
## Concurrency
- Worker polls queue every 500ms.
- Exponential backoff on failure: 100ms × 2^attempt, capped at 30s.
- After `MAX_ATTEMPTS = 5` failures, job is `PermanentlyFailed`.
- `DashMap` shard locking ensures concurrent ranking queries are not blocked during the atomic swap.