@fjall/components-infrastructure 16.0.1 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/lib/config/aws/scpPreset.js +103 -17
  2. package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +103 -8
  3. package/dist/lib/patterns/aws/clickhouseDatabase.js +51 -12
  4. package/dist/lib/patterns/aws/database.d.ts +1 -1
  5. package/dist/lib/patterns/aws/database.js +1 -1
  6. package/dist/lib/patterns/aws/interfaces/domain.d.ts +1 -0
  7. package/dist/lib/patterns/aws/storage.d.ts +8 -0
  8. package/dist/lib/patterns/aws/storage.js +10 -3
  9. package/dist/lib/patterns/aws/targets/fjallTargets.d.ts +7 -5
  10. package/dist/lib/patterns/aws/targets/fjallTargets.js +9 -6
  11. package/dist/lib/patterns/aws/targets/targetResolution.d.ts +8 -5
  12. package/dist/lib/patterns/aws/targets/targetResolution.js +11 -8
  13. package/dist/lib/resources/aws/database/clickhouseBackupScript.d.ts +103 -0
  14. package/dist/lib/resources/aws/database/clickhouseBackupScript.js +123 -0
  15. package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +132 -20
  16. package/dist/lib/resources/aws/database/clickhouseConstants.js +126 -15
  17. package/dist/lib/resources/aws/database/clickhouseSchemas.d.ts +33 -1
  18. package/dist/lib/resources/aws/database/clickhouseSchemas.js +31 -0
  19. package/dist/lib/resources/aws/database/clickhouseStorage.d.ts +58 -0
  20. package/dist/lib/resources/aws/database/clickhouseStorage.js +87 -0
  21. package/dist/lib/resources/aws/database/clickhouseTuning.d.ts +5 -6
  22. package/dist/lib/resources/aws/database/clickhouseTuning.js +39 -16
  23. package/dist/lib/resources/aws/database/clickhouseUserData.d.ts +21 -1
  24. package/dist/lib/resources/aws/database/clickhouseUserData.js +22 -6
  25. package/dist/lib/resources/aws/database/clickhouseXmlRenderer.js +22 -1
  26. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.d.ts +54 -5
  27. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.js +106 -10
  28. package/dist/lib/resources/aws/storage/s3.d.ts +19 -7
  29. package/dist/lib/resources/aws/storage/s3.js +15 -3
  30. package/package.json +3 -3
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The backup task's shell body: take the backup, then prove it restores.
3
+ *
4
+ * `BACKUP DATABASE … TO S3(…)` returning `BACKUP_CREATED` means ClickHouse
5
+ * finished writing and the statement did not raise. It does NOT mean the
6
+ * bytes it wrote can be read back — and the gap between those two is the
7
+ * whole reason a backup exists. So the task restores what it just wrote,
8
+ * into a scratch database, and says so in its own log line.
9
+ *
10
+ * The restore is a FULL one, deliberately. `SETTINGS structure_only = 1`
11
+ * looks like the cheap version of this check and is not a version of it at
12
+ * all: on 26.3.17.56 a structure-only restore of a backup whose entire
13
+ * `data/` tree had been deleted returned `RESTORED` and exit 0, because it
14
+ * reads `metadata/` and never opens a part. A verification that passes on a
15
+ * backup with no data in it is worse than no verification — it converts an
16
+ * unrestorable backup into a green signal. The full restore fails that same
17
+ * case loudly (`STD_EXCEPTION`, missing `checksums.txt`), which is the
18
+ * behaviour the alarm is built on.
19
+ */
20
+ export interface ClickHouseBackupScriptParams {
21
+ /** Fully-formed `clickhouse-client …` invocation, minus `--query`. */
22
+ readonly client: string;
23
+ /** Database the backup covers, and the restore compares against. */
24
+ readonly databaseName: string;
25
+ /** `https://<bucket>.s3.<region>.amazonaws.com/backup/` — trailing slash. */
26
+ readonly backupDestUrl: string;
27
+ /** Critical free-space floor in GiB; the verify gate keeps twice this
28
+ * clear so the check can never be the thing that pages. */
29
+ readonly diskFreeCriticalGib: number;
30
+ /** Declared data-volume size in GiB; bounds the verify restore's timeout
31
+ * via `clickHouseBackupVerifyTimeoutSeconds`. */
32
+ readonly storageGb: number;
33
+ /** When false, the script takes the backup and stops — no restore verify.
34
+ * The caller (`ClickHouseDatabase`) also withholds the verify alarms, so
35
+ * the two halves of the contract disable together. */
36
+ readonly verify: boolean;
37
+ }
38
+ /**
39
+ * Builds the backup + restore-verify script.
40
+ *
41
+ * Statement order carries decisions that are not obvious from reading it
42
+ * forward:
43
+ *
44
+ * 1. **The scratch database is dropped FIRST, not only last.** A restore
45
+ * onto an existing database of that name succeeds silently — verified on
46
+ * 26.3.17.56 — so a run that died between RESTORE and DROP would leave
47
+ * tables behind that satisfy the next run's table-set comparison no
48
+ * matter what the new backup contains. Dropping on the way in also makes
49
+ * the free-space reading below honest, since a leftover scratch copy is
50
+ * occupying the disk being measured.
51
+ *
52
+ * 2. **The live row count is sampled BEFORE the backup, and it arms the
53
+ * zero-rows check.** A restore of a genuinely empty database restores
54
+ * zero rows and is CORRECT; failing it would page FAILED on every fresh
55
+ * stack from night one. So `restored_rows=0` is a failure only when the
56
+ * source had rows at backup time. A failed source sample reads as
57
+ * `unknown`, which arms the check — an unreadable source is not evidence
58
+ * the database was empty (fail closed, per the read-failure posture).
59
+ *
60
+ * 3. **The restore is gated on free space, and the gate reads the same
61
+ * quantity the critical disk alarm guards.** A full restore writes a
62
+ * second copy of the database onto the volume it is verifying. That is
63
+ * affordable at today's size and stops being affordable at some larger
64
+ * one, and the failure mode if it is left ungated is the worst kind: the
65
+ * backup check fills the disk that the backups exist to protect. When
66
+ * the copy would not leave `diskFreeCriticalGib × margin` clear, the run
67
+ * skips the restore and SAYS SO with its own marker. Silent degradation
68
+ * is not available here — a skipped verify that logged nothing would be
69
+ * indistinguishable from a passing one. The same discipline covers the
70
+ * probe itself: a failed free-space read emits the SKIPPED marker (the
71
+ * verify did not run, which is exactly what that marker means) and exits
72
+ * non-zero, rather than being the one silent path out of the script.
73
+ *
74
+ * 4. **The restore is bounded by `timeout`, sized from the volume.** A hung
75
+ * restore is the one backup failure with no signal: `BACKUP_CREATED` is
76
+ * already emitted so the heartbeat is satisfied, and the verify alarms
77
+ * count markers rather than their absence. The bound converts that
78
+ * silence into exit 124, which takes the same branch as a raised
79
+ * restore. It scales with `storageGb` because the largest restore the
80
+ * free-space gate admits scales with the volume — a flat bound would
81
+ * misreport every healthy verify as FAILED past the size where the
82
+ * restore outgrows it.
83
+ *
84
+ * 5. **The comparison is on the table SET, never on row counts — and it
85
+ * ignores materialised-view inner tables.** The database keeps ingesting
86
+ * during and after the backup, so the restored copy is a point-in-time
87
+ * from somewhere inside the backup window and can never be expected to
88
+ * match live counts. Rows are checked only against zero (armed per 2).
89
+ * MV inner tables (`.inner_id.<uuid>` on Atomic databases) are excluded
90
+ * from both sides of the set comparison: `RESTORE … AS <scratch>` on the
91
+ * same server assigns NEW UUIDs, so the live database's inner names can
92
+ * never appear in the scratch database and a raw name comparison reports
93
+ * every implicit MV as missing — a FAILED page against a good backup.
94
+ * The MVs themselves (and their data, via the restored inner tables)
95
+ * remain fully compared.
96
+ *
97
+ * Every statement carries an explicit `|| …` failure branch rather than
98
+ * relying on `set -e`. The TLS preamble this script is concatenated onto
99
+ * sets `set -eu`, but ONLY when TLS is active — so a script depending on
100
+ * `set -e` would abort on the first failure with TLS on and blunder past it
101
+ * with TLS off. Explicit branches behave identically either way.
102
+ */
103
+ export declare function buildClickHouseBackupScript(params: ClickHouseBackupScriptParams): string;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The backup task's shell body: take the backup, then prove it restores.
3
+ *
4
+ * `BACKUP DATABASE … TO S3(…)` returning `BACKUP_CREATED` means ClickHouse
5
+ * finished writing and the statement did not raise. It does NOT mean the
6
+ * bytes it wrote can be read back — and the gap between those two is the
7
+ * whole reason a backup exists. So the task restores what it just wrote,
8
+ * into a scratch database, and says so in its own log line.
9
+ *
10
+ * The restore is a FULL one, deliberately. `SETTINGS structure_only = 1`
11
+ * looks like the cheap version of this check and is not a version of it at
12
+ * all: on 26.3.17.56 a structure-only restore of a backup whose entire
13
+ * `data/` tree had been deleted returned `RESTORED` and exit 0, because it
14
+ * reads `metadata/` and never opens a part. A verification that passes on a
15
+ * backup with no data in it is worse than no verification — it converts an
16
+ * unrestorable backup into a green signal. The full restore fails that same
17
+ * case loudly (`STD_EXCEPTION`, missing `checksums.txt`), which is the
18
+ * behaviour the alarm is built on.
19
+ */
20
+ import { CLICKHOUSE_BACKUP_SCRATCH_DATABASE, CLICKHOUSE_BACKUP_VERIFY_FREE_SPACE_MARGIN, CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER, CLICKHOUSE_BACKUP_VERIFY_OK_MARKER, CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER, clickHouseBackupVerifyTimeoutSeconds } from "./clickhouseConstants.js";
21
+ const BYTES_PER_GIB = 1024 ** 3;
22
+ /**
23
+ * Builds the backup + restore-verify script.
24
+ *
25
+ * Statement order carries decisions that are not obvious from reading it
26
+ * forward:
27
+ *
28
+ * 1. **The scratch database is dropped FIRST, not only last.** A restore
29
+ * onto an existing database of that name succeeds silently — verified on
30
+ * 26.3.17.56 — so a run that died between RESTORE and DROP would leave
31
+ * tables behind that satisfy the next run's table-set comparison no
32
+ * matter what the new backup contains. Dropping on the way in also makes
33
+ * the free-space reading below honest, since a leftover scratch copy is
34
+ * occupying the disk being measured.
35
+ *
36
+ * 2. **The live row count is sampled BEFORE the backup, and it arms the
37
+ * zero-rows check.** A restore of a genuinely empty database restores
38
+ * zero rows and is CORRECT; failing it would page FAILED on every fresh
39
+ * stack from night one. So `restored_rows=0` is a failure only when the
40
+ * source had rows at backup time. A failed source sample reads as
41
+ * `unknown`, which arms the check — an unreadable source is not evidence
42
+ * the database was empty (fail closed, per the read-failure posture).
43
+ *
44
+ * 3. **The restore is gated on free space, and the gate reads the same
45
+ * quantity the critical disk alarm guards.** A full restore writes a
46
+ * second copy of the database onto the volume it is verifying. That is
47
+ * affordable at today's size and stops being affordable at some larger
48
+ * one, and the failure mode if it is left ungated is the worst kind: the
49
+ * backup check fills the disk that the backups exist to protect. When
50
+ * the copy would not leave `diskFreeCriticalGib × margin` clear, the run
51
+ * skips the restore and SAYS SO with its own marker. Silent degradation
52
+ * is not available here — a skipped verify that logged nothing would be
53
+ * indistinguishable from a passing one. The same discipline covers the
54
+ * probe itself: a failed free-space read emits the SKIPPED marker (the
55
+ * verify did not run, which is exactly what that marker means) and exits
56
+ * non-zero, rather than being the one silent path out of the script.
57
+ *
58
+ * 4. **The restore is bounded by `timeout`, sized from the volume.** A hung
59
+ * restore is the one backup failure with no signal: `BACKUP_CREATED` is
60
+ * already emitted so the heartbeat is satisfied, and the verify alarms
61
+ * count markers rather than their absence. The bound converts that
62
+ * silence into exit 124, which takes the same branch as a raised
63
+ * restore. It scales with `storageGb` because the largest restore the
64
+ * free-space gate admits scales with the volume — a flat bound would
65
+ * misreport every healthy verify as FAILED past the size where the
66
+ * restore outgrows it.
67
+ *
68
+ * 5. **The comparison is on the table SET, never on row counts — and it
69
+ * ignores materialised-view inner tables.** The database keeps ingesting
70
+ * during and after the backup, so the restored copy is a point-in-time
71
+ * from somewhere inside the backup window and can never be expected to
72
+ * match live counts. Rows are checked only against zero (armed per 2).
73
+ * MV inner tables (`.inner_id.<uuid>` on Atomic databases) are excluded
74
+ * from both sides of the set comparison: `RESTORE … AS <scratch>` on the
75
+ * same server assigns NEW UUIDs, so the live database's inner names can
76
+ * never appear in the scratch database and a raw name comparison reports
77
+ * every implicit MV as missing — a FAILED page against a good backup.
78
+ * The MVs themselves (and their data, via the restored inner tables)
79
+ * remain fully compared.
80
+ *
81
+ * Every statement carries an explicit `|| …` failure branch rather than
82
+ * relying on `set -e`. The TLS preamble this script is concatenated onto
83
+ * sets `set -eu`, but ONLY when TLS is active — so a script depending on
84
+ * `set -e` would abort on the first failure with TLS on and blunder past it
85
+ * with TLS off. Explicit branches behave identically either way.
86
+ */
87
+ export function buildClickHouseBackupScript(params) {
88
+ const { client, databaseName, backupDestUrl, diskFreeCriticalGib, storageGb, verify } = params;
89
+ const backupStatements = [
90
+ "STAMP=$(date +%Y%m%d-%H%M%S)",
91
+ `DEST="${backupDestUrl}backup-$STAMP/"`,
92
+ `${client} --query "BACKUP DATABASE ${databaseName} TO S3('$DEST')" || exit 1`
93
+ ];
94
+ if (!verify) {
95
+ return backupStatements.join("; ");
96
+ }
97
+ const scratch = CLICKHOUSE_BACKUP_SCRATCH_DATABASE;
98
+ const requiredFreeBytes = Math.round(diskFreeCriticalGib *
99
+ BYTES_PER_GIB *
100
+ CLICKHOUSE_BACKUP_VERIFY_FREE_SPACE_MARGIN);
101
+ const verifyTimeoutSeconds = clickHouseBackupVerifyTimeoutSeconds(storageGb);
102
+ const dropScratch = `${client} --query "DROP DATABASE IF EXISTS ${scratch} SYNC"`;
103
+ const sourceRowsQuery = `SELECT sum(rows) FROM system.parts WHERE database = '${databaseName}' AND active`;
104
+ const fitVerdictQuery = `SELECT if((SELECT free_space FROM system.disks WHERE name = 'default')` +
105
+ ` - (SELECT sum(bytes_on_disk) FROM system.parts WHERE database = '${databaseName}' AND active)` +
106
+ ` >= ${requiredFreeBytes}, 'FITS', 'SKIP')`;
107
+ const missingTablesQuery = `SELECT count() FROM (SELECT name FROM system.tables WHERE database = '${databaseName}' AND name NOT LIKE '.inner%'` +
108
+ ` EXCEPT SELECT name FROM system.tables WHERE database = '${scratch}' AND name NOT LIKE '.inner%')`;
109
+ const restoredRowsQuery = `SELECT sum(rows) FROM system.parts WHERE database = '${scratch}' AND active`;
110
+ return [
111
+ `${dropScratch} || exit 1`,
112
+ `SRCROWS=$(${client} --query "${sourceRowsQuery}") || SRCROWS=unknown`,
113
+ ...backupStatements,
114
+ `FIT=$(${client} --query "${fitVerdictQuery}") || { echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST free-space probe failed — verify did not run"; exit 1; }`,
115
+ `if [ "$FIT" != "FITS" ]; then echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST restore would not leave ${requiredFreeBytes} bytes free"; exit 0; fi`,
116
+ `timeout ${verifyTimeoutSeconds} ${client} --query "RESTORE DATABASE ${databaseName} AS ${scratch} FROM S3('$DEST')" || { echo "${CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER} $DEST restore raised or exceeded ${verifyTimeoutSeconds}s"; ${dropScratch} || true; exit 1; }`,
117
+ `MISSING=$(${client} --query "${missingTablesQuery}") || MISSING=unknown`,
118
+ `ROWS=$(${client} --query "${restoredRowsQuery}") || ROWS=0`,
119
+ `${dropScratch} || true`,
120
+ `if [ "$MISSING" != "0" ] || { [ "$ROWS" = "0" ] && [ "$SRCROWS" != "0" ]; }; then echo "${CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER} $DEST missing_tables=$MISSING restored_rows=$ROWS source_rows=$SRCROWS"; exit 1; fi`,
121
+ `echo "${CLICKHOUSE_BACKUP_VERIFY_OK_MARKER} $DEST restored_rows=$ROWS source_rows=$SRCROWS"`
122
+ ].join("; ");
123
+ }
@@ -1,3 +1,4 @@
1
+ import type { ClickHouseInstanceType } from "@fjall/util/clickhouse";
1
2
  /** Database name created at ClickHouse bootstrap; consumed by BACKUP DATABASE,
2
3
  * OPTIMIZE TABLE, and the DatabaseName CfnOutput so all four sites share one source. */
