@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
@@ -23,6 +23,19 @@ export type ClickHouseSchemaAdmin = z.infer<typeof ClickHouseSchemaAdminSchema>;
23
23
  */
24
24
  export declare const ManagedPasswordNameSchema: z.ZodString;
25
25
  export type ManagedPasswordName = z.infer<typeof ManagedPasswordNameSchema>;
26
+ /**
27
+ * One setting's constraint inside a profile's `<constraints>` block.
28
+ * `max`/`min` bound what a statement-level `SETTINGS` clause may set the
29
+ * value to; `readonly` forbids changing it at all. A statement that tries
30
+ * to exceed a bound fails with 452 SETTING_CONSTRAINT_VIOLATION instead of
31
+ * silently widening its own budget.
32
+ */
33
+ export declare const SettingConstraintSchema: z.ZodObject<{
34
+ min: z.ZodOptional<z.ZodNumber>;
35
+ max: z.ZodOptional<z.ZodNumber>;
36
+ readonly: z.ZodOptional<z.ZodLiteral<true>>;
37
+ }, z.core.$strict>;
38
+ export type SettingConstraint = z.infer<typeof SettingConstraintSchema>;
26
39
  /**
27
40
  * Per-profile resource-cap shape. Field names mirror the ClickHouse XML
28
41
  * element names (snake_case at the wire; camelCase here for TS ergonomics).
@@ -31,8 +44,18 @@ export type ManagedPasswordName = z.infer<typeof ManagedPasswordNameSchema>;
31
44
  * `renderUsersXml` maps each present field to the snake_case XML element
32
45
  * via `camelToSnakeCase`. Unknown fields are rejected by `.strict()` so a
33
46
  * typo at the consumer doesn't silently ship a no-op profile.
47
+ *
48
+ * `constraints` is the one non-scalar member: it renders as the profile's
49
+ * `<constraints>` block (keys camelCase here, snake_case at the wire, same
50
+ * as the scalar settings), making the named ceilings unraisable from a
51
+ * statement-level `SETTINGS` clause.
34
52
  */
35
53
  export declare const ProfileSpecSchema: z.ZodObject<{
54
+ constraints: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
55
+ min: z.ZodOptional<z.ZodNumber>;
56
+ max: z.ZodOptional<z.ZodNumber>;
57
+ readonly: z.ZodOptional<z.ZodLiteral<true>>;
58
+ }, z.core.$strict>>>;
36
59
  maxThreads: z.ZodOptional<z.ZodNumber>;
37
60
  maxInsertThreads: z.ZodOptional<z.ZodNumber>;
38
61
  maxConcurrentQueriesForUser: z.ZodOptional<z.ZodNumber>;
@@ -62,6 +85,15 @@ export declare const ProfileSpecSchema: z.ZodObject<{
62
85
  materializeTtlAfterModify: z.ZodOptional<z.ZodBoolean>;
63
86
  }, z.core.$strict>;
64
87
  export type ProfileSpec = z.infer<typeof ProfileSpecSchema>;
88
+ /**
89
+ * Deep-frozen `ProfileSpec` — matches the `Object.freeze` depth of the
90
+ * derived defaults so the type system forbids what the runtime freeze
91
+ * throws on. `Readonly<ProfileSpec>` alone is shallow: it left the nested
92
+ * constraint objects typed mutable while frozen at runtime.
93
+ */
94
+ export type FrozenProfileSpec = Readonly<Omit<ProfileSpec, "constraints">> & {
95
+ readonly constraints?: Readonly<Record<string, Readonly<SettingConstraint>>>;
96
+ };
65
97
  /**
66
98
  * Re-exported for the construct's Stage 1 validation. Profile keys MUST
67
99
  * match the same lowercase snake_case shape as user names — they emit as
@@ -88,4 +120,4 @@ export declare const PROFILE_NAME_PATTERN: RegExp;
88
120
  * consumer cannot mutate the defaults in place. Pass your own `profiles:`
89
121
  * map to the construct to extend / override; never mutate this constant.
90
122
  */
