5.1 KiB
Design: Retroactive Signal Purge
Module Layout
tidal/src/
├── cohort/
│ ├── contribution.rs # CohortContributionLog (new)
│ ├── purge.rs # PurgeCoordinator, PurgeManifest, PurgeId (new)
│ ├── ledger.rs # +retract(), +lambdas_for(), +remove_entry()
│ └── mod.rs # re-exports
├── signals/
│ ├── hot.rs # +subtract_contribution()
│ └── warm.rs # +subtract_bucket()
├── storage/
│ └── keys.rs # +Tag::PurgeManifest = 0x10
└── db/
├── purge.rs # TidalDb::request_community_purge(), list_purge_manifests() (new)
├── signals.rs # try_cohort_attribution() +contribution_log.push()
└── mod.rs # +contribution_log, +purge_coordinator fields
Component Design
CohortContributionLog (cohort/contribution.rs)
Bounded FIFO ring-buffer of ContributionRecord entries.
CohortContributionLog {
entries: Mutex<VecDeque<ContributionRecord>>,
cap: usize, // default: 5_000_000
eviction_count: AtomicU64,
}
push(record)— O(1) amortised. Evicts front when at capacity.drain_for(user_id, cohort) -> Vec<ContributionRecord>— O(n) drain.eviction_count() -> u64— monotonic counter for manifest.
PurgeCoordinator (cohort/purge.rs)
Orchestrates the in-memory retraction. Has no I/O responsibilities.
PurgeCoordinator {
contribution_log: Arc<CohortContributionLog>,
cohort_ledger: Arc<CohortSignalLedger>,
}
request_purge(user_id, cohort, now_ns, evicted) -> (PurgeId, PurgeManifest):
- Drain contribution log for (user_id, cohort).
- For each entry, call
cohort_ledger.retract(...). - Build
PurgeManifestwith drained entries. - Return
(rand::random::<u128>(), manifest).
Ledger Retraction (cohort/ledger.rs)
retract(cohort, entity_id, type_id, weight, timestamp_ns):
- Looks up entry; silent no-op if absent.
- Calls
entry.hot.subtract_contribution(weight, timestamp_ns, lambdas). - Calls
entry.warm.subtract_bucket(timestamp_ns).
Hot Tier Subtraction (signals/hot.rs)
subtract_contribution(weight, contribution_ts_ns, lambdas):
- Computes
dt = last_update_ns - contribution_ts_ns(clamped to 0). - For each lambda:
decayed = weight * exp(-lambda * dt). - CAS loop:
new_score = max(0.0, old_score - decayed).
Warm Tier Subtraction (signals/warm.rs)
subtract_bucket(contribution_ts_ns):
- Decrements
all_time_count(CAS, floor 0). - If timestamp within current minute: decrements minute bucket.
- If timestamp within current hour: decrements hour bucket.
Storage Key Format
Tag::PurgeManifest = 0x10
Key: encode_key(EntityId::new(user_id), Tag::PurgeManifest, &purge_id.to_be_bytes())
= [user_id: 8B BE][0x00][0x10][purge_id: 16B BE]
Value: JSON-serialized PurgeManifest
Public API (db/purge.rs)
request_community_purge(user_id, cohort_name):
require_writeable().- Get
evicted = contribution_log.eviction_count(). purge_coordinator.request_purge(...)→ (purge_id, manifest).- If storage:
storage.items_engine().put(key, manifest.to_json()). - Return
(purge_id, manifest).
list_purge_manifests(user_id):
- Scan prefix
entity_tag_prefix(user_id, PurgeManifest). - Deserialize each value via
PurgeManifest::from_json().
Data Flow
signal_with_context(signal, entity, weight, ts, user_id)
└─ try_cohort_attribution(...)
├─ cohort_ledger.record(cohort, entity, type_id, weight, ts_ns)
└─ contribution_log.push(ContributionRecord { user_id, cohort, entity, type_id, weight, ts_ns })
request_community_purge(user_id, cohort_name)
└─ purge_coordinator.request_purge(user_id, cohort_name, now_ns, eviction_count)
├─ contribution_log.drain_for(user_id, cohort_name) → entries
├─ for each entry: cohort_ledger.retract(...)
│ ├─ hot.subtract_contribution(weight, ts_ns, lambdas) [CAS loop]
│ └─ warm.subtract_bucket(ts_ns) [CAS loop]
└─ return (purge_id, PurgeManifest { entries })
└─ storage.put(encode_key(user_id, PurgeManifest, purge_id), manifest.to_json())
Design Decisions
-
No WAL event for purge — The purge manifest in storage is the durable record. WAL replay is the responsibility of
m9-purge-rematerialization. Adding a WAL event would create a dependency on the WAL format that this feature intentionally avoids. -
f32 weight in contribution log — Lossy compression (f64→f32) reduces log memory by 4B/entry. Subtraction is approximate; full correctness is deferred to WAL replay by
m9-purge-rematerialization. -
Score floors at 0.0 — CAS loops in
subtract_contributionandsubtract_bucketclamp to 0 to prevent negative scores from floating-point imprecision on the hot path. -
PurgeJobQueuefor async re-materialization —db/purge.rsalso exposessubmit_purge_job()so callers can enqueue a full WAL-replay re-materialization after the in-memory retraction.