Skip to content

Commit 91c1b79

Browse files
committed
_is_deleted
1 parent b57b64e commit 91c1b79

23 files changed

Lines changed: 235 additions & 149 deletions

docker/ch-config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ database = "demo"
99
user = "default"
1010
password = ""
1111
compression = "lz4"
12+
# soft_delete = true
1213

1314
[table."demo.users"]
1415
target = "demo.users"
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
-- walshadow demo destination table. Synthetic columns (_lsn, _xid,
2-
-- _op, _commit_ts) match the emitter's TablePlan; their types are
3-
-- fixed by walshadow::ch_emitter::TablePlan::build.
2+
-- _commit_ts, _is_deleted) match the emitter's TablePlan; their types
3+
-- are fixed by walshadow::ch_emitter::TablePlan::build.
44

55
CREATE DATABASE IF NOT EXISTS demo;
66

@@ -10,8 +10,8 @@ CREATE TABLE IF NOT EXISTS demo.users (
1010
email String,
1111
_lsn UInt64,
1212
_xid UInt32,
13-
_op Enum8('insert' = 1, 'update' = 2, 'delete' = 3),
14-
_commit_ts DateTime64(6, 'UTC')
13+
_commit_ts DateTime64(6, 'UTC'),
14+
_is_deleted Bool
1515
)
16-
ENGINE = ReplacingMergeTree(_lsn)
16+
ENGINE = ReplacingMergeTree(_lsn, _is_deleted)
1717
ORDER BY id;

docker/init/clickhouse/02-pgbench.sh

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
# Demo-only: pre-create the four pgbench destination tables on CH so the
33
# walshadow emitter's pinned mappings (ch-config.demo.toml) have targets
44
# to INSERT into. No-op unless WALSHADOW_DEMO_PGBENCH is set. Column
5-
# order + synthetic _lsn/_xid/_op/_commit_ts trailer mirror the emitter's
6-
# TablePlan; engine is ReplacingMergeTree(_lsn) so a row's newest LSN
7-
# wins on FINAL. Shapes match tests/pgbench_acceptance.rs.
5+
# order + synthetic _lsn/_xid/_commit_ts/_is_deleted trailer mirror the
6+
# emitter's TablePlan; engine ReplacingMergeTree(_lsn, _is_deleted) drops
7+
# deletes on FINAL. Shapes match tests/pgbench_acceptance.rs.
88

99
set -euo pipefail
1010

@@ -16,31 +16,27 @@ CREATE DATABASE IF NOT EXISTS demo;
1616
CREATE TABLE IF NOT EXISTS demo.pgbench_accounts (
1717
aid Int32, bid Int32, abalance Int32, filler String,
1818
_lsn UInt64, _xid UInt32,
19-
_op Enum8('insert' = 1, 'update' = 2, 'delete' = 3),
20-
_commit_ts DateTime64(6, 'UTC')
21-
) ENGINE = ReplacingMergeTree(_lsn) ORDER BY aid;
19+
_commit_ts DateTime64(6, 'UTC'), _is_deleted Bool
20+
) ENGINE = ReplacingMergeTree(_lsn, _is_deleted) ORDER BY aid;
2221
2322
CREATE TABLE IF NOT EXISTS demo.pgbench_branches (
2423
bid Int32, bbalance Int32, filler Nullable(String),
2524
_lsn UInt64, _xid UInt32,
26-
_op Enum8('insert' = 1, 'update' = 2, 'delete' = 3),
27-
_commit_ts DateTime64(6, 'UTC')
28-
) ENGINE = ReplacingMergeTree(_lsn) ORDER BY bid;
25+
_commit_ts DateTime64(6, 'UTC'), _is_deleted Bool
26+
) ENGINE = ReplacingMergeTree(_lsn, _is_deleted) ORDER BY bid;
2927
3028
CREATE TABLE IF NOT EXISTS demo.pgbench_tellers (
3129
tid Int32, bid Int32, tbalance Int32, filler Nullable(String),
3230
_lsn UInt64, _xid UInt32,
33-
_op Enum8('insert' = 1, 'update' = 2, 'delete' = 3),
34-
_commit_ts DateTime64(6, 'UTC')
35-
) ENGINE = ReplacingMergeTree(_lsn) ORDER BY tid;
31+
_commit_ts DateTime64(6, 'UTC'), _is_deleted Bool
32+
) ENGINE = ReplacingMergeTree(_lsn, _is_deleted) ORDER BY tid;
3633
3734
CREATE TABLE IF NOT EXISTS demo.pgbench_history (
3835
tid Int32, bid Int32, aid Int32, delta Int32,
3936
mtime DateTime64(6), filler Nullable(String),
4037
_lsn UInt64, _xid UInt32,
41-
_op Enum8('insert' = 1, 'update' = 2, 'delete' = 3),
42-
_commit_ts DateTime64(6, 'UTC')
43-
) ENGINE = ReplacingMergeTree(_lsn) ORDER BY (tid, mtime, aid);
38+
_commit_ts DateTime64(6, 'UTC'), _is_deleted Bool
39+
) ENGINE = ReplacingMergeTree(_lsn, _is_deleted) ORDER BY (tid, mtime, aid);
4440
"
4541

4642
echo "walshadow-demo: pgbench destination tables created on ClickHouse"

plans/emitter.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,8 @@ non-nullable by construction, encoded in `TableEncoder::new`:
162162
|---|---|---|
163163
| `_lsn` | `UInt64` | source commit-record LSN. `ReplacingMergeTree(_lsn)` keys dedup on this so restart-and-replay window collapses re-emitted rows to latest LSN per PK |
164164
| `_xid` | `UInt32` | source xid. Lets analytic queries group all rows from one xact, recover xact boundary CH lost when emitter serialised across tables |
165-
| `_op` | `Enum8('insert'=1,'update'=2,'delete'=3)` | row-op classification. CH-side `WHERE _op != 3` is the cheap "live rows" filter; HOT_UPDATE collapses to UPDATE (code 2), PG-internal distinction doesn't reach CH |
166165
| `_commit_ts` | `DateTime64(6, 'UTC')` | xact commit timestamp, shifted from PG's 2000-01-01 epoch to Unix via `DATETIME64_PG_EPOCH_US` |
166+
| `_is_deleted` | `Bool` | 1 on delete, else 0. `Bool` is `UInt8` underneath (1 wire byte), so it satisfies `ReplacingMergeTree`'s `is_deleted` UInt8 requirement. `ReplacingMergeTree(_lsn, _is_deleted)` second arg collapses deletes on FINAL; `WHERE _is_deleted = 0` is the cheap "live rows" filter. `soft_delete` keeps it out of the engine args to retain tombstones |
167167

168168
`_lsn` is dedup key because emitter ack lags actual CH durability by up
169169
to one flush window. On restart cursor's `emitter_ack_lsn` rewinds to

