@fjall/components-infrastructure 16.0.0 → 17.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.
@@ -29,15 +29,82 @@ export interface ClickHouseDatabaseProps {
29
29
  * taking a new type live needs a launch-template refresh + instance
30
30
  * refresh + ClickHouse restart, not a config reload.
31
31
  *
32
- * WARNING — changing this on a DEPLOYED stack REPLACES the persistent
33
- * data volume, not just the instance. The instance type is embedded in
34
- * the compute construct's logical-ID key (`getEc2ConfigKey`), so the
35
- * `AWS::EC2::Volume` beneath it gets a new logical ID: CloudFormation
36
- * snapshots-then-deletes the old volume (`RemovalPolicy.SNAPSHOT`) and
37
- * creates an EMPTY one, and the volume's `ownerLogicalId` re-attach tag
38
- * chain resets. Plan a snapshot-restore migration for live data — this
39
- * is NOT a config-only change on a stack with data. */
32
+ * Changing this on a DEPLOYED stack keeps the data. The volume's logical
33
+ * ID and its `fjall:OwnerLogicalId` re-attach tag both derive from the
34
+ * capacity ANCHOR (`resolveCapacityAnchor`, `${Aws.STACK_NAME}/${anchor}/
35
+ * PersistentDataVolume`), not from the instance type, so neither moves
36
+ * when the type does and the re-attach chain holds. Config-keyed identity
37
+ * was the pre-6.0 derivation `getEc2ConfigKey` survives only to emit
38
+ * the manifest's legacy alias.
39
+ *
40
+ * It is still not free: the type lives in the launch template and in
41
+ * user-data-baked server tuning, so it takes an instance refresh, and on
42
+ * this single-node cluster that refresh is a restart. The ASG terminates
43
+ * the instance (TERMINATING hook detaches the volume), launches a
44
+ * replacement, and the LAUNCHING hook re-attaches — ClickHouse is
45
+ * unavailable across that window. Size the change into a quiet period;
46
+ * do not plan a snapshot-restore migration for it. */
40
47
  instanceType?: string;
48
+ /**
49
+ * EBS data-volume size in GiB. Default `CLICKHOUSE_EBS_VOLUME_SIZE_GB`
50
+ * (400). Integer, between `CLICKHOUSE_MIN_STORAGE_GB` and the gp3 ceiling;
51
+ * validated against `alarms.diskFreeCriticalGib` as a pair, because a
52
+ * volume smaller than five times that floor would deploy a critical alarm
53
+ * that can never leave ALARM.
54
+ *
55
+ * GROWTH ONLY, and the growth is not instantaneous. EBS `ModifyVolume`
56
+ * refuses to shrink a volume, so a decrease fails mid-update and rolls the
57
+ * stack back; it also permits one modification per volume per 6 hours, so
58
+ * a second bump inside that window fails until the cooldown elapses.
59
+ *
60
+ * Resizing the block device is half the job — the ext4 filesystem on it
61
+ * does not follow until `resize2fs` runs, which is a step in
62
+ * `buildClickHouseUserData` and therefore runs at BOOT. The declared size
63
+ * is embedded in that user data, so changing this prop versions the launch
64
+ * template and triggers the ASG instance refresh whose boot grows the
65
+ * filesystem — the increase applies in the same deploy, by the same
66
+ * mechanism an `instanceType` change rides. On this single-node cluster
67
+ * that relaunch is a restart (the TERMINATING hook detaches the volume,
68
+ * LAUNCHING re-attaches it): the data survives; the cluster is unavailable
69
+ * across the window. Size increases into a quiet period, exactly as with
70
+ * `instanceType`.
71
+ */
72
+ storageGb?: number;
73
+ /**
74
+ * Provisioned IOPS on the gp3 data volume. Default `CLICKHOUSE_EBS_IOPS`
75
+ * (3000, the baseline the volume price includes). Integer, between
76
+ * `CLICKHOUSE_MIN_IOPS` and `CLICKHOUSE_MAX_IOPS`, and at most
77
+ * `CLICKHOUSE_MAX_IOPS_PER_GB` × `storageGb` — EBS rejects more IOPS than
78
+ * the volume is large enough to carry. Validated with `throughputMbps` as
79
+ * a pair, because throughput needs IOPS to ride on (below).
80
+ *
81
+ * The same knob RDS exposes as provisioned IOPS on gp3 storage. Applied by
82
+ * EBS `ModifyVolume` in place: no launch-template change, no instance
83
+ * relaunch, no restart — the cluster stays up. It shares the
84
+ * one-modification-per-volume-per-6-hours cooldown with `storageGb` and
85
+ * `throughputMbps`; a change to any of the three inside that window fails
86
+ * until it elapses, so move them together.
87
+ *
88
+ * Worth raising only once the host can use it: a volume's provisioned
89
+ * figure is a ceiling the instance's own EBS bandwidth may sit under, so
90
+ * pair a large value with an `instanceType` whose EBS bandwidth reaches it.
91
+ */
92
+ iops?: number;
93
+ /**
94
+ * Provisioned throughput on the gp3 data volume, in MiB/s. Default
95
+ * `CLICKHOUSE_EBS_THROUGHPUT_MBPS` (125, the baseline the volume price
96
+ * includes). Integer, between `CLICKHOUSE_MIN_THROUGHPUT_MBPS` and
97
+ * `CLICKHOUSE_MAX_THROUGHPUT_MBPS`, and needing
98
+ * `CLICKHOUSE_IOPS_PER_THROUGHPUT_MBPS` (4) IOPS per MiB/s — 1000 MiB/s
99
+ * needs `iops: 4000` or more, and the synth error names the figure.
100
+ *
101
+ * The same knob RDS exposes as storage throughput on gp3. Applied in place
102
+ * by `ModifyVolume` like `iops`, under the same shared 6-hour cooldown, and
103
+ * likewise bounded by the instance's EBS bandwidth. The restore-verify
104
+ * timeout is derived from the baseline rather than this value on purpose
105
+ * (see `clickHouseBackupVerifyTimeoutSeconds`).
106
+ */
107
+ throughputMbps?: number;
41
108
  /**
42
109
  * ECS service desired task count. Defaults to 1 (single-node, single-task
43
110
  * by design). Override to 0 via the `clickhouseDesiredCount` CDK context
@@ -57,6 +124,12 @@ export interface ClickHouseDatabaseProps {
57
124
  * - omitted/undefined: cold tier ENABLED (S3 bucket provisioned).
58
125
  * - `false`: cold tier DISABLED (single-tier hot storage on EBS only).
59
126
  * - `{ mode: "s3" }`: cold tier ENABLED (explicit).
127
+ *
128
+ * Interacts with the backup restore-verify: the verify restores onto the
129
+ * EBS volume, so a cold-heavy database can outgrow the free-space gate
130
+ * long before the hot tier fills — from that day the verify skips (and its
131
+ * alarm pages daily) with no cure but a larger `storageGb`. When that
132
+ * trade is deliberate, `backupVerify: false` is the sanctioned off-switch.
60
133
  */
61
134
  coldTier?: false | {
62
135
  mode: "s3";
@@ -85,10 +158,32 @@ export interface ClickHouseDatabaseProps {
85
158
  * schedules sparser than ~5.8 days must disable the heartbeat.
86
159
  */
87
160
  backupSchedule?: string | false;
161
+ /**
162
+ * Restore-verify on each scheduled backup. Omitted (the default): after
163
+ * `BACKUP DATABASE … TO S3` returns, the task restores what it just wrote
164
+ * into a scratch database, compares the table set, and emits a verdict
165
+ * marker the verify alarms read — the only signal that the backups are
166
+ * actually restorable. `false` disables the verify (the backup itself
167
+ * still runs and the backup-failure alarm + success heartbeat remain);
168
+ * the verify alarms are withheld with it, so disabling never reads as a
169
+ * silent green. Reach for it when the restore cost is structurally
170
+ * unaffordable on-box — a cold-heavy dataset (see `coldTier`), or a
171
+ * volume deliberately sized tight — and accept that `BACKUP_CREATED`
172
+ * becomes the only evidence a backup exists.
173
+ */
174
+ backupVerify?: false;
88
175
  /**
89
176
  * Backup-bucket lifecycle expiration in days for both current and noncurrent
90
177
  * versions. Default: `BACKUP_RETENTION_DAYS` from `clickhouseConstants.ts`.
91
178
  * Ignored when `backupBucket` is supplied (caller-owned lifecycle).
179
+ * Must exceed the `backupSchedule` interval — a retention shorter than the
180
+ * gap between runs expires each backup before its successor exists, which
181
+ * is a window with no backup at all rather than a short history.
182
+ *
183
+ * An integer between `CLICKHOUSE_MIN_BACKUP_RETENTION_DAYS` and
184
+ * `CLICKHOUSE_MAX_BACKUP_RETENTION_DAYS`. This is an S3 object lifetime and
185
+ * deliberately does NOT share the RDS 35-day ceiling, which bounds a
186
+ * different mechanism (`backupRetention` on the relational types).
92
187
  */
93
188
  backupRetentionDays?: number;
94
189
  /**
@@ -23,7 +23,10 @@ import { createClickHouseAlarms, validateClickHouseAlarmThresholds, BACKUP_HEART
23
23
  import { ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, PROFILE_NAME_PATTERN } from "../../resources/aws/database/clickhouseSchemas.js";
24
24
  import { deriveClickHouseDefaultProfiles } from "../../resources/aws/database/clickhouseTuning.js";
25
25
  import { inferAmiHardwareType } from "../../resources/aws/compute/ecsConstants.js";
26
- import { CLICKHOUSE_DATABASE_NAME, DEFAULT_CLICKHOUSE_INSTANCE_TYPE, CLICKHOUSE_IMAGE, CLICKHOUSE_EBS_VOLUME_SIZE_GB, CLICKHOUSE_EBS_IOPS, CLICKHOUSE_EBS_THROUGHPUT_MBPS, clickHouseTaskMemoryMiB, CLICKHOUSE_HTTP_PORT, CLICKHOUSE_HTTPS_PORT, CLICKHOUSE_NATIVE_PORT, CLICKHOUSE_TCP_SECURE_PORT, CLICKHOUSE_TLS_CERT_MOUNT_PATH, CLICKHOUSE_PROMETHEUS_PORT, CLICKHOUSE_DATA_MOUNT_PATH, CLICKHOUSE_SECRET_OPTIONS, CLICKHOUSE_SERVER_ROLE_TAG, CLICKHOUSE_HOST_METRICS, clickHouseUserSecretName, CLICKHOUSE_HEALTH_CHECK, CLICKHOUSE_STOP_TIMEOUT_SECONDS, CLICKHOUSE_EBS_DEVICE_NAME, CLICKHOUSE_CONFIG_SUBDIR, CLICKHOUSE_USERS_SUBDIR, userPasswordEnvName, OPTIMISE_FINAL_SCHEDULE, REPLACING_MERGE_TREE_TABLES, OPTIMISE_MV_TABLES, CLICKHOUSE_CLOUDMAP_SERVICE_NAME, CLICKHOUSE_SERVER_CONTAINER_NAME, CLICKHOUSE_SERVICE_NAME, OPTIMISE_TASK_MEMORY_MIB, OPTIMISE_TASK_CPU_UNITS, BACKUP_SCHEDULE, BACKUP_TASK_MEMORY_MIB, BACKUP_TASK_CPU_UNITS, BACKUP_RETENTION_DAYS } from "../../resources/aws/database/clickhouseConstants.js";
26
+ import { CLICKHOUSE_DATABASE_NAME, DEFAULT_CLICKHOUSE_INSTANCE_TYPE, CLICKHOUSE_IMAGE, clickHouseTaskMemoryMiB, CLICKHOUSE_HTTP_PORT, CLICKHOUSE_HTTPS_PORT, CLICKHOUSE_NATIVE_PORT, CLICKHOUSE_TCP_SECURE_PORT, CLICKHOUSE_TLS_CERT_MOUNT_PATH, CLICKHOUSE_PROMETHEUS_PORT, CLICKHOUSE_DATA_MOUNT_PATH, CLICKHOUSE_SECRET_OPTIONS, CLICKHOUSE_SERVER_ROLE_TAG, CLICKHOUSE_HOST_METRICS, clickHouseUserSecretName, CLICKHOUSE_HEALTH_CHECK, CLICKHOUSE_STOP_TIMEOUT_SECONDS, CLICKHOUSE_EBS_DEVICE_NAME, CLICKHOUSE_CONFIG_SUBDIR, CLICKHOUSE_USERS_SUBDIR, userPasswordEnvName, OPTIMISE_FINAL_SCHEDULE, REPLACING_MERGE_TREE_TABLES, OPTIMISE_MV_TABLES, CLICKHOUSE_CLOUDMAP_SERVICE_NAME, CLICKHOUSE_SERVER_CONTAINER_NAME, CLICKHOUSE_SERVICE_NAME, OPTIMISE_TASK_MEMORY_MIB, OPTIMISE_TASK_CPU_UNITS, BACKUP_SCHEDULE, BACKUP_TASK_MEMORY_MIB, BACKUP_TASK_CPU_UNITS, BACKUP_RETENTION_DAYS } from "../../resources/aws/database/clickhouseConstants.js";
27
+ import { CLICKHOUSE_MAX_BACKUP_RETENTION_DAYS, CLICKHOUSE_MIN_BACKUP_RETENTION_DAYS } from "@fjall/util/clickhouse";
28
+ import { resolveClickHouseStorage, resolveClickHouseVolumePerformance } from "../../resources/aws/database/clickhouseStorage.js";
29
+ import { buildClickHouseBackupScript } from "../../resources/aws/database/clickhouseBackupScript.js";
27
30
  import { TlsCertGenerator } from "../../resources/aws/utilities/tlsCertGenerator.js";
28
31
  import { EcsCompute } from "./computeEcs.js";
29
32
  /**
@@ -263,6 +266,7 @@ export class ClickHouseDatabase extends Construct {
263
266
  const desiredCount = resolveClickHouseDesiredCount(this.node.tryGetContext("clickhouseDesiredCount"), props.desiredCount);
264
267
  const optimiseEnabled = props.optimiseSchedule !== false;
265
268
  const backupEnabled = props.backupSchedule !== false;
269
+ const backupVerifyEnabled = props.backupVerify !== false;
266
270
  const optimiseSchedule = typeof props.optimiseSchedule === "string"
267
271
  ? props.optimiseSchedule
268
272
  : OPTIMISE_FINAL_SCHEDULE;
@@ -270,9 +274,15 @@ export class ClickHouseDatabase extends Construct {
270
274
  ? props.backupSchedule
271
275
  : BACKUP_SCHEDULE;
272
276
  const backupRetentionDays = props.backupRetentionDays ?? BACKUP_RETENTION_DAYS;
273
- if (props.backupRetentionDays !== undefined &&
274
- (props.backupRetentionDays < 1 || props.backupRetentionDays > 3650)) {
275
- throw new Error(`ClickHouseDatabase: backupRetentionDays must be between 1 and 3650; got ${props.backupRetentionDays}.`);
277
+ // Checked after defaulting, so the default is held to the same bound as a
278
+ // caller's value. Integer-ness is part of the bound rather than a separate
279
+ // nicety: the value becomes `Duration.days()` on an S3 lifecycle rule,
280
+ // which rejects a fraction far downstream with no mention of the knob that
281
+ // supplied it.
282
+ if (!Number.isInteger(backupRetentionDays) ||
283
+ backupRetentionDays < CLICKHOUSE_MIN_BACKUP_RETENTION_DAYS ||
284
+ backupRetentionDays > CLICKHOUSE_MAX_BACKUP_RETENTION_DAYS) {
285
+ throw new Error(`ClickHouseDatabase: backupRetentionDays must be an integer between ${CLICKHOUSE_MIN_BACKUP_RETENTION_DAYS} and ${CLICKHOUSE_MAX_BACKUP_RETENTION_DAYS} days; got ${backupRetentionDays}.`);
276
286
  }
277
287
  const tlsOptions = props.tls ?? {
278
288
  mode: "self-signed"
@@ -348,10 +358,29 @@ export class ClickHouseDatabase extends Construct {
348
358
  retention: RetentionDays.TWO_WEEKS
349
359
  })
350
360
  : undefined;
361
+ // Resolved BEFORE the user data is built: the declared volume size is
362
+ // embedded into the script so a storageGb change versions the launch
363
+ // template (see `dataVolumeSizeGb` on BuildClickHouseUserDataOptions).
364
+ // The restore-verify gate is bounded by the same free-space floor the
365
+ // disk alarm pages on — the check that protects the data must not be
366
+ // able to fill the volume it is protecting. Volume size and floor are
367
+ // only checkable against each other, and the floor lives on the alarms
368
+ // knob.
369
+ const alarmThresholds = typeof props.alarms === "object" ? props.alarms : {};
370
+ validateClickHouseAlarmThresholds(alarmThresholds);
371
+ const { storageGb, diskFreeCriticalGib } = resolveClickHouseStorage(props.storageGb, alarmThresholds.diskFreeCriticalGib);
372
+ // IOPS and throughput are bounded by each other and by the size just
373
+ // resolved, so they resolve second, against the validated storageGb.
374
+ const { iops, throughputMbps } = resolveClickHouseVolumePerformance({
375
+ storageGb,
376
+ iops: props.iops,
377
+ throughputMbps: props.throughputMbps
378
+ });
351
379
  const userData = UserData.custom(buildClickHouseUserData({
352
380
  backupBucketName: backupBucket.bucketName,
353
381
  backupBucketRegion: Stack.of(this).region,
354
382
  instanceType,
383
+ dataVolumeSizeGb: storageGb,
355
384
  ...(coldTierBucket !== undefined && {
356
385
  coldTier: {
357
386
  bucketName: coldTierBucket.bucketName,
@@ -405,12 +434,15 @@ export class ClickHouseDatabase extends Construct {
405
434
  // deploy-order window, or a dropped table lingering in the constants)
406
435
  // would permanently skip every optimise after it. Failures are logged and
407
436
  // the task still exits non-zero so they stay visible in the task history.
408
- const optimiseClient = `clickhouse-client --host ${clickHouseHost} --port ${nativePort} --user ${schemaAdmin.name}${sidecarTlsClientArgs}`;
437
+ // Both scheduled tasks authenticate identically; the password arrives via
438
+ // CLICKHOUSE_PASSWORD rather than argv.
439
+ const clickHouseClient = `clickhouse-client --host ${clickHouseHost} --port ${nativePort} --user ${schemaAdmin.name}${sidecarTlsClientArgs}`;
409
440
  const optimiseScript = [
410
441
  "FAILED=0",
411
- ...optimiseStatements.map((stmt) => `${optimiseClient} --query "${stmt}" || { echo "optimise failed: ${stmt}"; FAILED=1; }`),
442
+ ...optimiseStatements.map((stmt) => `${clickHouseClient} --query "${stmt}" || { echo "optimise failed: ${stmt}"; FAILED=1; }`),
412
443
  'exit "$FAILED"'
413
444
  ].join("; ");
445
+ // Resolved before the scheduled tasks because the backup task's
414
446
  const scheduledTasks = [];
415
447
  if (optimiseEnabled) {
416
448
  scheduledTasks.push({
@@ -439,7 +471,14 @@ export class ClickHouseDatabase extends Construct {
439
471
  "sh",
440
472
  "-c",
441
473
  // Password via CLICKHOUSE_PASSWORD env, not --password on argv (argv → /proc/<pid>/cmdline).
442
- `${sidecarTlsPreamble}STAMP=$(date +%Y%m%d-%H%M%S) && clickhouse-client --host ${clickHouseHost} --port ${nativePort} --user ${schemaAdmin.name}${sidecarTlsClientArgs} --query "BACKUP DATABASE ${CLICKHOUSE_DATABASE_NAME} TO S3('${backupDestUrl}weekly-$STAMP/')"`
474
+ `${sidecarTlsPreamble}${buildClickHouseBackupScript({
475
+ client: clickHouseClient,
476
+ databaseName: CLICKHOUSE_DATABASE_NAME,
477
+ backupDestUrl,
478
+ diskFreeCriticalGib,
479
+ storageGb,
480
+ verify: backupVerifyEnabled
481
+ })}`
443
482
  ],
444
483
  secrets: {
445
484
  CLICKHOUSE_PASSWORD: EcsSecret.fromSecretsManager(adminSecret.secret, "password"),
@@ -459,8 +498,6 @@ export class ClickHouseDatabase extends Construct {
459
498
  const alarmsDisabled = props.alarms === false;
460
499
  const applicationId = props.applicationId ??
461
500
  (App.hasInstance() ? App.getInstance().getName() : undefined);
462
- const alarmThresholds = typeof props.alarms === "object" ? props.alarms : {};
463
- validateClickHouseAlarmThresholds(alarmThresholds);
464
501
  const serviceLogAlarms = alarmsDisabled
465
502
  ? undefined
466
503
  : buildClickHouseLogAlarmSpecs(alarmThresholds, coldTierEnabled);
@@ -520,11 +557,11 @@ export class ClickHouseDatabase extends Construct {
520
557
  instanceMonitoring: Monitoring.BASIC,
521
558
  availabilityZones: [dataAz],
522
559
  persistentDataVolume: {
523
- sizeGb: CLICKHOUSE_EBS_VOLUME_SIZE_GB,
560
+ sizeGb: storageGb,
524
561
  deviceName: CLICKHOUSE_EBS_DEVICE_NAME,
525
562
  availabilityZone: dataAz,
526
- iops: CLICKHOUSE_EBS_IOPS,
527
- throughputMbps: CLICKHOUSE_EBS_THROUGHPUT_MBPS,
563
+ iops,
564
+ throughputMbps,
528
565
  ...(resolvedAlertsTopic !== undefined && {
529
566
  alarmTopic: resolvedAlertsTopic
530
567
  })
@@ -658,6 +695,8 @@ export class ClickHouseDatabase extends Construct {
658
695
  asgName,
659
696
  alarmTopic: resolvedAlertsTopic,
660
697
  config: alarmThresholds,
698
+ diskFreeCriticalGib,
699
+ backupVerifyEnabled,
661
700
  ...(applicationId !== undefined && { applicationId }),
662
701
  ...(backupTaskLogGroup !== undefined && { backupTaskLogGroup })
663
702
  });
@@ -332,7 +332,7 @@ export declare class RelationalDatabase extends Construct implements IRelational
332
332
  grantIamConnect(grantee: IGrantable, dbUsername: string): Grant;
333
333
  }
334
334
  export { ClickHouseDatabase, type ClickHouseDatabaseProps, type ClickHouseAlarmThresholds };
335
- export { ClickHouseDefaultProfiles, ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, type ClickHouseSchemaAdmin, type ManagedPasswordName, type ProfileSpec } from "../../resources/aws/database/clickhouseSchemas.js";
335
+ export { ClickHouseDefaultProfiles, ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, SettingConstraintSchema, type ClickHouseSchemaAdmin, type FrozenProfileSpec, type ManagedPasswordName, type ProfileSpec, type SettingConstraint } from "../../resources/aws/database/clickhouseSchemas.js";
336
336
  export { renderUsersXml } from "../../resources/aws/database/clickhouseXmlRenderer.js";
337
337
  export type { RenderUsersXmlOptions } from "../../resources/aws/database/clickhouseXmlRenderer.js";
338
338
  export type { DynamoDBKeySchema, DynamoDBGlobalSecondaryIndex, DynamoDBTableProps } from "../../resources/aws/database/dynamodb.js";
@@ -655,6 +655,6 @@ export class RelationalDatabase extends Construct {
655
655
  }
656
656
  }
657
657
  export { ClickHouseDatabase };
658
- export { ClickHouseDefaultProfiles, ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema } from "../../resources/aws/database/clickhouseSchemas.js";
658
+ export { ClickHouseDefaultProfiles, ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, SettingConstraintSchema } from "../../resources/aws/database/clickhouseSchemas.js";
659
659
  export { renderUsersXml } from "../../resources/aws/database/clickhouseXmlRenderer.js";
660
660
  export { isRelationalDatabase, isDynamoDBDatabase, isClickHouseDatabase, isAuroraDatabase, isInstanceDatabase, isGlobalAuroraDatabase } from "./interfaces/database.js";
@@ -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
+ }