3
4
  export declare const CLICKHOUSE_DATABASE_NAME = "analytics";
@@ -42,28 +43,67 @@ export declare const DEFAULT_CLICKHOUSE_INSTANCE_TYPE = "m7g.medium";
42
43
  * that pulls once per launch. Upstream CH CI runs its full perf + stress
43
44
  * matrix on the Ubuntu build; Alpine is community-tier coverage. */
44
45
  export declare const CLICKHOUSE_IMAGE = "docker.io/clickhouse/clickhouse-server:26.3.17.56";
46
+ /** Absolute floor of the disk-critical alarm, in GiB.
47
+ *
48
+ * A pure percentage cannot express "about to break": 85 % used is 12 GiB
49
+ * free on an 80 GiB volume and 600 GiB free on a 4 TiB one, and only one of
50
+ * those is an incident. But a pure constant cannot either, because the other
51
+ * half of the failure scales with the data — ClickHouse will not select a
52
+ * merge whose source parts need more than the free space allows, and parts
53
+ * grow with the table. So the default floor is the LARGER of this constant
54
+ * and `CLICKHOUSE_DISK_FREE_CRITICAL_SHARE` of the volume: the constant
55
+ * guards response time on a small disk, the share guards merge headroom on a
56
+ * large one. See `resolveClickHouseStorage`.
57
+ *
58
+ * 20 GiB is days of ingest headroom at the observed rate — enough lead time
59
+ * to grow the volume, which takes an instance restart and cannot be done in
60
+ * minutes. */
61
+ export declare const CLICKHOUSE_DISK_FREE_CRITICAL_GIB = 20;
62
+ /** Share of the volume the disk-critical floor rises to on larger disks.
63
+ * At 5 %, a 4 TiB volume pages with 200 GiB free — roughly twice a large
64
+ * part, so merges still have room to complete while the operator responds.
65
+ * The two halves cross over at 400 GiB, the default volume size. */
66
+ export declare const CLICKHOUSE_DISK_FREE_CRITICAL_SHARE = 0.05;
45
67
  /** EBS volume configuration.
46
68
  *
47
- * Sized for the 30-organisation baseline the 2026-08 capacity review set,
48
- * not for today's tenant count: the 80 GiB the cluster launched on binds at
49
- * roughly 20-25 organisations once `log_events` and `application_metrics`
50
- * carry a full retention window each, which is BELOW that baseline. 400 GiB
51
- * buys the headroom to reach it without a second resize, and gp3 storage is
52
- * cheap relative to an out-of-disk ClickHouse (merges stop, inserts start
53
- * failing, and the recovery is a restore rather than a resize).
54
- *
55
- * Raising this alone is not enough `buildClickHouseUserData` must grow the
56
- * ext4 filesystem to match, because ModifyVolume resizes the block device
57
- * and nothing else. The pair moves together; see the `resize2fs` step there.
58
- * Applying an increase to a RUNNING cluster therefore needs the instance to
59
- * relaunch (user data runs at boot), and EBS permits one modification per
60
- * volume per 6 hours.
61
- *
62
- * IOPS and throughput stay at the gp3 baseline: they are size-independent on
63
- * gp3, and the workload is merge- and scan-bound rather than IOPS-starved. */
69
+ * `CLICKHOUSE_EBS_VOLUME_SIZE_GB` is the DEFAULT for
70
+ * `ClickHouseDatabaseProps.storageGb`, not a fixed size. 400 GiB is sized
71
+ * for a mid-size multi-tenant analytics workload order of tens of tenant
72
+ * organisations each carrying full `log_events` / `application_metrics`
73
+ * retention windows with enough headroom that the first resize is a
74
+ * deliberate capacity decision rather than an early surprise. (The
75
+ * reference deployment's history is the evidence: 80 GiB bound at roughly
76
+ * two-thirds of that scale.) gp3 storage is cheap relative to an
77
+ * out-of-disk ClickHouse merges stop, inserts start failing, and the
78
+ * recovery is a restore rather than a resize. Workloads outside that
79
+ * profile set `storageGb` directly, exactly as they would size an RDS
80
+ * volume.
81
+ *
82
+ * Raising the size alone is not enough — `buildClickHouseUserData` must grow
83
+ * the ext4 filesystem to match, because ModifyVolume resizes the block
84
+ * device and nothing else. The pair moves together: the declared size is
85
+ * embedded in the user data, so a `storageGb` change versions the launch
86
+ * template and triggers the instance refresh whose boot runs `resize2fs`.
87
+ * EBS permits one modification per volume per 6 hours.
88
+ *
89
+ * IOPS and throughput default to the gp3 baseline — the figures the volume
90
+ * price already includes — and are the construct's `iops` / `throughputMbps`
91
+ * props, the way RDS exposes provisioned IOPS and throughput on gp3. They are
92
+ * size-independent on gp3 and the default workload is merge- and scan-bound
93
+ * rather than IOPS-starved, so the baseline is the right default; a caller
94
+ * whose scans are bandwidth-bound raises them, within the bounds and ratios
95
+ * `resolveClickHouseVolumePerformance` enforces. */
64
96
  export declare const CLICKHOUSE_EBS_VOLUME_SIZE_GB = 400;