src/bin/stream.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1396,7 +1396,7 @@ async fn populate_metrics(
13961396
/// exists and is non-empty (true once this returns).
13971397
///
13981398
/// `ch_config` `Some`: bootstrap rows route through the shared insert tail
1399-
/// (synthetic INSERT `_op = 1`, `_lsn = start_lsn`, `_commit_ts = 0`).
1399+
/// (synthetic INSERT `_lsn = start_lsn`, `_commit_ts = 0`, `_is_deleted = 0`).
14001400
/// `wait_through(K)` proves every bootstrap seq durable on CH before
14011401
/// teardown, so the WAL pump resumes against a fully-shipped baseline.
14021402
/// `None`: rows drain to a metrics-only observer via `drain_backfill`.

src/ch_ddl.rs

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ pub struct DdlConfig {
7575
/// Per-namespace overrides, fallback to the global fields above when
7676
/// a namespace has none
7777
pub namespaces: HashMap<String, NamespaceMapping>,
78+
/// Keep `_is_deleted` out of `ReplacingMergeTree`'s args so deletes
79+
/// stay queryable; mirrors [`EmitterConfig::soft_delete`]
80+
pub soft_delete: bool,
7881
}
7982

8083
impl DdlConfig {
@@ -92,6 +95,7 @@ impl DdlConfig {
9295
auto_create_namespaces,
9396
target_database: cfg.database.clone(),
9497
namespaces: cfg.namespaces.clone(),
98+
soft_delete: cfg.soft_delete,
9599
}
96100
}
97101

@@ -205,7 +209,7 @@ impl DdlApplicator {
205209
.config
206210
.target_database_for(&desc.namespace_name)
207211
.to_owned();
208-
let sql = match render_create_table(desc, &target_db)? {
212+
let sql = match render_create_table(desc, &target_db, self.config.soft_delete)? {
209213
Some(s) => s,
210214
None => {
211215
self.stats.skipped += 1;
@@ -502,6 +506,7 @@ pub fn render_add_column(target: &str, name: &str, resolved: &ResolvedColumn) ->
502506
pub fn render_create_table(
503507
desc: &RelDescriptor,
504508
target_database: &str,
509+
soft_delete: bool,
505510
) -> Result<Option<String>, EmitterError> {
506511
let target = format!(
507512
"{}.{}",
@@ -539,8 +544,15 @@ pub fn render_create_table(
539544
// Synthetic columns mirror `TablePlan::build`
540545
col_defs.push("`_lsn` UInt64".into());
541546
col_defs.push("`_xid` UInt32".into());
542-
col_defs.push("`_op` Enum8('insert' = 1, 'update' = 2, 'delete' = 3)".into());
543547
col_defs.push("`_commit_ts` DateTime64(6, 'UTC')".into());
548+
col_defs.push("`_is_deleted` Bool".into());
549+
550+
// soft_delete keeps `_is_deleted` out of the engine args
551+
let engine_args = if soft_delete {
552+
"`_lsn`"
553+
} else {
554+
"`_lsn`, `_is_deleted`"
555+
};
544556

545557
// ORDER BY: prefer PK columns, else `_lsn`
546558
let order_by = if pk_attnums.is_empty() {
@@ -563,7 +575,7 @@ pub fn render_create_table(
563575
};
564576

565577
let sql = format!(
566-
"CREATE TABLE IF NOT EXISTS {target} (\n {}\n) ENGINE = ReplacingMergeTree(`_lsn`)\nORDER BY {order_by}",
578+
"CREATE TABLE IF NOT EXISTS {target} (\n {}\n) ENGINE = ReplacingMergeTree({engine_args})\nORDER BY {order_by}",
567579
col_defs.join(",\n ")
568580
);
569581
Ok(Some(sql))
@@ -638,6 +650,7 @@ mod tests {
638650
auto_create_namespaces: HashSet::new(),
639651
target_database: "default".into(),
640652
namespaces,
653+
soft_delete: false,
641654
};
642655
assert_eq!(cfg.target_database_for("analytics"), "warehouse");
643656
assert_eq!(cfg.target_database_for("logs"), "default");
@@ -658,6 +671,7 @@ mod tests {
658671
auto_create_namespaces: HashSet::new(),
659672
target_database: "default".into(),
660673
namespaces: HashMap::new(),
674+
soft_delete: false,
661675
};
662676
assert_eq!(cfg.drop_table_strategy, DropTableStrategy::Retain);
663677
let cfg = cfg.with_drop_strategy(DropTableStrategy::Drop);
@@ -741,20 +755,39 @@ mod tests {
741755
],
742756
Some(vec![1]),
743757
);
744-
let sql = render_create_table(&d, "default").unwrap().unwrap();
758+
let sql = render_create_table(&d, "default", false).unwrap().unwrap();
745759
assert!(sql.contains("CREATE TABLE IF NOT EXISTS `default`.`orders`"));
746760
assert!(sql.contains("`id` Int32"));
747761
assert!(sql.contains("`body` Nullable(String)"));
748762
assert!(sql.contains("`_lsn` UInt64"));
749-
assert!(sql.contains("`_op` Enum8"));
763+
assert!(!sql.contains("`_op`"));
764+
assert!(sql.contains("`_is_deleted` Bool"));
765+
assert!(sql.contains("ENGINE = ReplacingMergeTree(`_lsn`, `_is_deleted`)"));
766+
assert!(sql.ends_with("ORDER BY (`id`)"));
767+
}
768+
769+
#[test]
770+
fn soft_delete_keeps_is_deleted_out_of_engine_args() {
771+
let d = desc(
772+
"orders",
773+
vec![
774+
att(1, "id", INT4OID, true, None),
775+
att(2, "body", TEXTOID, false, None),
776+
],
777+
Some(vec![1]),
778+
);
779+
let sql = render_create_table(&d, "default", true).unwrap().unwrap();
780+
// Column always present; soft_delete only drops it from the engine
781+
assert!(sql.contains("`_is_deleted` Bool"));
750782
assert!(sql.contains("ENGINE = ReplacingMergeTree(`_lsn`)"));
783+
assert!(!sql.contains("ReplacingMergeTree(`_lsn`, `_is_deleted`)"));
751784
assert!(sql.ends_with("ORDER BY (`id`)"));
752785
}
753786

754787
#[test]
755788
fn render_create_table_falls_back_to_lsn_when_no_pk() {
756789
let d = desc("events", vec![att(1, "body", TEXTOID, false, None)], None);
757-
let sql = render_create_table(&d, "default").unwrap().unwrap();
790+
let sql = render_create_table(&d, "default", false).unwrap().unwrap();
758791
assert!(sql.ends_with("ORDER BY (`_lsn`)"));
759792
}
760793

@@ -763,7 +796,7 @@ mod tests {
763796
let mut a = att(1, "ship_at", TIMESTAMPTZOID, false, None);
764797
a.typmod = 3;
765798
let d = desc("t", vec![a], None);
766-
let sql = render_create_table(&d, "db").unwrap().unwrap();
799+
let sql = render_create_table(&d, "db", false).unwrap().unwrap();
767800
assert!(
768801
sql.contains("`ship_at` Nullable(DateTime64(3, 'UTC'))"),
769802
"{sql}"
@@ -792,7 +825,7 @@ mod tests {
792825
// type_bridge falls back to String for unknown OIDs today, so
793826
// this never hits None; revisit if the bridge grows strictness
794827
let d = desc("t", vec![att(1, "id", 99999, true, None)], None);
795-
let sql = render_create_table(&d, "db").unwrap();
828+
let sql = render_create_table(&d, "db", false).unwrap();
796829
assert!(sql.is_some(), "fallback path keeps the CREATE renderable");
797830
}
798831

0 commit comments

Comments
 (0)