Phase E: Soft deletes (tombstones) for compliance-grade row deletion

Implements GDPR/CCPA-compatible row-level deletion without rewriting
the underlying Parquet. Tombstone markers live beside each dataset and
are applied at query time via a DataFusion view that excludes the
deleted row_key_values.

Schema (shared::types):
- Tombstone { dataset, row_key_column, row_key_value, deleted_at,
              actor, reason }
- All tombstones for a dataset must share one row_key_column —
  enforced at write so the query-time filter remains a single
  WHERE NOT IN (...) clause

Storage (catalogd::tombstones):
- Per-dataset AppendLog at _catalog/tombstones/{dataset}/
- flush_threshold=1 + explicit flush after every append — tombstones
  are high-value, low-frequency; durability on return is the contract
- Reuses storaged::append_log infra so compaction is already wired
  (POST .../tombstones/compact will work once we expose it)

Catalog (catalogd::registry):
- add_tombstone validates dataset exists + key column compatibility
- list_tombstones for the GET endpoint
- TombstoneStore exposed via Registry::tombstones() for queryd

HTTP (catalogd::service):
- POST /catalog/datasets/by-name/{name}/tombstone
    { row_key_column, row_key_values[], actor, reason }
  Returns rows_tombstoned count + per-value failure list (207 on
  partial success).
- GET same path lists active tombstones with full audit info.

Query layer (queryd::context):
- Snapshot tombstones-by-dataset before registering tables
- Tombstoned tables: raw goes to "__raw__{name}", public "{name}"
  becomes DataFusion view with
  SELECT * FROM "__raw__{name}" WHERE CAST(col AS VARCHAR) NOT IN (...)
- CAST AS VARCHAR handles both string and integer key columns
- Untombstoned tables register as before — zero overhead

End-to-end on candidates (100K rows):
- Pick CAND-000001/2/3 (Linda/Charles/Kimberly)
- POST tombstone -> rows_tombstoned: 3
- COUNT(*) drops 100000 -> 99997
- WHERE candidate_id IN (those 3) -> 0 rows
- candidates_safe view transitively excludes them
  (Linda+Denver: __raw__candidates=159, candidates_safe=158)
- Restart: COUNT still 99997, 3 tombstones reload from disk

Reversibility: tombstones are reversible deletes, not destruction.
Power users can still query "__raw__{name}" to see deleted rows.
Phase 13 access control is what stops a non-admin from accessing
__raw__* tables.

Limits / follow-up:
- Physical compaction not yet integrated — Phase 8's compact_files
  doesn't read tombstones during merge. Tombstoned rows are still
  on disk until that integration ships.
- Phase 9 journald event emission for tombstones not wired —
  tombstone records carry their own actor+reason+timestamp so the
  audit trail is intact, but cross-referencing with the mutation
  event log would help compliance reporting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-16 09:40:48 -05:00
co-authored by Claude Opus 4.6
parent 09fd446c8d
commit d87f2ccac6
7 changed files with 332 additions and 9 deletions
+61 -8
View File
@@ -117,6 +117,19 @@ impl QueryEngine {
async fn build_context(&self) -> Result<SessionContext, String> {
let ctx = SessionContext::new();
// Phase E: snapshot tombstones by dataset before registering tables
// so we can wrap tombstoned tables in a filter view. The underlying
// base table is registered under an internal name `__raw__{dataset}`
// and the public `{dataset}` name becomes the filtered view.
let all_dataset_names: Vec<String> = self.registry.list().await
.iter().map(|d| d.name.clone()).collect();
let tombstones_by_dataset = self
.registry
.tombstones()
.all_grouped(&all_dataset_names)
.await
.unwrap_or_default();
// Federation layer 2: register every configured bucket as its own
// DataFusion ObjectStore under a distinct URL scheme. Each
// dataset's ObjectRef.bucket determines which store DataFusion
@@ -186,17 +199,57 @@ impl QueryEngine {
let table = ListingTable::try_new(config)
.map_err(|e| format!("table creation failed for {}: {e}", dataset.name))?;
// Tolerate duplicate manifest entries for the same name —
// pre-existing pipeline::ingest_file behavior creates a fresh
// dataset id on every ingest. First registration wins; later
// ones are skipped with a warning rather than failing the
// whole context build.
if let Err(e) = ctx.register_table(&dataset.name, Arc::new(table)) {
// Decide the registration name: if this dataset has any
// tombstones, the raw table gets an internal name and the
// public name becomes a filtered view.
let tombstone_entry = tombstones_by_dataset.get(&dataset.name);
let register_name = if tombstone_entry.is_some() {
format!("__raw__{}", dataset.name)
} else {
dataset.name.clone()
};
if let Err(e) = ctx.register_table(register_name.as_str(), Arc::new(table)) {
let msg = e.to_string();
if msg.contains("already exists") {
tracing::debug!("skip duplicate manifest registration: {}", dataset.name);
tracing::debug!("skip duplicate manifest registration: {}", register_name);
continue;
} else {
return Err(format!("table registration failed for {}: {}", dataset.name, msg));
return Err(format!("table registration failed for {}: {}", register_name, msg));
}
}
// If there are tombstones, register the public name as a
// filtered view that excludes tombstoned row_key_values.
if let Some((key_col, values)) = tombstone_entry {
// Build WHERE NOT IN (...) — quote values to be SQL-safe.
// For string keys this is a literal list; for integer keys
// CAST(col AS VARCHAR) makes the comparison unambiguous.
let quoted: Vec<String> = values.iter()
.map(|v| format!("'{}'", v.replace('\'', "''")))
.collect();
let sql = format!(
"SELECT * FROM \"{}\" WHERE CAST(\"{}\" AS VARCHAR) NOT IN ({})",
register_name, key_col, quoted.join(", "),
);
tracing::debug!(
"tombstone filter for '{}': {} row_keys excluded",
dataset.name, values.len(),
);
match ctx.sql(&sql).await {
Ok(df) => {
if let Err(e) = ctx.register_table(dataset.name.as_str(), df.into_view()) {
let msg = e.to_string();
if msg.contains("already exists") {
tracing::debug!("skip duplicate tombstone view: {}", dataset.name);
} else {
tracing::warn!("tombstone view registration failed for {}: {msg}", dataset.name);
}
}
}
Err(e) => {
tracing::warn!("tombstone view SQL failed for '{}': {e}", dataset.name);
}
}
}
}