65
97
  export declare const CLICKHOUSE_EBS_IOPS = 3000;
66
98
  export declare const CLICKHOUSE_EBS_THROUGHPUT_MBPS = 125;
99
+ /** Volume and backup-retention bounds. Re-exported, NOT redeclared:
100
+ * `@fjall/generator` enforces the same numbers when it scaffolds or rewrites
101
+ * an `infrastructure.ts` declaration, and a second copy here is exactly the
102
+ * drift `backupRetentionDays` shipped for as long as it borrowed the RDS
103
+ * schema (generator 35 vs construct 3650). One source, two enforcement
104
+ * sites. */
105
+ export { CLICKHOUSE_MIN_STORAGE_GB, CLICKHOUSE_MAX_STORAGE_GB, CLICKHOUSE_MIN_IOPS, CLICKHOUSE_MAX_IOPS, CLICKHOUSE_MAX_IOPS_PER_GB, CLICKHOUSE_MIN_THROUGHPUT_MBPS, CLICKHOUSE_MAX_THROUGHPUT_MBPS, CLICKHOUSE_IOPS_PER_THROUGHPUT_MBPS, CLICKHOUSE_MIN_BACKUP_RETENTION_DAYS, CLICKHOUSE_MAX_BACKUP_RETENTION_DAYS, CLICKHOUSE_INSTANCE_TYPES } from "@fjall/util/clickhouse";
106
+ export type { ClickHouseInstanceType } from "@fjall/util/clickhouse";
67
107
  /** Host memory reserved from the ClickHouse container: kernel + ECS agent +
68
108
  * the host-metrics timer. The ECS agent advertises MemTotal minus
69
109
  * CLICKHOUSE_ECS_RESERVED_MEMORY_MIB, and MemTotal itself runs below the
@@ -81,13 +121,25 @@ export declare const CLICKHOUSE_ECS_RESERVED_MEMORY_MIB = 256;
81
121
  /** Hardware spec of every instance type the ClickHouse construct supports.
82
122
  * Values are the AWS nominal figures. Single source of truth for BOTH the
83
123
  * container memory limit (`clickHouseTaskMemoryMiB`) and the derived server
84
- * tuning + default profiles (`clickhouseTuning.ts`) — adding a type here is
85
- * the only step needed to support it end to end. */
124
+ * tuning + default profiles (`clickhouseTuning.ts`).
125
+ *
126
+ * Keyed on `ClickHouseInstanceType` — the supported-type vocabulary lives in
127
+ * `@fjall/util/clickhouse` so the generator can validate `instanceType` at
128
+ * scaffold time against the same list. The `Record` key type makes the
129
+ * parity two-way at compile time: a vocabulary entry without a spec row is a
130
+ * missing-property error, a spec row outside the vocabulary is an
131
+ * excess-property error. Supporting a new type = add it to the tuple there
132
+ * and the spec row here; everything else derives. */
86
133
  export interface ClickHouseInstanceSpec {
87
134
  vcpus: number;
88
135
  memoryGib: number;
89
136
  }
