From 554bdf77b9c934e273de8f6243f5a3d552929f53 Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:48:18 +0200 Subject: [PATCH 1/3] feat(clickhouse): add optional retention TTL on the snapshot table Add setRetention(?int $days) to the ClickHouse adapter. When set, setup() applies an idempotent `ALTER TABLE ... MODIFY TTL toDateTime(time) + INTERVAL DAY` to the snapshot (ReplacingMergeTree) table so latest-state rows older than the window are dropped by background merges. - Only the snapshot table gets a TTL; the aggregated (SummingMergeTree) table is left untouched since it backs long-term usage/billing history. - Applied as a separate ALTER (not in CREATE TABLE) so it also covers already-existing tables; MODIFY TTL is a no-op when unchanged, keeping setup() re-runnable. - materialize_ttl_after_modify = 0 defers the purge to background merges instead of an immediate mutation. - Default null preserves existing behaviour (no TTL). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Adapter/ClickHouse.php | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 2f809be2..e6d1b431 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -63,6 +63,9 @@ class ClickHouse extends SQL protected string $namespace = ''; + /** @var int|null Retention in days; when set, setup() applies a TTL to the snapshot table. Null disables TTL. */ + protected ?int $retention = null; + /** @var bool Whether to log queries for debugging */ private bool $enableQueryLogging = false; @@ -507,6 +510,35 @@ public function isSharedTables(): bool return $this->sharedTables; } + /** + * Set the retention window in days. When set, setup() applies a TTL to the + * snapshot table so rows older than the window are dropped by background + * merges. The aggregated table is left untouched. Pass null to disable + * (the default). + * + * @param int|null $days + * @return self + * @throws Exception If $days is not positive + */ + public function setRetention(?int $days): self + { + if ($days !== null && $days < 1) { + throw new Exception('Retention must be a positive number of days'); + } + $this->retention = $days; + return $this; + } + + /** + * Get the retention window in days, or null when TTL is disabled. + * + * @return int|null + */ + public function getRetention(): ?int + { + return $this->retention; + } + /** * Get the table name with namespace prefix. * Namespace is used to isolate tables for different projects/applications. @@ -1071,6 +1103,21 @@ public function setup(): void "; $this->query($createCounterTableSql); + + // Apply retention to the snapshot table only, as a separate idempotent + // ALTER. CREATE TABLE IF NOT EXISTS won't add a TTL to an existing + // table, and MODIFY TTL is a no-op when unchanged, so setup() stays + // re-runnable. The aggregated table is intentionally left untouched — + // it backs long-term usage/billing history. materialize_ttl_after_modify + // = 0 defers the purge to background merges rather than an immediate + // mutation. + if ($this->retention !== null) { + $this->query( + "ALTER TABLE {$escapedSnapshotDatabaseAndTable} " + . "MODIFY TTL toDateTime(time) + INTERVAL {$this->retention} DAY " + . 'SETTINGS materialize_ttl_after_modify = 0' + ); + } } /** From 96d1962c33e5cc7c46b0506d0abc82695f6bce17 Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:18:09 +0200 Subject: [PATCH 2/3] fix(clickhouse): remove TTL when retention is disabled; make retention private Addresses review feedback on the retention TTL: - setup() now issues ALTER TABLE ... REMOVE TTL on the null path so disabling retention actually strips a previously applied TTL, matching the documented "pass null to disable" contract. REMOVE TTL is a no-op on a table without a TTL, so setup() stays idempotent. - $retention is now private, matching the other sensitive fields and preventing subclasses from bypassing the positive-day guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Adapter/ClickHouse.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index e6d1b431..871dfc8b 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -64,7 +64,7 @@ class ClickHouse extends SQL protected string $namespace = ''; /** @var int|null Retention in days; when set, setup() applies a TTL to the snapshot table. Null disables TTL. */ - protected ?int $retention = null; + private ?int $retention = null; /** @var bool Whether to log queries for debugging */ private bool $enableQueryLogging = false; @@ -1117,6 +1117,14 @@ public function setup(): void . "MODIFY TTL toDateTime(time) + INTERVAL {$this->retention} DAY " . 'SETTINGS materialize_ttl_after_modify = 0' ); + } else { + // Disabling retention must actively strip any TTL a previous run + // applied; otherwise rows keep being purged despite retention being + // null. REMOVE TTL is a no-op on a table without a TTL, so this is + // safe to run unconditionally and keeps setup() idempotent. + $this->query( + "ALTER TABLE {$escapedSnapshotDatabaseAndTable} REMOVE TTL" + ); } } From 8ac5867d3818140eee914f21f5949661ff2a338d Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:54:39 +0200 Subject: [PATCH 3/3] fix(clickhouse): tolerate REMOVE TTL on tables without a TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REMOVE TTL is not a no-op — ClickHouse raises error 36 (BAD_ARGUMENTS, "Table doesn't have any table TTL expression, cannot remove") when the snapshot table has no TTL. That broke setup() on every fresh table when retention is disabled. Swallow that specific error to keep setup() idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Usage/Adapter/ClickHouse.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 871dfc8b..d0ed940e 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -1120,11 +1120,18 @@ public function setup(): void } else { // Disabling retention must actively strip any TTL a previous run // applied; otherwise rows keep being purged despite retention being - // null. REMOVE TTL is a no-op on a table without a TTL, so this is - // safe to run unconditionally and keeps setup() idempotent. - $this->query( - "ALTER TABLE {$escapedSnapshotDatabaseAndTable} REMOVE TTL" - ); + // null. ClickHouse errors (code 36) when REMOVE TTL runs on a table + // that has no TTL, so swallow that specific case to keep setup() + // idempotent. + try { + $this->query( + "ALTER TABLE {$escapedSnapshotDatabaseAndTable} REMOVE TTL" + ); + } catch (Exception $e) { + if (!str_contains($e->getMessage(), "doesn't have any table TTL expression")) { + throw $e; + } + } } }