diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 2f809be2..d0ed940e 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. */ + private ?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,36 @@ 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' + ); + } else { + // Disabling retention must actively strip any TTL a previous run + // applied; otherwise rows keep being purged despite retention being + // 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; + } + } + } } /**