90
- export declare const CLICKHOUSE_INSTANCE_SPECS: Record<string, ClickHouseInstanceSpec>;
137
+ export declare const CLICKHOUSE_INSTANCE_SPECS: Record<ClickHouseInstanceType, ClickHouseInstanceSpec>;
138
+ /** Guarded string-keyed lookup into `CLICKHOUSE_INSTANCE_SPECS` for
139
+ * caller-supplied types (props carry `string`); `undefined` for unknown
140
+ * types, which every caller converts into its own named synth throw. The
141
+ * exhaustively-keyed Record above stays the compile-time contract. */
142
+ export declare function clickHouseInstanceSpec(instanceType: string): ClickHouseInstanceSpec | undefined;
91
143
  /** Derived memory-only view of CLICKHOUSE_INSTANCE_SPECS. */
92
144
  export declare const CLICKHOUSE_INSTANCE_MEMORY_GIB: Record<string, number>;
93
145
  /** ECS container memory for the ClickHouse server task, derived from the
@@ -179,6 +231,7 @@ export declare const CLICKHOUSE_HOST_METRICS: {
179
231
  readonly namespace: "CWAgent";
180
232
  readonly memoryMetric: "mem_used_percent";
181
233
  readonly diskMetric: "disk_used_percent";
234
+ readonly diskFreeMetric: "disk_free_gib";
182
235
  readonly asgDimension: "AutoScalingGroupName";
183
236
  };
184
237
  /** Shared secret generation options (all ClickHouse users share the same policy). */
@@ -248,3 +301,62 @@ export declare const BACKUP_TASK_MEMORY_MIB = 256;
248
301
  export declare const BACKUP_TASK_CPU_UNITS = 256;
249
302
  /** Backup object expiration: 14 days (retains 14 daily snapshots). */
250
303
  export declare const BACKUP_RETENTION_DAYS = 14;
304
+ /** Scratch database the backup task restores into to prove the backup it just
305
+ * wrote can be read back. Dropped on the way in and on the way out; never
306
+ * queried by the application. */
307
+ export declare const CLICKHOUSE_BACKUP_SCRATCH_DATABASE = "fjall_backup_verify";
308
+ /** Multiple of the critical free-space floor the verify restore must leave
309
+ * clear to proceed.
310
+ *
311
+ * A full restore writes a second copy of the database onto the volume it is
312
+ * verifying, so the check has to be bounded by the same quantity the disk
313
+ * alarm pages on — otherwise the mechanism that protects the data is also
314
+ * the mechanism most likely to fill the disk. Twice the floor rather than
315
+ * once so a verify can never itself be the thing that pages: at exactly the
316
+ * floor the restore would land the volume on the alarm threshold and hold it
317
+ * there until the scratch database is dropped. */
318
+ export declare const CLICKHOUSE_BACKUP_VERIFY_FREE_SPACE_MARGIN = 2;
319
+ /** Backup task log markers. Distinct full tokens, because the CloudWatch
320
+ * metric filters that read them match on substrings — a shared `BACKUP_`
321
+ * stem would make the OK filter count the failures too. */
322
+ export declare const CLICKHOUSE_BACKUP_VERIFY_OK_MARKER = "BACKUP_VERIFY_OK";
323
+ export declare const CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER = "BACKUP_VERIFY_FAILED";
324
+ export declare const CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER = "BACKUP_VERIFY_SKIPPED_FOR_SIZE";
325
+ /** Bounds on the verify restore's wall-clock ceiling, in seconds.
326
+ *
327
+ * Without a ceiling a hung RESTORE is the one backup failure with NO signal
328
+ * at all: BACKUP_CREATED has already been emitted so the heartbeat is
329
+ * satisfied, and the verify alarms count markers rather than their absence,
330
+ * so a task that never finishes reports nothing. `timeout` turns that
331
+ * silence into exit 124, which takes the same branch as a raised restore
332
+ * and pages.
333
+ *
334
+ * The ceiling scales with the volume (`clickHouseBackupVerifyTimeoutSeconds`
335
+ * below) because the largest restore the free-space gate admits scales with
336
+ * it: on the default 400 GiB the floor of one hour already covers it, but a
337
+ * multi-TiB volume can legitimately need hours, and a fixed hour would page
338
+ * FAILED on every healthy verify past the size where the restore outgrows
339
+ * it. The 20-hour cap keeps a daily-scheduled verify from overlapping its
340
+ * successor — two concurrent runs share the scratch database destructively,
341
+ * so the bound must land inside the schedule interval. */
342
+ export declare const CLICKHOUSE_BACKUP_VERIFY_MIN_TIMEOUT_SECONDS = 3600;
343
+ export declare const CLICKHOUSE_BACKUP_VERIFY_MAX_TIMEOUT_SECONDS = 72000;
344
+ /** Verify-restore timeout for a given data-volume size.
345
+ *
346
+ * Worst case admitted by the free-space gate is a database of roughly half
347
+ * the volume (it must fit beside itself). Writing that back at the gp3
348
+ * baseline throughput (`CLICKHOUSE_EBS_THROUGHPUT_MBPS`) bounds the healthy
349
+ * duration; doubled for read + merge overhead, then clamped to the
350
+ * floor/cap pair above. At the 400 GiB default this resolves to the
351
+ * one-hour floor, so the default behaviour is unchanged from the flat bound
352
+ * it replaced.
353
+ *
354
+ * Deliberately the BASELINE throughput, not the provisioned `throughputMbps`
355
+ * prop. The volume's provisioned figure is an upper bound the instance may
356
+ * not reach — an m7g.medium's EBS bandwidth sits under the gp3 baseline, and
357
+ * no default-tier host reaches a provisioned 1,000 MiB/s — so a bound that
358
+ * tightened with provisioning would page FAILED on healthy restores on
359
+ * exactly the hosts customers start on. The baseline-derived bound is loose
360
+ * on a provisioned volume, which only delays a hung-restore page; the cap
361
+ * keeps that delay inside the schedule interval either way. */
362
+ export declare function clickHouseBackupVerifyTimeoutSeconds(storageGb: number): number;
@@ -42,28 +42,66 @@ export const DEFAULT_CLICKHOUSE_INSTANCE_TYPE = "m7g.medium";
42
42
  * that pulls once per launch. Upstream CH CI runs its full perf + stress
43
43
  * matrix on the Ubuntu build; Alpine is community-tier coverage. */
44
44
  export const CLICKHOUSE_IMAGE = "docker.io/clickhouse/clickhouse-server:26.3.17.56";
45
+ /** Absolute floor of the disk-critical alarm, in GiB.
46
+ *
47
+ * A pure percentage cannot express "about to break": 85 % used is 12 GiB
48
+ * free on an 80 GiB volume and 600 GiB free on a 4 TiB one, and only one of
49
+ * those is an incident. But a pure constant cannot either, because the other
50
+ * half of the failure scales with the data — ClickHouse will not select a
51
+ * merge whose source parts need more than the free space allows, and parts
52
+ * grow with the table. So the default floor is the LARGER of this constant
53
+ * and `CLICKHOUSE_DISK_FREE_CRITICAL_SHARE` of the volume: the constant
54
+ * guards response time on a small disk, the share guards merge headroom on a
55
+ * large one. See `resolveClickHouseStorage`.
56
+ *
57
+ * 20 GiB is days of ingest headroom at the observed rate — enough lead time
58
+ * to grow the volume, which takes an instance restart and cannot be done in
59
+ * minutes. */
60
+ export const CLICKHOUSE_DISK_FREE_CRITICAL_GIB = 20;
61
+ /** Share of the volume the disk-critical floor rises to on larger disks.
62
+ * At 5 %, a 4 TiB volume pages with 200 GiB free — roughly twice a large
63
+ * part, so merges still have room to complete while the operator responds.
64
+ * The two halves cross over at 400 GiB, the default volume size. */
65
+ export const CLICKHOUSE_DISK_FREE_CRITICAL_SHARE = 0.05;
45
66
  /** EBS volume configuration.
46
67
  *
47
- * Sized for the 30-organisation baseline the 2026-08 capacity review set,
48
- * not for today's tenant count: the 80 GiB the cluster launched on binds at
49
- * roughly 20-25 organisations once `log_events` and `application_metrics`
50
- * carry a full retention window each, which is BELOW that baseline. 400 GiB
51
- * buys the headroom to reach it without a second resize, and gp3 storage is
52
- * cheap relative to an out-of-disk ClickHouse (merges stop, inserts start
53
- * failing, and the recovery is a restore rather than a resize).
68
+ * `CLICKHOUSE_EBS_VOLUME_SIZE_GB` is the DEFAULT for
69
+ * `ClickHouseDatabaseProps.storageGb`, not a fixed size. 400 GiB is sized
70
+ * for a mid-size multi-tenant analytics workload order of tens of tenant
71
+ * organisations each carrying full `log_events` / `application_metrics`
72
+ * retention windows with enough headroom that the first resize is a
73
+ * deliberate capacity decision rather than an early surprise. (The
74
+ * reference deployment's history is the evidence: 80 GiB bound at roughly
75
+ * two-thirds of that scale.) gp3 storage is cheap relative to an
76
+ * out-of-disk ClickHouse — merges stop, inserts start failing, and the
77
+ * recovery is a restore rather than a resize. Workloads outside that
78
+ * profile set `storageGb` directly, exactly as they would size an RDS
79
+ * volume.
54
80
  *
55
- * Raising this alone is not enough — `buildClickHouseUserData` must grow the
56
- * ext4 filesystem to match, because ModifyVolume resizes the block device
57
- * and nothing else. The pair moves together; see the `resize2fs` step there.
58
- * Applying an increase to a RUNNING cluster therefore needs the instance to
59
- * relaunch (user data runs at boot), and EBS permits one modification per
60
- * volume per 6 hours.
81
+ * Raising the size alone is not enough — `buildClickHouseUserData` must grow
82
+ * the ext4 filesystem to match, because ModifyVolume resizes the block
83
+ * device and nothing else. The pair moves together: the declared size is
84
+ * embedded in the user data, so a `storageGb` change versions the launch
85
+ * template and triggers the instance refresh whose boot runs `resize2fs`.
86
+ * EBS permits one modification per volume per 6 hours.
61
87
  *
62
- * IOPS and throughput stay at the gp3 baseline: they are size-independent on
63
- * gp3, and the workload is merge- and scan-bound rather than IOPS-starved. */
88
+ * IOPS and throughput default to the gp3 baseline the figures the volume
89
+ * price already includes and are the construct's `iops` / `throughputMbps`
90
+ * props, the way RDS exposes provisioned IOPS and throughput on gp3. They are
91
+ * size-independent on gp3 and the default workload is merge- and scan-bound
92
+ * rather than IOPS-starved, so the baseline is the right default; a caller
93
+ * whose scans are bandwidth-bound raises them, within the bounds and ratios
94
+ * `resolveClickHouseVolumePerformance` enforces. */
64
95
  export const CLICKHOUSE_EBS_VOLUME_SIZE_GB = 400;
