@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.
@@ -1,4 +1,4 @@
1
- import type { ProfileSpec } from "./clickhouseSchemas.js";
1
+ import type { FrozenProfileSpec } from "./clickhouseSchemas.js";
2
2
  /**
3
3
  * Server-level tuning values derived from the instance type. Consumed by
4
4
  * `generateServerConfigXml` — every value here lands in the rendered
@@ -50,10 +50,9 @@ export declare const SYSTEM_LOG_TTL = "event_date + INTERVAL 14 DAY DELETE";
50
50
  export declare function deriveClickHouseServerTuning(instanceType: string): ClickHouseServerTuning;
51
51
  /**
52
52
  * Derive the four workload-class default profiles from the instance type.
53
- * Memory caps, external-spill thresholds and row-read ceilings scale with
54
- * instance memory; thread caps scale with vCPUs. At the default
55
- * m7g.medium this reproduces the historical hand-tuned values exactly
56
- * (pinned by clickhouseTuning.test.ts).
53
+ * Memory caps, external-spill thresholds and constraint ceilings scale with
54
+ * instance memory; thread caps scale with vCPUs. The full m7g.medium
55
+ * shape is pinned by clickhouseTuning.test.ts.
57
56
  *
58
57
  * `high_throughput_ingest.maxConcurrentQueriesForUser` deliberately does
59
58
  * NOT scale: it mirrors the webapp's client-side FIFO gate
@@ -61,4 +60,4 @@ export declare function deriveClickHouseServerTuning(instanceType: string): Clic
61
60
  * parity test on the webapp side enforces the pair, so the value is a
62
61
  * client contract, not an instance-capacity knob.
63
62
  */
64
- export declare function deriveClickHouseDefaultProfiles(instanceType: string): Readonly<Record<string, Readonly<ProfileSpec>>>;
63
+ export declare function deriveClickHouseDefaultProfiles(instanceType: string): Readonly<Record<string, FrozenProfileSpec>>;
@@ -1,4 +1,4 @@
1
- import { CLICKHOUSE_INSTANCE_SPECS } from "./clickhouseConstants.js";
1
+ import { clickHouseInstanceSpec, CLICKHOUSE_INSTANCE_SPECS } from "./clickhouseConstants.js";
2
2
  /** 4 GiB / 1 vCPU (m7g.medium) baseline the scaling formulas anchor to.
3
3
  * These are the proven prod values the construct shipped hand-tuned before
4
4
  * derivation existed — `deriveClickHouseServerTuning("m7g.medium")` must
@@ -47,7 +47,7 @@ export const MAX_SERVER_MEMORY_RAM_RATIO = 0.75;
47
47
  * change is a single edit. */
48
48
  export const SYSTEM_LOG_TTL = "event_date + INTERVAL 14 DAY DELETE";