91
- export declare const ClickHouseDefaultProfiles: Readonly<Record<string, Readonly<ProfileSpec>>>;
123
+ export declare const ClickHouseDefaultProfiles: Readonly<Record<string, FrozenProfileSpec>>;
@@ -9,6 +9,12 @@ import { deriveClickHouseDefaultProfiles } from "./clickhouseTuning.js";
9
9
  * surfaces as a parse failure in the runner). Keeps the XML element names
10
10
  * emit-safe (no escaping needed). */
11
11
  const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
12
+ /** Constraint-record keys — camelCase (or snake_case) setting names with a
13
+ * leading lowercase letter. Record keys are the one profile surface
14
+ * `.strict()` cannot police, and they emit as XML element names via
15
+ * `camelToSnakeCase`, so the emit-safety NAME_PATTERN guarantees for
16
+ * user/profile names must come from the key schema itself here. */
17
+ const CONSTRAINT_KEY_PATTERN = /^[a-z][a-zA-Z0-9_]*$/;
12
18
  /**
13
19
  * Schema-admin user — the framework's bootstrap-privileged identity that
14
20
  * customer SQL runs as. Owns DDL, schema migrations, GRANTs. Rendered into
@@ -41,6 +47,21 @@ export const ManagedPasswordNameSchema = z
41
47
  .min(1)
42
48
  .max(63)
43
49
  .regex(NAME_PATTERN, "Must be lowercase snake_case");
50
+ /**
51
+ * One setting's constraint inside a profile's `<constraints>` block.
52
+ * `max`/`min` bound what a statement-level `SETTINGS` clause may set the
53
+ * value to; `readonly` forbids changing it at all. A statement that tries
54
+ * to exceed a bound fails with 452 SETTING_CONSTRAINT_VIOLATION instead of
55
+ * silently widening its own budget.
56
+ */
57
+ export const SettingConstraintSchema = z
58
+ .object({
59
+ min: z.number().int().nonnegative().optional(),
60
+ max: z.number().int().nonnegative().optional(),
61
+ readonly: z.literal(true).optional()
62
+ })
63
+ .strict()
64
+ .refine((c) => c.min !== undefined || c.max !== undefined || c.readonly === true, "A setting constraint must declare min, max or readonly");
44
65
  /**
45
66
  * Per-profile resource-cap shape. Field names mirror the ClickHouse XML
46
67
  * element names (snake_case at the wire; camelCase here for TS ergonomics).
@@ -49,9 +70,19 @@ export const ManagedPasswordNameSchema = z
49
70
  * `renderUsersXml` maps each present field to the snake_case XML element
50
71
  * via `camelToSnakeCase`. Unknown fields are rejected by `.strict()` so a
51
72
  * typo at the consumer doesn't silently ship a no-op profile.
73
+ *
74
+ * `constraints` is the one non-scalar member: it renders as the profile's
75
+ * `<constraints>` block (keys camelCase here, snake_case at the wire, same
76
+ * as the scalar settings), making the named ceilings unraisable from a
77
+ * statement-level `SETTINGS` clause.
52
78
  */
53
79
  export const ProfileSpecSchema = z
54
80
  .object({
81
+ constraints: z
82
+ .record(z
83
+ .string()
84
+ .regex(CONSTRAINT_KEY_PATTERN, "Constraint keys must be camelCase (or snake_case) setting names"), SettingConstraintSchema)
85
+ .optional(),
55
86
  maxThreads: z.number().int().positive().optional(),
56
87
  maxInsertThreads: z.number().int().positive().optional(),
57
88
  maxConcurrentQueriesForUser: z.number().int().positive().optional(),
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Default critical free-space floor for a volume of `storageGb`.
3
+ *
4
+ * The larger of the absolute floor and a share of the volume — neither alone
5
+ * is right at both ends of the size range. See
6
+ * `CLICKHOUSE_DISK_FREE_CRITICAL_GIB` for the reasoning; rounded to whole GiB
7
+ * so the alarm description reads as a number an operator would say out loud.
8
+ */
9
+ export declare function defaultDiskFreeCriticalGib(storageGb: number): number;
10
+ export interface ClickHouseStorageResolution {
11
+ /** EBS volume size in GiB, defaulted and validated. */
12
+ storageGb: number;
13
+ /** Critical free-space floor in GiB, defaulted and validated. */
14
+ diskFreeCriticalGib: number;
15
+ }
16
+ /**
17
+ * Resolve and validate the two values that together decide when a ClickHouse
18
+ * volume is considered nearly full.
19
+ *
20
+ * They are validated as a PAIR because neither is checkable alone: a 100 GiB
21
+ * volume is fine until someone sets a 60 GiB free-space floor, and a 60 GiB
22
+ * floor is fine until someone shrinks the volume under it. Split across two
23
+ * validators, each would pass on its own and the deployed stack would carry a
24
+ * critical alarm that can never leave ALARM.
25
+ *
26
+ * Throws rather than annotating: both are numbers the caller typed, and a
27
+ * warning on a synth that still emits a permanently-breaching pager is worse
28
+ * than a failed synth with the arithmetic in the message.
29
+ */
30
+ export declare function resolveClickHouseStorage(storageGb: number | undefined, diskFreeCriticalGib: number | undefined): ClickHouseStorageResolution;
31
+ export interface ClickHouseVolumePerformanceInput {
32
+ /** Validated volume size from `resolveClickHouseStorage` — the IOPS ceiling
33
+ * is a function of it. */
34
+ storageGb: number;
35
+ iops: number | undefined;
36
+ throughputMbps: number | undefined;
37
+ }
38
+ export interface ClickHouseVolumePerformanceResolution {
39
+ /** Provisioned IOPS, defaulted and validated. */
40
+ iops: number;
41
+ /** Provisioned throughput in MiB/s, defaulted and validated. */
42
+ throughputMbps: number;
43
+ }
44
+ /**
45
+ * Resolve and validate the gp3 performance pair for the data volume.
46
+ *
47
+ * Same shape as `resolveClickHouseStorage`: each value has a band of its own,
48
+ * but EBS also constrains them against each other and against the size —
49
+ * throughput needs four IOPS per MiB/s, and IOPS needs a GiB per 500 — so a
50
+ * value legal on its own is rejected by CreateVolume mid-deploy once its
51
+ * partner is in view. Checking the pair at synth turns that rollback into a
52
+ * message that names the figure the other half needs.
53
+ *
54
+ * Both checks are the ones aws-cdk-lib's `Volume` applies too; this resolver
55
+ * exists so the failure names `ClickHouseDatabase` props and their cure
56
+ * rather than a CDK-internal field.
57
+ */
58
+ export declare function resolveClickHouseVolumePerformance(input: ClickHouseVolumePerformanceInput): ClickHouseVolumePerformanceResolution;
@@ -0,0 +1,87 @@
1
+ import { CLICKHOUSE_DISK_FREE_CRITICAL_GIB, CLICKHOUSE_DISK_FREE_CRITICAL_SHARE, CLICKHOUSE_EBS_IOPS, CLICKHOUSE_EBS_THROUGHPUT_MBPS, CLICKHOUSE_EBS_VOLUME_SIZE_GB, CLICKHOUSE_IOPS_PER_THROUGHPUT_MBPS, CLICKHOUSE_MAX_IOPS, CLICKHOUSE_MAX_IOPS_PER_GB, CLICKHOUSE_MAX_STORAGE_GB, CLICKHOUSE_MAX_THROUGHPUT_MBPS, CLICKHOUSE_MIN_IOPS, CLICKHOUSE_MIN_STORAGE_GB, CLICKHOUSE_MIN_THROUGHPUT_MBPS } from "./clickhouseConstants.js";
2
+ /**
3
+ * Largest share of the volume the critical free-space floor may claim. A
4
+ * floor at half the disk would page from the day the cluster passed 50 %
5
+ * used, which is a capacity-planning fact, not an incident; at a fifth, the
6
+ * alarm means what its description says — the disk is nearly gone.
7
+ */
8
+ const MAX_FLOOR_SHARE_OF_VOLUME = 0.2;
9
+ /**
10
+ * Default critical free-space floor for a volume of `storageGb`.
11
+ *
12
+ * The larger of the absolute floor and a share of the volume — neither alone
13
+ * is right at both ends of the size range. See
14
+ * `CLICKHOUSE_DISK_FREE_CRITICAL_GIB` for the reasoning; rounded to whole GiB
15
+ * so the alarm description reads as a number an operator would say out loud.
16
+ */
17
+ export function defaultDiskFreeCriticalGib(storageGb) {
18
+ return Math.max(CLICKHOUSE_DISK_FREE_CRITICAL_GIB, Math.round(storageGb * CLICKHOUSE_DISK_FREE_CRITICAL_SHARE));
19
+ }
20
+ /**
21
+ * Resolve and validate the two values that together decide when a ClickHouse
22
+ * volume is considered nearly full.
23
+ *
24
+ * They are validated as a PAIR because neither is checkable alone: a 100 GiB
25
+ * volume is fine until someone sets a 60 GiB free-space floor, and a 60 GiB
26
+ * floor is fine until someone shrinks the volume under it. Split across two
27
+ * validators, each would pass on its own and the deployed stack would carry a
28
+ * critical alarm that can never leave ALARM.
29
+ *
30
+ * Throws rather than annotating: both are numbers the caller typed, and a
31
+ * warning on a synth that still emits a permanently-breaching pager is worse
32
+ * than a failed synth with the arithmetic in the message.
33
+ */
34
+ export function resolveClickHouseStorage(storageGb, diskFreeCriticalGib) {
35
+ const size = storageGb ?? CLICKHOUSE_EBS_VOLUME_SIZE_GB;
36
+ if (!Number.isInteger(size) ||
37
+ size < CLICKHOUSE_MIN_STORAGE_GB ||
38
+ size > CLICKHOUSE_MAX_STORAGE_GB) {
39
+ throw new Error(`ClickHouseDatabase: storageGb must be an integer between ${CLICKHOUSE_MIN_STORAGE_GB} and ${CLICKHOUSE_MAX_STORAGE_GB} GiB; got ${size}.`);
40
+ }
41
+ const floor = diskFreeCriticalGib ?? defaultDiskFreeCriticalGib(size);
42
+ if (!Number.isFinite(floor) || floor <= 0) {
43
+ throw new Error(`ClickHouseDatabase: alarms.diskFreeCriticalGib must be a positive number of GiB; got ${floor}.`);
44
+ }
45
+ const ceiling = size * MAX_FLOOR_SHARE_OF_VOLUME;
46
+ if (floor > ceiling) {
47
+ throw new Error(`ClickHouseDatabase: alarms.diskFreeCriticalGib (${floor} GiB) exceeds a fifth of storageGb (${size} GiB → ${ceiling} GiB). A floor that large keeps the disk-critical alarm in ALARM on a healthy cluster; raise storageGb or lower the floor.`);
48
+ }
49
+ return { storageGb: size, diskFreeCriticalGib: floor };
50
+ }
51
+ /**
52
+ * Resolve and validate the gp3 performance pair for the data volume.
53
+ *
54
+ * Same shape as `resolveClickHouseStorage`: each value has a band of its own,
55
+ * but EBS also constrains them against each other and against the size —
56
+ * throughput needs four IOPS per MiB/s, and IOPS needs a GiB per 500 — so a
57
+ * value legal on its own is rejected by CreateVolume mid-deploy once its
58
+ * partner is in view. Checking the pair at synth turns that rollback into a
59
+ * message that names the figure the other half needs.
60
+ *
61
+ * Both checks are the ones aws-cdk-lib's `Volume` applies too; this resolver
62
+ * exists so the failure names `ClickHouseDatabase` props and their cure
63
+ * rather than a CDK-internal field.
64
+ */
65
+ export function resolveClickHouseVolumePerformance(input) {
66
+ const iops = input.iops ?? CLICKHOUSE_EBS_IOPS;
67
+ if (!Number.isInteger(iops) ||
68
+ iops < CLICKHOUSE_MIN_IOPS ||
69
+ iops > CLICKHOUSE_MAX_IOPS) {
70
+ throw new Error(`ClickHouseDatabase: iops must be an integer between ${CLICKHOUSE_MIN_IOPS} and ${CLICKHOUSE_MAX_IOPS}; got ${iops}.`);
71
+ }
72
+ const iopsCeilingForSize = input.storageGb * CLICKHOUSE_MAX_IOPS_PER_GB;
73
+ if (iops > iopsCeilingForSize) {
74
+ throw new Error(`ClickHouseDatabase: iops (${iops}) exceeds ${CLICKHOUSE_MAX_IOPS_PER_GB} per GiB of storageGb (${input.storageGb} GiB → ${iopsCeilingForSize}). EBS rejects the volume; raise storageGb to at least ${Math.ceil(iops / CLICKHOUSE_MAX_IOPS_PER_GB)} GiB or lower iops.`);
75
+ }
76
+ const throughputMbps = input.throughputMbps ?? CLICKHOUSE_EBS_THROUGHPUT_MBPS;
77
+ if (!Number.isInteger(throughputMbps) ||
78
+ throughputMbps < CLICKHOUSE_MIN_THROUGHPUT_MBPS ||
79
+ throughputMbps > CLICKHOUSE_MAX_THROUGHPUT_MBPS) {
80
+ throw new Error(`ClickHouseDatabase: throughputMbps must be an integer between ${CLICKHOUSE_MIN_THROUGHPUT_MBPS} and ${CLICKHOUSE_MAX_THROUGHPUT_MBPS} MiB/s; got ${throughputMbps}.`);
81
+ }
82
+ const iopsNeeded = throughputMbps * CLICKHOUSE_IOPS_PER_THROUGHPUT_MBPS;
83
+ if (iops < iopsNeeded) {
84
+ throw new Error(`ClickHouseDatabase: throughputMbps (${throughputMbps} MiB/s) needs ${CLICKHOUSE_IOPS_PER_THROUGHPUT_MBPS} IOPS per MiB/s, so at least ${iopsNeeded} iops; got ${iops}. EBS rejects the volume; raise iops to ${iopsNeeded} or lower throughputMbps to ${Math.floor(iops / CLICKHOUSE_IOPS_PER_THROUGHPUT_MBPS)}.`);
85
+ }
86
+ return { iops, throughputMbps };
87
+ }
@@ -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.
@@ -52,7 +63,16 @@ export interface BuildClickHouseUserDataOptions {
52
63
  serverSecretArn: string;
53
64
  };
54
65
  }
55
- export declare function generateServerConfigXml(options: BuildClickHouseUserDataOptions): string;
66
+ /**
67
+ * The subset of the user-data options that config.xml generation actually
68
+ * reads. `dataVolumeSizeGb` is deliberately absent: it shapes the boot
69
+ * SCRIPT (the launch-template refresh trigger), not the rendered server
70
+ * config — demanding it here forced the XML parity tests to invent a value
71
+ * the render never consumes. `tls` is read for presence only (it toggles
72
+ * the secure-listener block); the ARNs inside it are boot-script concerns.
73
+ */
74
+ export type ServerConfigXmlOptions = Pick<BuildClickHouseUserDataOptions, "backupBucketName" | "backupBucketRegion" | "instanceType" | "coldTier" | "tls">;
75
+ export declare function generateServerConfigXml(options: ServerConfigXmlOptions): string;
56
76
  export interface GenerateUsersConfigXmlOptions {
57
77
  schemaAdmin: ClickHouseSchemaAdmin;
58
78
  profiles: Record<string, ProfileSpec>;
@@ -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