65
96
  export const CLICKHOUSE_EBS_IOPS = 3000;
66
97
  export const CLICKHOUSE_EBS_THROUGHPUT_MBPS = 125;
98
+ /** Volume and backup-retention bounds. Re-exported, NOT redeclared:
99
+ * `@fjall/generator` enforces the same numbers when it scaffolds or rewrites
100
+ * an `infrastructure.ts` declaration, and a second copy here is exactly the
101
+ * drift `backupRetentionDays` shipped for as long as it borrowed the RDS
102
+ * schema (generator 35 vs construct 3650). One source, two enforcement
103
+ * sites. */
104
+ export { CLICKHOUSE_MIN_STORAGE_GB, CLICKHOUSE_MAX_STORAGE_GB, CLICKHOUSE_MIN_IOPS, CLICKHOUSE_MAX_IOPS, CLICKHOUSE_MAX_IOPS_PER_GB, CLICKHOUSE_MIN_THROUGHPUT_MBPS, CLICKHOUSE_MAX_THROUGHPUT_MBPS, CLICKHOUSE_IOPS_PER_THROUGHPUT_MBPS, CLICKHOUSE_MIN_BACKUP_RETENTION_DAYS, CLICKHOUSE_MAX_BACKUP_RETENTION_DAYS, CLICKHOUSE_INSTANCE_TYPES } from "@fjall/util/clickhouse";
67
105
  /** Host memory reserved from the ClickHouse container: kernel + ECS agent +
68
106
  * the host-metrics timer. The ECS agent advertises MemTotal minus
69
107
  * CLICKHOUSE_ECS_RESERVED_MEMORY_MIB, and MemTotal itself runs below the
@@ -93,6 +131,15 @@ export const CLICKHOUSE_INSTANCE_SPECS = {
93
131
  "r7g.xlarge": { vcpus: 4, memoryGib: 32 },
94
132
  "r8g.xlarge": { vcpus: 4, memoryGib: 32 }
95
133
  };
134
+ /** Guarded string-keyed lookup into `CLICKHOUSE_INSTANCE_SPECS` for
135
+ * caller-supplied types (props carry `string`); `undefined` for unknown
136
+ * types, which every caller converts into its own named synth throw. The
137
+ * exhaustively-keyed Record above stays the compile-time contract. */
138
+ export function clickHouseInstanceSpec(instanceType) {
139
+ return Object.hasOwn(CLICKHOUSE_INSTANCE_SPECS, instanceType)
140
+ ? CLICKHOUSE_INSTANCE_SPECS[instanceType]
141
+ : undefined;
142
+ }
96
143
  /** Derived memory-only view of CLICKHOUSE_INSTANCE_SPECS. */