49
49
  function resolveSpec(instanceType) {
50
- const spec = CLICKHOUSE_INSTANCE_SPECS[instanceType];
50
+ const spec = clickHouseInstanceSpec(instanceType);
51
51
  if (spec === undefined) {
52
52
  throw new Error(`ClickHouseDatabase: unknown instance type '${instanceType}' — cannot ` +
53
53
  "derive server tuning. Supported: " +
@@ -110,10 +110,9 @@ export function deriveClickHouseServerTuning(instanceType) {
110
110
  }
111
111
  /**
112
112
  * Derive the four workload-class default profiles from the instance type.
113
- * Memory caps, external-spill thresholds and row-read ceilings scale with
114
- * instance memory; thread caps scale with vCPUs. At the default
115
- * m7g.medium this reproduces the historical hand-tuned values exactly
116
- * (pinned by clickhouseTuning.test.ts).
113
+ * Memory caps, external-spill thresholds and constraint ceilings scale with
114
+ * instance memory; thread caps scale with vCPUs. The full m7g.medium
115
+ * shape is pinned by clickhouseTuning.test.ts.
117
116
  *
118
117
  * `high_throughput_ingest.maxConcurrentQueriesForUser` deliberately does
119
118
  * NOT scale: it mirrors the webapp's client-side FIFO gate
@@ -124,12 +123,27 @@ export function deriveClickHouseServerTuning(instanceType) {
124
123
  export function deriveClickHouseDefaultProfiles(instanceType) {
125
124
  const spec = resolveSpec(instanceType);
126
125
  const scale = memoryScale(spec);
126
+ // The four unraisable ceilings — one source for both the ingest profile's
127
+ // scalar defaults and its <constraints> block, so a retuned default can
128
+ // never drift from (or exceed) its own ceiling.
129
+ const ingestCeilings = Object.freeze({
130
+ maxThreads: spec.vcpus,
131
+ maxMemoryUsage: 1610612736 * scale, // 1.5 GiB baseline
132
+ maxMemoryUsageForUser: 2147483648 * scale, // 2 GiB baseline
133
+ maxExecutionTime: 30
134
+ });
127
135
  return Object.freeze({
128
136
  high_throughput_ingest: Object.freeze({
129
- maxThreads: spec.vcpus,
137
+ maxThreads: ingestCeilings.maxThreads,
130
138
  maxInsertThreads: spec.vcpus,
131
139
  maxConcurrentQueriesForUser: 6,
132
- logQueriesMinQueryDurationMs: 100,
140
+ // MUST stay 0: any non-zero value drops ALL ExceptionBeforeStart rows
141
+ // (quota / concurrency / limit refusals) and every QueryStart row from
142
+ // system.query_log unconditionally (executeQuery.cpp gates on elapsed
143
+ // time, which refused queries never accrue) — failed product queries
144
+ // become invisible. If log volume ever needs a lever it is
145
+ // log_queries_min_type=QUERY_FINISH, never a duration floor.
146
+ logQueriesMinQueryDurationMs: 0,
133
147
  optimizeMoveToPrewhere: true,
134
148
  useQueryConditionCache: true,
135
149
  useSkipIndexesIfFinal: true,
@@ -146,16 +160,25 @@ export function deriveClickHouseDefaultProfiles(instanceType) {
146
160
  inputFormatParallelParsing: false,
147
161
  outputFormatParallelFormatting: false,
148
162
  queryPlanOptimizeLazyMaterialization: true,
149
- maxMemoryUsage: 1610612736 * scale, // 1.5 GiB baseline
150
- maxMemoryUsageForUser: 2147483648 * scale, // 2 GiB baseline
163
+ maxMemoryUsage: ingestCeilings.maxMemoryUsage,
164
+ maxMemoryUsageForUser: ingestCeilings.maxMemoryUsageForUser,
151
165
  maxBytesBeforeExternalSort: 536870912 * scale, // 512 MiB baseline
152
166
  maxBytesBeforeExternalGroupBy: 536870912 * scale,
153
- maxExecutionTime: 30,
154
- // The ceiling an unbounded consumer query dies against (TOO_MANY_ROWS).
155
- // Scales with memory so a bigger node's tables get proportionate
156
- // headroom but consumers must still time-bound their scans; this is
157
- // a guard rail, not a budget to grow into.
158
- maxRowsToRead: 10000000 * scale
167
+ maxExecutionTime: ingestCeilings.maxExecutionTime,
168
+ // Deliberately no maxRowsToRead: the planning-time estimate refuses
169
+ // legitimate in-order LIMIT reads, and any statement can override it.
170
+ // Per-box protection is the unraisable ceilings below; per-tenant
171
+ // fairness is the org-keyed quota (webapp clickhouse-init/030).
172
+ constraints: Object.freeze({
173
+ maxExecutionTime: Object.freeze({
174
+ max: ingestCeilings.maxExecutionTime
175
+ }),
176
+ maxMemoryUsage: Object.freeze({ max: ingestCeilings.maxMemoryUsage }),
177
+ maxMemoryUsageForUser: Object.freeze({
178
+ max: ingestCeilings.maxMemoryUsageForUser
179
+ }),
180
+ maxThreads: Object.freeze({ max: ingestCeilings.maxThreads })
181
+ })
159
182
  }),
160
183
  audit_append: Object.freeze({
161
184
  maxThreads: 1,
@@ -18,6 +18,17 @@ export interface BuildClickHouseUserDataOptions {
18
18
  * as `clickHouseTaskMemoryMiB`).
19
19
  */
20
20
  instanceType: string;
21
+ /**
22
+ * Declared data-volume size in GiB, embedded into the emitted script as a
23
+ * comment line. Load-bearing, not documentation: user data is part of the
24
+ * launch template, so embedding the size means a `storageGb` change
25
+ * produces a new launch-template version — which is what triggers the ASG
26
+ * instance refresh whose boot runs the `resize2fs` below. Without it, a
27
+ * volume increase modifies the EBS device in place, nothing relaunches,
28
+ * and the grown capacity is inert until some unrelated deploy happens to
29
+ * replace the instance. Same apply mechanism `instanceType` rides.
30
+ */
31
+ dataVolumeSizeGb: number;
21
32
  /**
22
33
  * Optional S3 cold-tier configuration.
23
34
  * When omitted, single-tier hot storage on the EC2 EBS volume is used.
@@ -362,9 +362,14 @@ chmod 644 "$MOUNT_POINT/server-certs/client.xml"
362
362
  `
363
363
  : "";
364
364
  // The CWAgent-namespace metrics have no producer without this — no
365
- // CloudWatch Agent is installed. disk_used_percent feeds the host disk
366
- // alarms; mem_used_percent is dashboards-only (the CWAgent memory alarm is
367
- // retiredmemory alarming lives on ECS MemoryUtilization). The ASG name
365
+ // CloudWatch Agent is installed. disk_used_percent feeds the disk WARN
366
+ // alarm (trajectory) and disk_free_gib the disk CRITICAL alarm (imminent
367
+ // breakagemerges need free bytes, not free percent, so the paging signal
368
+ // is absolute and the warn one is not); mem_used_percent is dashboards-only
369
+ // (the CWAgent memory alarm is retired — memory alarming lives on ECS
370
+ // MemoryUtilization). Free space is read in MiB and divided rather than
371
+ // taken from `--block-size=1G`, whose integer truncation would quantise the
372
+ // metric to 1 GiB steps against a floor measured in tens. The ASG name
368
373
  // (the alarms' dimension) is unknowable at synth (LaunchTemplate → ASG is a
369
374
  // CFN cycle), so it resolves at run time: IMDS instance tags first, with a
370
375
  // describe-auto-scaling-instances fallback. AL2023 ECS AMIs ship no cron;
@@ -375,10 +380,10 @@ chmod 644 "$MOUNT_POINT/server-certs/client.xml"
375
380
  // timer never re-triggers a still-activating unit, so ONE wedged run (hung
376
381
  // IMDS or AWS call) would silence the publisher forever — every curl carries
377
382
  // --max-time and the unit a TimeoutStartSec under the 60s cadence. The disk
378
- // metric publishes only when the data volume is actually mounted: `df` on
383
+ // metrics publish only when the data volume is actually mounted: `df` on
379
384
  // an unmounted path reports the ROOT filesystem, a false-healthy number
380
385
  // that would defeat the disk-critical alarm's missing-data=BREACHING flip.
381
- const { namespace, memoryMetric, diskMetric, asgDimension } = CLICKHOUSE_HOST_METRICS;
386
+ const { namespace, memoryMetric, diskMetric, diskFreeMetric, asgDimension } = CLICKHOUSE_HOST_METRICS;
382
387
  const metricsPublisher = `
383
388
  cat > /usr/local/bin/fjall-ch-metrics.sh << 'METRICSEOF'
384
389
  #!/bin/bash
@@ -401,8 +406,10 @@ METRICS=("MetricName=${memoryMetric},Dimensions=[{Name=${asgDimension},Value=$AS
401
406
  if mountpoint -q ${CLICKHOUSE_DATA_MOUNT_PATH}; then
402
407
  DISK=$(df --output=pcent ${CLICKHOUSE_DATA_MOUNT_PATH} | tail -n 1 | tr -dc '0-9')
403
408
  METRICS+=("MetricName=${diskMetric},Dimensions=[{Name=${asgDimension},Value=$ASG}],Value=$DISK,Unit=Percent")
409
+ FREE=$(df --output=avail --block-size=1M ${CLICKHOUSE_DATA_MOUNT_PATH} | tail -n 1 | tr -dc '0-9' | awk '{printf "%.2f", $1/1024}')
410
+ METRICS+=("MetricName=${diskFreeMetric},Dimensions=[{Name=${asgDimension},Value=$ASG}],Value=$FREE,Unit=Gigabytes")
404
411
  else
405
- echo "fjall-ch-metrics: ${CLICKHOUSE_DATA_MOUNT_PATH} not mounted, skipping disk metric" >&2
412
+ echo "fjall-ch-metrics: ${CLICKHOUSE_DATA_MOUNT_PATH} not mounted, skipping disk metrics" >&2
406
413
  fi
407
414
  aws cloudwatch put-metric-data --region "$REGION" --namespace ${namespace} --metric-data "\${METRICS[@]}"
408
415
  METRICSEOF
@@ -490,6 +497,15 @@ fi
490
497
  mkdir -p "$MOUNT_POINT"
491
498
  mount "$DEVICE" "$MOUNT_POINT"
492
499
 
500
+ # The declared size below is load-bearing, and it MUST be a statement, not a
501
+ # comment — compactUserDataScript strips comments from the emitted script.
502
+ # User data is part of the launch template, so the size appearing verbatim in
503
+ # the emitted script is what turns a storageGb change into a new template
504
+ # version -> instance refresh -> the boot-time resize2fs below. Without it a
505
+ # volume increase never relaunches anything and the grown capacity stays
506
+ # unusable until an unrelated deploy replaces the instance.
507
+ DECLARED_DATA_VOLUME_GIB=${options.dataVolumeSizeGb}
508
+ #
493
509
  # Grow the filesystem to fill the volume. Raising the CDK volume size issues a
494
510
  # ModifyVolume against the block device only — ext4 keeps its old size until
495
511
  # resize2fs runs, so without this line every capacity increase is INERT and the
@@ -1,15 +1,36 @@
1
1
  function camelToSnakeCase(input) {
2
2
  return input.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase());
3
3
  }
4
+ function renderConstraintsBlock(constraints) {
5
+ const settings = Object.entries(constraints).map(([key, constraint]) => {
6
+ const xmlName = camelToSnakeCase(key);
7
+ const children = [];
8
+ if (constraint.min !== undefined) {
9
+ children.push(` <min>${String(constraint.min)}</min>`);
10
+ }
11
+ if (constraint.max !== undefined) {
12
+ children.push(` <max>${String(constraint.max)}</max>`);
13
+ }
14
+ if (constraint.readonly === true) {
15
+ children.push(` <readonly/>`);
16
+ }
17
+ return ` <${xmlName}>\n${children.join("\n")}\n </${xmlName}>`;
18
+ });
19
+ return ` <constraints>\n${settings.join("\n")}\n </constraints>`;
20
+ }
4
21
  function renderProfileSpec(spec) {
22
+ const { constraints, ...scalars } = spec;
5
23
  const lines = [];
6
- for (const [key, value] of Object.entries(spec)) {
24
+ for (const [key, value] of Object.entries(scalars)) {
7
25
  if (value === undefined)
8
26
  continue;
9
27
  const xmlName = camelToSnakeCase(key);
10
28
  const xmlValue = typeof value === "boolean" ? (value ? "1" : "0") : String(value);
11
29
  lines.push(` <${xmlName}>${xmlValue}</${xmlName}>`);
12
30
  }
31
+ if (constraints !== undefined && Object.keys(constraints).length > 0) {
32
+ lines.push(renderConstraintsBlock(constraints));
33
+ }
13
34
  return lines.join("\n");
14
35
  }
15
36
  function renderDefaultUserBlock(access) {
@@ -23,10 +23,39 @@ export interface ClickHouseAlarmThresholds {
23
23
  * CWAgent host-memory alarm is retired (its threshold meant a different
24
24
  * headroom per instance size; the timer metric remains for dashboards). */
25
25
  memoryThreshold?: number;
26
- /** ClickHouse data volume disk % used. Default 70 (warn) paired with critical at 85. */
26
+ /** ClickHouse data volume disk % used. Default 70. The TRAJECTORY signal
27
+ * "this volume is filling up, plan growth" — which is the one disk
28
+ * question a percentage answers well at any volume size. The imminent-
29
+ * breakage question is answered in GiB by `diskFreeCriticalGib`.
30
+ *
31
+ * The nightly restore-verify transiently doubles the database's on-disk
32
+ * footprint, so a database above ~35 % of the volume crosses a 70 % warn
33
+ * during every verify window. That is the knob's honest reading — the
34
+ * volume genuinely is that full while the scratch copy exists — but when
35
+ * the nightly warn becomes noise, the cures are a larger `storageGb`, a
36
+ * higher threshold here, or `backupVerify: false`; the critical floor is
37
+ * protected from the same transient by the verify gate's ×2 margin. */
27
38
  diskWarnThreshold?: number;
28
- /** ClickHouse data volume disk % used. Default 85. */
29
- diskCriticalThreshold?: number;
39
+ /** Free space on the ClickHouse data volume, in GiB, below which the
40
+ * critical alarm pages. Defaults to `defaultDiskFreeCriticalGib(storageGb)`
41
+ * — the larger of an absolute floor and a share of the volume.
42
+ *
43
+ * Absolute rather than a percentage BECAUSE the failure it guards is
44
+ * absolute: a merge needs free bytes proportional to the parts it is
45
+ * merging, not to the volume, and the server stops accepting inserts on a
46
+ * fixed reserve. A percentage critical was either redundant (on a small
47
+ * volume the free-space floor is crossed first) or noise (on a large one
48
+ * 85 % used still leaves hundreds of usable GiB) — never uniquely useful.
49
+ *
50
+ * Must stay under a fifth of `storageGb`, so the floor is a slice of the
51
+ * disk rather than most of it; `resolveClickHouseStorage` enforces the
52
+ * pair together, since neither value is checkable alone.
53
+ *
54
+ * Replaces `diskCriticalThreshold` (a percentage of the volume used). The
55
+ * rename carries a unit change, so there is no value that means the same
56
+ * thing in both — a declaration on the old key fails to compile rather
57
+ * than deploying a threshold read as the wrong quantity. */
58
+ diskFreeCriticalGib?: number;
30
59
  /** `<Error>`/`<Fatal>` server-log lines per 5 min. Default 30. */
31
60
  serverErrorsPer5Min?: number;
32
61
  /** Failed `executeQuery:` error lines per 5 min. Default 10 — absolute, not
@@ -52,7 +81,7 @@ export interface ClickHouseAlarmThresholds {
52
81
  * backup heartbeat window. The remaining fields are consumed by the pattern's
53
82
  * service-alarm and logAlarms wiring (see `ClickHouseAlarmThresholds`).
54
83
  */
55
- export type ClickHouseHostAlarmThresholds = Pick<ClickHouseAlarmThresholds, "cpuThreshold" | "diskWarnThreshold" | "diskCriticalThreshold" | "backupHeartbeatWindowHours">;
84
+ export type ClickHouseHostAlarmThresholds = Pick<ClickHouseAlarmThresholds, "cpuThreshold" | "diskWarnThreshold" | "backupHeartbeatWindowHours">;
56
85
  export declare const BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS = 26;
57
86
  /**
58
87
  * Ceiling derivation — an ORDERING bound, not CloudWatch expressibility.
@@ -97,7 +126,25 @@ export interface ClickHouseAlarmsProps {
97
126
  * Omitted when `backupSchedule: false` — no backup task, no log group.
98
127
  */
99
128
  backupTaskLogGroup?: ILogGroup;
129
+ /**
130
+ * Whether the backup task runs the restore-verify (the pattern's
131
+ * `backupVerify` prop, already resolved). `false` withholds the two verify
132
+ * alarms so a deliberately disabled verify never leaves marker-counting
133
+ * alarms armed against a script that emits no markers — the alarm half and
134
+ * the script half of the contract disable together. The backup-failure
135
+ * alarm and the success heartbeat stay: they monitor the backup itself.
136
+ */
137
+ backupVerifyEnabled?: boolean;
100
138
  config?: ClickHouseHostAlarmThresholds;
139
+ /**
140
+ * Critical free-space floor in GiB, ALREADY RESOLVED. Not read off `config`
141
+ * because its default depends on the volume size and its legality depends
142
+ * on the volume size — both of which live on the pattern, not here. The
143
+ * pattern resolves the pair once (`resolveClickHouseStorage`) and hands the
144
+ * answer down; re-deriving it here would be a second source for a number
145
+ * that must agree with the volume it guards.
146
+ */
147
+ diskFreeCriticalGib: number;
101
148
  /** Application ID for webhook-to-application alarm mapping. */
102
149
  applicationId?: string;
103
150
  }
@@ -107,7 +154,9 @@ export interface ClickHouseAlarmsProps {
107
154
  * (never installed on these hosts) but by the lightweight put-metric-data
108
155
  * timer the user-data installs (see `buildClickHouseUserData`), which publishes
109
156
  * `mem_used_percent` / `disk_used_percent` under the same names and
110
- * `AutoScalingGroupName` dimension the agent would use plus the
157
+ * `AutoScalingGroupName` dimension the agent would use, plus `disk_free_gib`,
158
+ * which has no CloudWatch Agent equivalent and exists because the disk
159
+ * question that pages must be answered in bytes rather than percent — plus the
111
160
  * backup-failure log alarm when a backup-task log group is supplied:
112
161
  *
113
162
  * - **Backup failures** — `AccessDenied` or `S3Exception` from the backup
@@ -5,7 +5,7 @@ import { Metric } from "aws-cdk-lib/aws-cloudwatch";
5
5
  import { FilterPattern, MetricFilter } from "aws-cdk-lib/aws-logs";
6
6
  import { ALARM_DEFAULTS, registerAlarm, tagAlarmsWithApplicationId, buildAlarmDescription } from "./alarmDefaults.js";
7
7
  import { METRIC_NAMESPACE, stackScopedMetricNamespace } from "./metricNamespaces.js";
8
- import { CLICKHOUSE_HOST_METRICS } from "../database/clickhouseConstants.js";
8
+ import { CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER, CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER, CLICKHOUSE_HOST_METRICS } from "../database/clickhouseConstants.js";
9
9
  export const BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS = 26;
10
10
  /**
11
11
  * Ceiling derivation — an ORDERING bound, not CloudWatch expressibility.
@@ -55,7 +55,9 @@ export function validateClickHouseAlarmThresholds(config) {
55
55
  * (never installed on these hosts) but by the lightweight put-metric-data
56
56
  * timer the user-data installs (see `buildClickHouseUserData`), which publishes
57
57
  * `mem_used_percent` / `disk_used_percent` under the same names and
58
- * `AutoScalingGroupName` dimension the agent would use plus the
58
+ * `AutoScalingGroupName` dimension the agent would use, plus `disk_free_gib`,
59
+ * which has no CloudWatch Agent equivalent and exists because the disk
60
+ * question that pages must be answered in bytes rather than percent — plus the
59
61
  * backup-failure log alarm when a backup-task log group is supplied:
60
62
  *
61
63
  * - **Backup failures** — `AccessDenied` or `S3Exception` from the backup
@@ -79,7 +81,7 @@ export function validateClickHouseAlarmThresholds(config) {
79
81
  * see (the EC2 host and the backup task).
80
82
  */
81
83
  export function createClickHouseAlarms(props) {
82
- const { scope, instanceRole, asgName, alarmTopic, backupTaskLogGroup, config = {}, applicationId } = props;
84
+ const { scope, instanceRole, asgName, alarmTopic, backupTaskLogGroup, backupVerifyEnabled = true, config = {}, diskFreeCriticalGib, applicationId } = props;
83
85
  validateClickHouseAlarmThresholds(config);
84
86
  const alarms = [];
85
87
  const snsAction = new SnsAction(alarmTopic);
@@ -116,24 +118,28 @@ export function createClickHouseAlarms(props) {
116
118
  treatMissingData: TreatMissingData.NOT_BREACHING
117
119
  });
118
120
  registerAlarm(diskWarnAlarm, snsAction, alarms);
119
- const diskCriticalThreshold = config.diskCriticalThreshold ?? 85;
120
121
  const diskCriticalAlarm = new Alarm(scope, "ClickHouseDiskCriticalAlarm", {
121
- alarmDescription: buildAlarmDescription(`ClickHouse data volume above ${diskCriticalThreshold}% usedimminent insert failures`, applicationId),
122
+ alarmDescription: buildAlarmDescription(`ClickHouse data volume under ${diskFreeCriticalGib} GiB free merges and inserts are about to fail`, applicationId),
122
123
  metric: new Metric({
123
124
  namespace: CLICKHOUSE_HOST_METRICS.namespace,
124
- metricName: CLICKHOUSE_HOST_METRICS.diskMetric,
125
+ metricName: CLICKHOUSE_HOST_METRICS.diskFreeMetric,
125
126
  dimensionsMap: { [CLICKHOUSE_HOST_METRICS.asgDimension]: asgName },
126
127
  period: Duration.minutes(5),
127
- statistic: "Average"
128
+ // Minimum, not Average: a single sample under the floor is the event,
129
+ // and averaging a dip against the surrounding minute hides exactly the
130
+ // moment a large merge claims the last of the disk.
131
+ statistic: "Minimum"
128
132
  }),
129
- threshold: diskCriticalThreshold,
133
+ threshold: diskFreeCriticalGib,
130
134
  evaluationPeriods: 2,
131
135
  datapointsToAlarm: 2,
132
- comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
136
+ comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
133
137
  // BREACHING, unlike the other host alarms: a dead metrics publisher looks
134
138
  // identical to a full disk (no datapoints either way), and this alarm's
135
139
  // whole job is to fire before inserts start failing. The 2×5min window
136
- // absorbs the boot gap on instance replacement.
140
+ // absorbs the boot gap on instance replacement — and, on the deploy that
141
+ // first introduces the metric, the instance refresh that starts
142
+ // publishing it. Expect one transient ALARM on that deploy.
137
143
  treatMissingData: TreatMissingData.BREACHING
138
144
  });
139
145
  registerAlarm(diskCriticalAlarm, snsAction, alarms);
@@ -163,6 +169,16 @@ export function createClickHouseAlarms(props) {
163
169
  treatMissingData: TreatMissingData.NOT_BREACHING
164
170
  });
165
171
  registerAlarm(backupFailureAlarm, snsAction, alarms);
172
+ if (backupVerifyEnabled) {
173
+ createBackupVerifyAlarms({
174
+ scope,
175
+ backupTaskLogGroup,
176
+ namespace: backupFailureNamespace,
177
+ snsAction,
178
+ applicationId,
179
+ alarms
180
+ });
181
+ }
166
182
  createBackupHeartbeat({
167
183
  scope,
168
184
  backupTaskLogGroup,
@@ -176,6 +192,86 @@ export function createClickHouseAlarms(props) {
176
192
  tagAlarmsWithApplicationId(alarms, applicationId);
177
193
  return alarms;
178
194
  }
195
+ /**
196
+ * Restore-verify alarms — the half of the backup story `BACKUP_CREATED`
197
+ * cannot tell.
198
+ *
199
+ * The success marker the heartbeat reads means the BACKUP statement returned.
200
+ * It says nothing about whether the bytes can be read back, and the cheap
201
+ * check that looks like it would say so does not: a `structure_only` restore
202
+ * of a backup whose entire `data/` tree had been deleted returns `RESTORED`
203
+ * on 26.3.17.56, because it reads `metadata/` and never opens a part. So the
204
+ * backup task restores in full into a scratch database and emits its own
205
+ * verdict, and these two alarms read it.
206
+ *
207
+ * - **Verify failed** (`BACKUP_VERIFY_FAILED`, Sum >= 1 per hour) — the
208
+ * backup that was just written could not be restored, or restored without
209
+ * the tables the live database has, or restored empty. This is the alarm
210
+ * that means the backups are not backups.
211
+ * - **Verify skipped for size** (`BACKUP_VERIFY_SKIPPED_FOR_SIZE`, Sum >= 1
212
+ * per DAY) — the restore would not have left the critical free-space floor
213
+ * clear, so the task declined to run it. Slower than its sibling because
214
+ * it is not an emergency on the day it first fires; it is nonetheless a
215
+ * page, because from that day on nothing is checking that the backups
216
+ * restore, and a degradation nobody is told about is the failure mode this
217
+ * whole mechanism exists to remove. The cure is a larger `storageGb` or
218
+ * verification moved off-box.
219
+ *
220
+ * Both treat missing data as NOT_BREACHING: a run that never happened is the
221
+ * heartbeat's question, not this one's, and double-paging one outage from two
222
+ * alarms buys nothing.
223
+ */
224
+ function createBackupVerifyAlarms(props) {
225
+ const { scope, backupTaskLogGroup, namespace, snsAction, applicationId, alarms } = props;
226
+ const verifyFailedMetricName = "ClickHouseBackupVerifyFailedCount";
227
+ new MetricFilter(scope, "ClickHouseBackupVerifyFailedMetricFilter", {
228
+ logGroup: backupTaskLogGroup,
229
+ metricNamespace: namespace,
230
+ metricName: verifyFailedMetricName,
231
+ filterPattern: FilterPattern.allTerms(CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER),
232
+ metricValue: "1",
233
+ defaultValue: 0
234
+ });
235
+ const verifyFailedAlarm = new Alarm(scope, "ClickHouseBackupVerifyFailedAlarm", {
236
+ alarmDescription: buildAlarmDescription("ClickHouse backup could not be restored — the backup completed but does not read back; treat the retained backups as unproven", applicationId),
237
+ metric: new Metric({
238
+ namespace,
239
+ metricName: verifyFailedMetricName,
240
+ period: Duration.hours(1),
241
+ statistic: "Sum"
242
+ }),
243
+ threshold: 1,
244
+ evaluationPeriods: 1,
245
+ datapointsToAlarm: 1,
246
+ comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
247
+ treatMissingData: TreatMissingData.NOT_BREACHING
248
+ });
249
+ registerAlarm(verifyFailedAlarm, snsAction, alarms);
250
+ const verifySkippedMetricName = "ClickHouseBackupVerifySkippedCount";
251
+ new MetricFilter(scope, "ClickHouseBackupVerifySkippedMetricFilter", {
252
+ logGroup: backupTaskLogGroup,
253
+ metricNamespace: namespace,
254
+ metricName: verifySkippedMetricName,
255
+ filterPattern: FilterPattern.allTerms(CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER),
256
+ metricValue: "1",
257
+ defaultValue: 0
258
+ });
259
+ const verifySkippedAlarm = new Alarm(scope, "ClickHouseBackupVerifySkippedAlarm", {
260
+ alarmDescription: buildAlarmDescription("ClickHouse backup restore-verify skipped — the database no longer fits a verify restore alongside itself; raise storageGb or move verification off-box", applicationId),
261
+ metric: new Metric({
262
+ namespace,
263
+ metricName: verifySkippedMetricName,
264
+ period: Duration.days(1),
265
+ statistic: "Sum"
266
+ }),
267
+ threshold: 1,
268
+ evaluationPeriods: 1,
269
+ datapointsToAlarm: 1,
270
+ comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
271
+ treatMissingData: TreatMissingData.NOT_BREACHING
272
+ });
273
+ registerAlarm(verifySkippedAlarm, snsAction, alarms);
274
+ }
179
275
  /**
180
276
  * Backup success heartbeat — the complete-but-slow half of the backup alarm
181
277
  * pair (the AccessDenied/S3Exception failure alarm above is the fast half).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "16.0.0",
3
+ "version": "17.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^16.0.0",
84
- "@fjall/util": "^16.0.0",
83
+ "@fjall/generator": "^17.0.0",
84
+ "@fjall/util": "^17.0.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {