@fjall/components-infrastructure 39.0.3 → 39.0.4

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.
@@ -17,7 +17,8 @@ import { Secret } from "../../resources/aws/secrets/secret.js";
17
17
  import { LogGroup } from "../../resources/aws/logging/logGroup.js";
18
18
  import { StackEncryptionKey } from "../../resources/aws/secrets/stackEncryptionKey.js";
19
19
  import { createClickHouseSecurityGroup } from "../../resources/aws/database/clickhouseSecurityGroup.js";
20
- import { buildMaintenancePreamble, maintenanceOkMarker, maintenanceRun } from "../../resources/aws/database/clickhouseMaintenanceScript.js";
20
+ import { buildMaintenancePreamble } from "../../resources/aws/database/clickhouseMaintenanceScript.js";
21
+ import { buildClickHouseOptimiseScript } from "../../resources/aws/database/clickhouseOptimiseScript.js";
21
22
  import { buildClickHouseEntrypointWrapper, buildClickHouseUserData, generateUsersConfigXml } from "../../resources/aws/database/clickhouseUserData.js";
22
23
  import { toPascalCase } from "../../utils/capitaliseString.js";
23
24
  import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
@@ -25,7 +26,7 @@ import { createClickHouseAlarms, validateClickHouseAlarmThresholds, BACKUP_HEART
25
26
  import { ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, PROFILE_NAME_PATTERN } from "../../resources/aws/database/clickhouseSchemas.js";
26
27
  import { deriveClickHouseDefaultProfiles } from "../../resources/aws/database/clickhouseTuning.js";
27
28
  import { inferAmiHardwareType } from "../../resources/aws/compute/ecsConstants.js";
28
- 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";
29
+ 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, OPTIMISE_STATEMENT_RECEIVE_TIMEOUT_SECONDS, clickHouseBackupVerifyTimeoutSeconds, BACKUP_SCHEDULE, BACKUP_TASK_MEMORY_MIB, BACKUP_TASK_CPU_UNITS, BACKUP_RETENTION_DAYS } from "../../resources/aws/database/clickhouseConstants.js";
29
30
  import { CLICKHOUSE_MAX_BACKUP_RETENTION_DAYS, CLICKHOUSE_MIN_BACKUP_RETENTION_DAYS } from "@fjall/util/clickhouse";
30
31
  import { resolveClickHouseStorage, resolveClickHouseVolumePerformance } from "../../resources/aws/database/clickhouseStorage.js";
31
32
  import { buildClickHouseBackupScript } from "../../resources/aws/database/clickhouseBackupScript.js";
@@ -468,16 +469,6 @@ export class ClickHouseDatabase extends Construct {
468
469
  const dataAz = Stack.of(this).availabilityZones[0];
469
470
  const clickHouseHost = this.getHostEndpoint();
470
471
  const backupDestUrl = `https://${backupBucket.bucketName}.s3.${Stack.of(this).region}.amazonaws.com/backup/`;
471
- const optimiseStatements = [
472
- ...REPLACING_MERGE_TREE_TABLES.map((table) => ({
473
- table,
474
- sql: `OPTIMIZE TABLE ${CLICKHOUSE_DATABASE_NAME}.${table} FINAL`
475
- })),
476
- ...OPTIMISE_MV_TABLES.map((table) => ({
477
- table,
478
- sql: `OPTIMIZE TABLE ${CLICKHOUSE_DATABASE_NAME}.${table}`
479
- }))
480
- ];
481
472
  const adminSecret = expectDefined(userSecrets.get(schemaAdmin.name), `schemaAdmin '${schemaAdmin.name}' secret not minted.`);
482
473
  const maintenanceSecret = expectDefined(frameworkSecrets.get(FJALL_MAINTENANCE_USER), `framework identity '${FJALL_MAINTENANCE_USER}' secret not minted.`);
483
474
  const sidecarTlsPreamble = tlsActive
@@ -496,16 +487,19 @@ export class ClickHouseDatabase extends Construct {
496
487
  // own status marker and the task still exits non-zero so they stay
497
488
  // visible in the task history. Both scheduled tasks authenticate as the
498
489
  // maintenance identity; the password arrives via CLICKHOUSE_PASSWORD
499
- // rather than argv.
490
+ // rather than argv. Each job's client names its own `receive_timeout`,
491
+ // because the client's 300 s default reports a statement the server is
492
+ // still finishing as failed.
500
493
  const clickHouseClient = `clickhouse-client --host ${clickHouseHost} --port ${nativePort} --user ${FJALL_MAINTENANCE_USER}${sidecarTlsClientArgs}`;
501
- const optimiseScript = [
502
- "FAILED=0",
503
- ...optimiseStatements.map(({ table, sql }) => `${maintenanceRun(`optimise_${table}`, sql)} || FAILED=1`),
504
- `if [ "$FAILED" = 0 ]; then ${maintenanceOkMarker("optimise")}; fi`,
505
- 'exit "$FAILED"'
506
- ].join("; ");
494
+ const optimiseClient = `${clickHouseClient} --receive_timeout=${OPTIMISE_STATEMENT_RECEIVE_TIMEOUT_SECONDS}`;
495
+ const backupClient = `${clickHouseClient} --receive_timeout=${clickHouseBackupVerifyTimeoutSeconds(storageGb)}`;
496
+ const optimiseScript = buildClickHouseOptimiseScript({
497
+ databaseName: CLICKHOUSE_DATABASE_NAME,
498
+ replacingTables: REPLACING_MERGE_TREE_TABLES,
499
+ mvTables: OPTIMISE_MV_TABLES
500
+ });
507
501
  const optimisePreamble = buildMaintenancePreamble({
508
- client: clickHouseClient,
502
+ client: optimiseClient,
509
503
  job: "optimise",
510
504
  schemaAdminName: schemaAdmin.name
511
505
  });
@@ -547,7 +541,7 @@ export class ClickHouseDatabase extends Construct {
547
541
  "-c",
548
542
  // Password via CLICKHOUSE_PASSWORD env, not --password on argv (argv → /proc/<pid>/cmdline).
549
543
  `${sidecarTlsPreamble}${buildClickHouseBackupScript({
550
- client: clickHouseClient,
544
+ client: backupClient,
551
545
  schemaAdminName: schemaAdmin.name,
552
546
  databaseName: CLICKHOUSE_DATABASE_NAME,
553
547
  backupDestUrl,
@@ -17,7 +17,7 @@
17
17
  * case loudly (`STD_EXCEPTION`, missing `checksums.txt`), which is the
18
18
  * behaviour the alarm is built on.
19
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";
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, CLICKHOUSE_DEFAULT_DISK_NAME, clickHouseBackupVerifyTimeoutSeconds } from "./clickhouseConstants.js";
21
21
  import { buildMaintenancePreamble, maintenanceOkMarker, maintenanceRun, maintenanceTry } from "./clickhouseMaintenanceScript.js";
22
22
  const BYTES_PER_GIB = 1024 ** 3;
23
23
  const JOB = "backup";
@@ -119,7 +119,7 @@ export function buildClickHouseBackupScript(params) {
119
119
  const dropScratchSql = `DROP DATABASE IF EXISTS ${scratch} SYNC`;
120
120
  const dropScratchTolerated = maintenanceTry("drop_scratch", dropScratchSql);
121
121
  const sourceRowsQuery = `SELECT sum(rows) FROM system.parts WHERE database = '${databaseName}' AND active`;
122
- const fitVerdictQuery = `SELECT if((SELECT free_space FROM system.disks WHERE name = 'default')` +
122
+ const fitVerdictQuery = `SELECT if((SELECT free_space FROM system.disks WHERE name = '${CLICKHOUSE_DEFAULT_DISK_NAME}')` +
123
123
  ` - (SELECT sum(bytes_on_disk) FROM system.parts WHERE database = '${databaseName}' AND active)` +
124
124
  ` >= ${requiredFreeBytes}, 'FITS', 'SKIP')`;
125
125
  const missingTablesQuery = `SELECT count() FROM (SELECT name FROM system.tables WHERE database = '${databaseName}' AND name NOT LIKE '.inner%'` +
@@ -253,14 +253,42 @@ export declare const CLICKHOUSE_HEALTH_CHECK: {
253
253
  * m7g.medium under merge load risks `max_suspicious_broken_parts` on the
254
254
  * next start. ECS upper bound is 120s. */
255
255
  export declare const CLICKHOUSE_STOP_TIMEOUT_SECONDS = 90;
256
- /** OPTIMIZE TABLE FINAL schedule.
257
- * RMT tables carry min_age_to_force_merge_seconds=600 so the engine already merges
258
- * old parts within 10 min; this task is a safety net for MVs (no engine-level setting)
259
- * and for ReplacingMergeTree dedup under skewed write patterns. 6 hours is sufficient. */
256
+ /** Optimise task schedule.
257
+ * The ReplacingMergeTree tables carry `min_age_to_force_merge_seconds=600`
258
+ * with `min_age_to_force_merge_on_partition_only=1`, so the engine force-merges
259
+ * a partition whose newest part is ten minutes old, but only when the merge
260
+ * selector has nothing else to do and the pool has a slot free. This task is
261
+ * the backstop for that, and for the materialised views in
262
+ * `OPTIMISE_MV_TABLES`. 6 hours is sufficient. */
260
263
  export declare const OPTIMISE_FINAL_SCHEDULE = "rate(6 hours)";
261
- /** Tables requiring periodic OPTIMIZE FINAL (ReplacingMergeTree only).
262
- * Keep in sync with REPLACING_MERGE_TREE_TABLES in
263
- * webapp/app/.server/lib/clickhouse/tenantQuery/tableConstants.ts (auto-FINAL). */
264
+ /** ClickHouse's built-in name for the local data disk: the hot volume of
265
+ * every storage policy, and the disk the free-space checks read. The server
266
+ * defines it from `<path>`, so it is ClickHouse's name, not a choice made
267
+ * here. The optimise sweep reads it to tell a partition wholly on local disk
268
+ * from one that has moved to cold storage. */
269
+ export declare const CLICKHOUSE_DEFAULT_DISK_NAME = "default";
270
+ /** How long the optimise client waits on one statement before it reports it
271
+ * failed, passed as `--receive_timeout`. It is the longest single-partition
272
+ * merge the job accepts, and a statement that runs past it is reported
273
+ * failed.
274
+ *
275
+ * It bounds the client's wait, not the server's work. Measured on
276
+ * production, clickhouse-server 26.3.17.56, 2026-09-30: at the client's
277
+ * 300 s default, every whole-table `log_events` merge was reported failed
278
+ * (FJL-609), while the server's `system.query_log` recorded each one
279
+ * finishing with code 0, after 724 to 1277 s. Over 13 days of
280
+ * `system.part_log`, the longest single-partition merge of any listed table
281
+ * was 225 s, on weekly `log_events` partitions of up to 3.8 GiB, so this is
282
+ * about five times the worst observed. */
283
+ export declare const OPTIMISE_STATEMENT_RECEIVE_TIMEOUT_SECONDS = 1200;
284
+ /** ReplacingMergeTree tables the optimise task settles, partition by
285
+ * partition (`buildClickHouseOptimiseScript`).
286
+ *
287
+ * Coupled with the webapp, whose tenant table manifest marks the same tables
288
+ * `replacingMergeTree`; a webapp test compares the two lists, reading this
289
+ * one from the installed package. The list follows the engine, not what the
290
+ * webapp reads with FINAL: a table that collapses duplicates on read still
291
+ * needs its merges. */
264
292
  export declare const REPLACING_MERGE_TREE_TABLES: readonly ["application_metrics", "cost_records", "log_events", "log_fingerprints", "insights", "asset_inventory"];
265
293
  /** Subdirectory on the EBS volume for server config files (must match CDK volume mount). */
266
294
  export declare const CLICKHOUSE_CONFIG_SUBDIR = "server-config.d";
@@ -279,14 +279,42 @@ export const CLICKHOUSE_HEALTH_CHECK = {
279
279
  * m7g.medium under merge load risks `max_suspicious_broken_parts` on the
280
280
  * next start. ECS upper bound is 120s. */
281
281
  export const CLICKHOUSE_STOP_TIMEOUT_SECONDS = 90;
282
- /** OPTIMIZE TABLE FINAL schedule.
283
- * RMT tables carry min_age_to_force_merge_seconds=600 so the engine already merges
284
- * old parts within 10 min; this task is a safety net for MVs (no engine-level setting)
285
- * and for ReplacingMergeTree dedup under skewed write patterns. 6 hours is sufficient. */
282
+ /** Optimise task schedule.
283
+ * The ReplacingMergeTree tables carry `min_age_to_force_merge_seconds=600`
284
+ * with `min_age_to_force_merge_on_partition_only=1`, so the engine force-merges
285
+ * a partition whose newest part is ten minutes old, but only when the merge
286
+ * selector has nothing else to do and the pool has a slot free. This task is
287
+ * the backstop for that, and for the materialised views in
288
+ * `OPTIMISE_MV_TABLES`. 6 hours is sufficient. */
286
289
  export const OPTIMISE_FINAL_SCHEDULE = "rate(6 hours)";
287
- /** Tables requiring periodic OPTIMIZE FINAL (ReplacingMergeTree only).
288
- * Keep in sync with REPLACING_MERGE_TREE_TABLES in
289
- * webapp/app/.server/lib/clickhouse/tenantQuery/tableConstants.ts (auto-FINAL). */
290
+ /** ClickHouse's built-in name for the local data disk: the hot volume of
291
+ * every storage policy, and the disk the free-space checks read. The server
292
+ * defines it from `<path>`, so it is ClickHouse's name, not a choice made
293
+ * here. The optimise sweep reads it to tell a partition wholly on local disk
294
+ * from one that has moved to cold storage. */
295
+ export const CLICKHOUSE_DEFAULT_DISK_NAME = "default";
296
+ /** How long the optimise client waits on one statement before it reports it
297
+ * failed, passed as `--receive_timeout`. It is the longest single-partition
298
+ * merge the job accepts, and a statement that runs past it is reported
299
+ * failed.
300
+ *
301
+ * It bounds the client's wait, not the server's work. Measured on
302
+ * production, clickhouse-server 26.3.17.56, 2026-09-30: at the client's
303
+ * 300 s default, every whole-table `log_events` merge was reported failed
304
+ * (FJL-609), while the server's `system.query_log` recorded each one
305
+ * finishing with code 0, after 724 to 1277 s. Over 13 days of
306
+ * `system.part_log`, the longest single-partition merge of any listed table
307
+ * was 225 s, on weekly `log_events` partitions of up to 3.8 GiB, so this is
308
+ * about five times the worst observed. */
309
+ export const OPTIMISE_STATEMENT_RECEIVE_TIMEOUT_SECONDS = 1200;
310
+ /** ReplacingMergeTree tables the optimise task settles, partition by
311
+ * partition (`buildClickHouseOptimiseScript`).
312
+ *
313
+ * Coupled with the webapp, whose tenant table manifest marks the same tables
314
+ * `replacingMergeTree`; a webapp test compares the two lists, reading this
315
+ * one from the installed package. The list follows the engine, not what the
316
+ * webapp reads with FINAL: a table that collapses duplicates on read still
317
+ * needs its merges. */
290
318
  export const REPLACING_MERGE_TREE_TABLES = [
291
319
  "application_metrics",
292
320
  "cost_records",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The optimise task's shell body: settle, partition by partition, the
3
+ * ReplacingMergeTree partitions that have something to settle, then compact
4
+ * the materialised views.
5
+ *
6
+ * A whole-table `OPTIMIZE TABLE t FINAL` is the wrong unit of work. These four
7
+ * reasons were measured on clickhouse-server 26.3.17.56, the image
8
+ * `CLICKHOUSE_IMAGE` pinned on 2026-09-30:
9
+ *
10
+ * 1. **It rewrites what is already settled.** FINAL merges every partition,
11
+ * a partition that is already a single part included, so the work grows
12
+ * with the table rather than with what changed.
13
+ * 2. **It rewrites cold data.** A merged part lands on the coldest volume its
14
+ * sources sit on, so every cold partition is read back from object
15
+ * storage and written to it again on every run.
16
+ * 3. **It stops silently.** At the first partition it cannot select it
17
+ * returns success without touching the rest of the table.
18
+ * 4. **Its duration is the table's, not the change's.** The statement holds
19
+ * the maintenance identity's single query slot for as long as the whole
20
+ * table takes. On production, `clickhouse-client` printed its
21
+ * `receive_timeout` error only once the server had finished, and the
22
+ * server's own record showed the merge completing with code 0. So the job
23
+ * reported completed merges as failed.
24
+ *
25
+ * So each run reads `system.parts` once per table and gives every partition
26
+ * holding more than one active part, or rows a lightweight delete has masked,
27
+ * one of the verdicts in `OPTIMISE_PARTITION_ACTION`:
28
+ *
29
+ * - `merge`: every part is on the local disk and the parts share one
30
+ * projection set. The job settles it with
31
+ * `OPTIMIZE TABLE … PARTITION ID '<id>' FINAL`.
32
+ * - `purge`: the partition carries rows a lightweight delete has masked. The
33
+ * job rewrites it wherever it sits, because the rewrite is what removes
34
+ * those rows from disk. This is the one case that writes to cold storage,
35
+ * and it arises only when a delete has run. A delete marks its part through
36
+ * `has_lightweight_delete` under `lightweight_delete_mode = 'alter_update'`,
37
+ * the 26.3 default and the mode production runs (measured 2026-09-30). A
38
+ * delete written as patch parts would not set it.
39
+ * - `skip_cold`: at least one part has moved off the local disk. The engine's
40
+ * own merges settle it.
41
+ * - `skip_projections`: the parts carry different projection sets, which the
42
+ * engine refuses to merge together (Code 388). The job says so rather than
43
+ * issuing a statement that could only report a no-op as success.
44
+ * - `skip_partition_id`: the partition id is not purely `[0-9A-Za-z_]`, so
45
+ * the job will not place it inside a statement.
46
+ *
47
+ * A partition the job skips is logged every run as a
48
+ * `fjall:maintenance:skipped` line. It is never a status marker: it neither
49
+ * pages nor defers, and the job still ends `status=ok`.
50
+ *
51
+ * A listed ReplacingMergeTree table that does not exist fails the job with
52
+ * `cause=missing_table`, naming the table. The enumeration alone would read a
53
+ * missing table as one with nothing to settle, and a dropped table left in
54
+ * the constants must stay as visible as it was when the whole-table statement
55
+ * raised `UNKNOWN_TABLE`. A missing materialised view still fails through its
56
+ * own statement, as `reason=optimise_<view>`.
57
+ *
58
+ * `sh` only (the image's `/bin/sh` is dash), and nothing relies on `set -e`:
59
+ * the TLS preamble sets `set -eu` only when TLS is active, so every failure
60
+ * path is an explicit `||` or `if` branch that behaves the same either way.
61
+ * POSIX `sh` has no local variables, and the statement runners assign `_rc`,
62
+ * `_label` and `_sql`, so the sweep's own variables carry a `FJALL_SWEEP_`
63
+ * prefix that the runners never touch.
64
+ */
65
+ /** The shell function that settles one table; defined once, called per table. */
66
+ export declare const OPTIMISE_SWEEP_FN = "fjall_ch_sweep";
67
+ /** Log token for a partition the sweep settles. Informational, not a status. */
68
+ export declare const OPTIMISE_PARTITION_LOG_TOKEN = "fjall:maintenance:optimise";
69
+ /** Log token for a partition the sweep leaves to the engine. Informational,
70
+ * not a status: no metric filter reads it. */
71
+ export declare const OPTIMISE_SKIPPED_LOG_TOKEN = "fjall:maintenance:skipped";
72
+ /** The verdicts the enumeration assigns. `merge` and `purge` issue a
73
+ * statement; the rest are logged. `skip_partition_id` is the shell's own
74
+ * refusal of an id it will not place inside a statement. */
75
+ export declare const OPTIMISE_PARTITION_ACTION: {
76
+ readonly merge: "merge";
77
+ readonly purge: "purge";
78
+ readonly skipCold: "skip_cold";
79
+ readonly skipProjections: "skip_projections";
80
+ readonly skipPartitionId: "skip_partition_id";
81
+ };
82
+ export interface ClickHouseOptimiseScriptParams {
83
+ /** Database the listed tables live in. */
84
+ readonly databaseName: string;
85
+ /** ReplacingMergeTree tables, settled partition by partition. */
86
+ readonly replacingTables: readonly string[];
87
+ /** Materialised views and their target tables, compacted without FINAL. */
88
+ readonly mvTables: readonly string[];
89
+ }
90
+ /**
91
+ * The optimise job body, to follow `buildMaintenancePreamble`. Globbing is
92
+ * switched off because the sweep word-splits the enumeration's output; every
93
+ * word is already `[0-9A-Za-z_:]`, so this is defence in depth. Exits 0 with
94
+ * `status=ok` only when every table and view settled without a failure.
95
+ */
96
+ export declare function buildClickHouseOptimiseScript(params: ClickHouseOptimiseScriptParams): string;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The optimise task's shell body: settle, partition by partition, the
3
+ * ReplacingMergeTree partitions that have something to settle, then compact
4
+ * the materialised views.
5
+ *
6
+ * A whole-table `OPTIMIZE TABLE t FINAL` is the wrong unit of work. These four
7
+ * reasons were measured on clickhouse-server 26.3.17.56, the image
8
+ * `CLICKHOUSE_IMAGE` pinned on 2026-09-30:
9
+ *
10
+ * 1. **It rewrites what is already settled.** FINAL merges every partition,
11
+ * a partition that is already a single part included, so the work grows
12
+ * with the table rather than with what changed.
13
+ * 2. **It rewrites cold data.** A merged part lands on the coldest volume its
14
+ * sources sit on, so every cold partition is read back from object
15
+ * storage and written to it again on every run.
16
+ * 3. **It stops silently.** At the first partition it cannot select it
17
+ * returns success without touching the rest of the table.
18
+ * 4. **Its duration is the table's, not the change's.** The statement holds
19
+ * the maintenance identity's single query slot for as long as the whole
20
+ * table takes. On production, `clickhouse-client` printed its
21
+ * `receive_timeout` error only once the server had finished, and the
22
+ * server's own record showed the merge completing with code 0. So the job
23
+ * reported completed merges as failed.
24
+ *
25
+ * So each run reads `system.parts` once per table and gives every partition
26
+ * holding more than one active part, or rows a lightweight delete has masked,
27
+ * one of the verdicts in `OPTIMISE_PARTITION_ACTION`:
28
+ *
29
+ * - `merge`: every part is on the local disk and the parts share one
30
+ * projection set. The job settles it with
31
+ * `OPTIMIZE TABLE … PARTITION ID '<id>' FINAL`.
32
+ * - `purge`: the partition carries rows a lightweight delete has masked. The
33
+ * job rewrites it wherever it sits, because the rewrite is what removes
34
+ * those rows from disk. This is the one case that writes to cold storage,
35
+ * and it arises only when a delete has run. A delete marks its part through
36
+ * `has_lightweight_delete` under `lightweight_delete_mode = 'alter_update'`,
37
+ * the 26.3 default and the mode production runs (measured 2026-09-30). A
38
+ * delete written as patch parts would not set it.
39
+ * - `skip_cold`: at least one part has moved off the local disk. The engine's
40
+ * own merges settle it.
41
+ * - `skip_projections`: the parts carry different projection sets, which the
42
+ * engine refuses to merge together (Code 388). The job says so rather than
43
+ * issuing a statement that could only report a no-op as success.
44
+ * - `skip_partition_id`: the partition id is not purely `[0-9A-Za-z_]`, so
45
+ * the job will not place it inside a statement.
46
+ *
47
+ * A partition the job skips is logged every run as a
48
+ * `fjall:maintenance:skipped` line. It is never a status marker: it neither
49
+ * pages nor defers, and the job still ends `status=ok`.
50
+ *
51
+ * A listed ReplacingMergeTree table that does not exist fails the job with
52
+ * `cause=missing_table`, naming the table. The enumeration alone would read a
53
+ * missing table as one with nothing to settle, and a dropped table left in
54
+ * the constants must stay as visible as it was when the whole-table statement
55
+ * raised `UNKNOWN_TABLE`. A missing materialised view still fails through its
56
+ * own statement, as `reason=optimise_<view>`.
57
+ *
58
+ * `sh` only (the image's `/bin/sh` is dash), and nothing relies on `set -e`:
59
+ * the TLS preamble sets `set -eu` only when TLS is active, so every failure
60
+ * path is an explicit `||` or `if` branch that behaves the same either way.
61
+ * POSIX `sh` has no local variables, and the statement runners assign `_rc`,
62
+ * `_label` and `_sql`, so the sweep's own variables carry a `FJALL_SWEEP_`
63
+ * prefix that the runners never touch.
64
+ */
65
+ import { CLICKHOUSE_DEFAULT_DISK_NAME, MAINTENANCE_STATUS, maintenanceStatusMarker } from "./clickhouseConstants.js";
66
+ import { MAINTENANCE_RUN_FN, maintenanceOkMarker, maintenanceRun } from "./clickhouseMaintenanceScript.js";
67
+ const JOB = "optimise";
68
+ /** The shell function that settles one table; defined once, called per table. */
69
+ export const OPTIMISE_SWEEP_FN = "fjall_ch_sweep";
70
+ /** Log token for a partition the sweep settles. Informational, not a status. */
71
+ export const OPTIMISE_PARTITION_LOG_TOKEN = "fjall:maintenance:optimise";
72
+ /** Log token for a partition the sweep leaves to the engine. Informational,
73
+ * not a status: no metric filter reads it. */
74
+ export const OPTIMISE_SKIPPED_LOG_TOKEN = "fjall:maintenance:skipped";
75
+ /** The verdicts the enumeration assigns. `merge` and `purge` issue a
76
+ * statement; the rest are logged. `skip_partition_id` is the shell's own
77
+ * refusal of an id it will not place inside a statement. */
78
+ export const OPTIMISE_PARTITION_ACTION = {
79
+ merge: "merge",
80
+ purge: "purge",
81
+ skipCold: "skip_cold",
82
+ skipProjections: "skip_projections",
83
+ skipPartitionId: "skip_partition_id"
84
+ };
85
+ const IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/;
86
+ function assertIdentifier(kind, value) {
87
+ if (!IDENTIFIER_PATTERN.test(value)) {
88
+ throw new Error(`ClickHouse optimise ${kind} must match ${IDENTIFIER_PATTERN}.`);
89
+ }
90
+ }
91
+ /**
92
+ * One row per partition that needs attention, as `<id>:<verdict>`.
93
+ *
94
+ * A partition id that is not purely `[0-9A-Za-z_]` is never returned as
95
+ * itself: it comes back hex-encoded with the `skip_partition_id` verdict, so
96
+ * nothing but an id the statement can quote safely ever reaches the shell.
97
+ * Every partition key in the webapp schema yields a digits-only id, so this
98
+ * arm exists for a future key rather than a present one.
99
+ */
100
+ function enumerationQuery(databaseName, table) {
101
+ const safeId = "length(partition_id) > 0 AND NOT match(partition_id, '[^0-9A-Za-z_]')";
102
+ const verdict = `multiIf(NOT (${safeId}), '${OPTIMISE_PARTITION_ACTION.skipPartitionId}',` +
103
+ ` uniqExact(arrayStringConcat(arraySort(projections), ',')) > 1, '${OPTIMISE_PARTITION_ACTION.skipProjections}',` +
104
+ ` max(has_lightweight_delete) = 1, '${OPTIMISE_PARTITION_ACTION.purge}',` +
105
+ ` countIf(disk_name != '${CLICKHOUSE_DEFAULT_DISK_NAME}') > 0, '${OPTIMISE_PARTITION_ACTION.skipCold}',` +
106
+ ` '${OPTIMISE_PARTITION_ACTION.merge}')`;
107
+ return (`SELECT concat(if(${safeId}, partition_id, concat('hex_', hex(partition_id))), ':', ${verdict})` +
108
+ ` FROM system.parts WHERE database = '${databaseName}' AND table = '${table}' AND active` +
109
+ ` GROUP BY partition_id HAVING count() > 1 OR max(has_lightweight_delete) = 1` +
110
+ ` ORDER BY partition_id DESC FORMAT TSVRaw`);
111
+ }
112
+ function sweepFunction(databaseName) {
113
+ const failedMissing = `echo "${maintenanceStatusMarker(MAINTENANCE_STATUS.failed)} reason=optimise_$FJALL_SWEEP_TABLE cause=missing_table job=$FJALL_JOB" >&2`;
114
+ const existsQuery = `SELECT count() FROM system.tables WHERE database = '${databaseName}' AND name = '$FJALL_SWEEP_TABLE'`;
115
+ const enumeration = enumerationQuery(databaseName, "$FJALL_SWEEP_TABLE");
116
+ const statement = `OPTIMIZE TABLE ${databaseName}.$FJALL_SWEEP_TABLE PARTITION ID '$FJALL_SWEEP_ID' FINAL`;
117
+ const { merge, purge, skipPartitionId } = OPTIMISE_PARTITION_ACTION;
118
+ return [
119
+ `${OPTIMISE_SWEEP_FN}() {`,
120
+ ` FJALL_SWEEP_TABLE="$1"; FJALL_SWEEP_RC=0`,
121
+ ` FJALL_SWEEP_EXISTS=$(${MAINTENANCE_RUN_FN} "exists_$FJALL_SWEEP_TABLE" "${existsQuery}") || return 1`,
122
+ ` if [ "$FJALL_SWEEP_EXISTS" != 1 ]; then ${failedMissing}; return 1; fi`,
123
+ ` FJALL_SWEEP_PARTS=$(${MAINTENANCE_RUN_FN} "enumerate_$FJALL_SWEEP_TABLE" "${enumeration}") || return 1`,
124
+ ` for FJALL_SWEEP_ENTRY in $FJALL_SWEEP_PARTS; do`,
125
+ ` FJALL_SWEEP_ID=\${FJALL_SWEEP_ENTRY%%:*}; FJALL_SWEEP_ACTION=\${FJALL_SWEEP_ENTRY#*:}`,
126
+ ` case "$FJALL_SWEEP_ID" in ''|*[!0-9A-Za-z_]*) FJALL_SWEEP_ID=unreadable; FJALL_SWEEP_ACTION=${skipPartitionId} ;; esac`,
127
+ ` case "$FJALL_SWEEP_ACTION" in`,
128
+ ` ${merge}|${purge})`,
129
+ ` echo "${OPTIMISE_PARTITION_LOG_TOKEN} table=$FJALL_SWEEP_TABLE partition=$FJALL_SWEEP_ID action=$FJALL_SWEEP_ACTION job=$FJALL_JOB" >&2`,
130
+ ` ${MAINTENANCE_RUN_FN} "optimise_$FJALL_SWEEP_TABLE" "${statement}" || FJALL_SWEEP_RC=1 ;;`,
131
+ ` *)`,
132
+ ` echo "${OPTIMISE_SKIPPED_LOG_TOKEN} table=$FJALL_SWEEP_TABLE partition=$FJALL_SWEEP_ID reason=$FJALL_SWEEP_ACTION job=$FJALL_JOB" >&2 ;;`,
133
+ ` esac`,
134
+ ` done`,
135
+ ` return "$FJALL_SWEEP_RC"`,
136
+ `}`
137
+ ].join("\n");
138
+ }
139
+ /**
140
+ * The optimise job body, to follow `buildMaintenancePreamble`. Globbing is
141
+ * switched off because the sweep word-splits the enumeration's output; every
142
+ * word is already `[0-9A-Za-z_:]`, so this is defence in depth. Exits 0 with
143
+ * `status=ok` only when every table and view settled without a failure.
144
+ */
145
+ export function buildClickHouseOptimiseScript(params) {
146
+ const { databaseName, replacingTables, mvTables } = params;
147
+ assertIdentifier("database name", databaseName);
148
+ for (const table of [...replacingTables, ...mvTables]) {
149
+ assertIdentifier("table name", table);
150
+ }
151
+ return [
152
+ "set -f",
153
+ "FAILED=0",
154
+ sweepFunction(databaseName),
155
+ ...replacingTables.map((table) => `${OPTIMISE_SWEEP_FN} ${table} || FAILED=1`),
156
+ ...mvTables.map((table) => `${maintenanceRun(`optimise_${table}`, `OPTIMIZE TABLE ${databaseName}.${table}`)} || FAILED=1`),
157
+ `if [ "$FAILED" = 0 ]; then ${maintenanceOkMarker(JOB)}; fi`,
158
+ 'exit "$FAILED"'
159
+ ].join("\n");
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "39.0.3",
3
+ "version": "39.0.4",
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": "^39.0.3",
84
- "@fjall/util": "^39.0.3",
83
+ "@fjall/generator": "^39.0.4",
84
+ "@fjall/util": "^39.0.4",
85
85
  "constructs": "^10.7.2",
86
86
  "zod": "^4.4.3"
87
87
  },