97
144
  export const CLICKHOUSE_INSTANCE_MEMORY_GIB = Object.fromEntries(Object.entries(CLICKHOUSE_INSTANCE_SPECS).map(([type, spec]) => [
98
145
  type,
@@ -210,6 +257,7 @@ export const CLICKHOUSE_HOST_METRICS = {
210
257
  namespace: "CWAgent",
211
258
  memoryMetric: "mem_used_percent",
212
259
  diskMetric: "disk_used_percent",
260
+ diskFreeMetric: "disk_free_gib",
213
261
  asgDimension: "AutoScalingGroupName"
214
262
  };
215
263
  /** Shared secret generation options (all ClickHouse users share the same policy). */
@@ -295,3 +343,66 @@ export const BACKUP_TASK_MEMORY_MIB = 256;
295
343
  export const BACKUP_TASK_CPU_UNITS = 256;
296
344
  /** Backup object expiration: 14 days (retains 14 daily snapshots). */
297
345
  export const BACKUP_RETENTION_DAYS = 14;
346
+ /** Scratch database the backup task restores into to prove the backup it just
347
+ * wrote can be read back. Dropped on the way in and on the way out; never
348
+ * queried by the application. */
349
+ export const CLICKHOUSE_BACKUP_SCRATCH_DATABASE = "fjall_backup_verify";
350
+ /** Multiple of the critical free-space floor the verify restore must leave
351
+ * clear to proceed.
352
+ *
353
+ * A full restore writes a second copy of the database onto the volume it is
354
+ * verifying, so the check has to be bounded by the same quantity the disk
355
+ * alarm pages on — otherwise the mechanism that protects the data is also
356
+ * the mechanism most likely to fill the disk. Twice the floor rather than
357
+ * once so a verify can never itself be the thing that pages: at exactly the
358
+ * floor the restore would land the volume on the alarm threshold and hold it
359
+ * there until the scratch database is dropped. */
360
+ export const CLICKHOUSE_BACKUP_VERIFY_FREE_SPACE_MARGIN = 2;
361
+ /** Backup task log markers. Distinct full tokens, because the CloudWatch
362
+ * metric filters that read them match on substrings — a shared `BACKUP_`
363
+ * stem would make the OK filter count the failures too. */
364
+ export const CLICKHOUSE_BACKUP_VERIFY_OK_MARKER = "BACKUP_VERIFY_OK";
365
+ export const CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER = "BACKUP_VERIFY_FAILED";
366
+ export const CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER = "BACKUP_VERIFY_SKIPPED_FOR_SIZE";
367
+ /** Bounds on the verify restore's wall-clock ceiling, in seconds.
368
+ *
369
+ * Without a ceiling a hung RESTORE is the one backup failure with NO signal
370
+ * at all: BACKUP_CREATED has already been emitted so the heartbeat is
371
+ * satisfied, and the verify alarms count markers rather than their absence,
372
+ * so a task that never finishes reports nothing. `timeout` turns that
373
+ * silence into exit 124, which takes the same branch as a raised restore
374
+ * and pages.
375
+ *
376
+ * The ceiling scales with the volume (`clickHouseBackupVerifyTimeoutSeconds`
377
+ * below) because the largest restore the free-space gate admits scales with
378
+ * it: on the default 400 GiB the floor of one hour already covers it, but a
379
+ * multi-TiB volume can legitimately need hours, and a fixed hour would page
380
+ * FAILED on every healthy verify past the size where the restore outgrows
381
+ * it. The 20-hour cap keeps a daily-scheduled verify from overlapping its
382
+ * successor — two concurrent runs share the scratch database destructively,
383
+ * so the bound must land inside the schedule interval. */
384
+ export const CLICKHOUSE_BACKUP_VERIFY_MIN_TIMEOUT_SECONDS = 3600;
385
+ export const CLICKHOUSE_BACKUP_VERIFY_MAX_TIMEOUT_SECONDS = 72000;
386
+ /** Verify-restore timeout for a given data-volume size.
387
+ *
388
+ * Worst case admitted by the free-space gate is a database of roughly half
389
+ * the volume (it must fit beside itself). Writing that back at the gp3
390
+ * baseline throughput (`CLICKHOUSE_EBS_THROUGHPUT_MBPS`) bounds the healthy
391
+ * duration; doubled for read + merge overhead, then clamped to the
392
+ * floor/cap pair above. At the 400 GiB default this resolves to the
393
+ * one-hour floor, so the default behaviour is unchanged from the flat bound
394
+ * it replaced.
395
+ *
396
+ * Deliberately the BASELINE throughput, not the provisioned `throughputMbps`
397
+ * prop. The volume's provisioned figure is an upper bound the instance may
398
+ * not reach — an m7g.medium's EBS bandwidth sits under the gp3 baseline, and
399
+ * no default-tier host reaches a provisioned 1,000 MiB/s — so a bound that
400
+ * tightened with provisioning would page FAILED on healthy restores on
401
+ * exactly the hosts customers start on. The baseline-derived bound is loose
402
+ * on a provisioned volume, which only delays a hung-restore page; the cap
403
+ * keeps that delay inside the schedule interval either way. */
404
+ export function clickHouseBackupVerifyTimeoutSeconds(storageGb) {
405
+ const worstCaseRestoreMib = (storageGb / 2) * 1024;
406
+ const derived = Math.ceil((worstCaseRestoreMib / CLICKHOUSE_EBS_THROUGHPUT_MBPS) * 2);
407
+ return Math.min(CLICKHOUSE_BACKUP_VERIFY_MAX_TIMEOUT_SECONDS, Math.max(CLICKHOUSE_BACKUP_VERIFY_MIN_TIMEOUT_SECONDS, derived));
408
+ }