@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.
- package/dist/lib/patterns/aws/clickhouseDatabase.js +15 -21
- package/dist/lib/resources/aws/database/clickhouseBackupScript.js +2 -2
- package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +35 -7
- package/dist/lib/resources/aws/database/clickhouseConstants.js +35 -7
- package/dist/lib/resources/aws/database/clickhouseOptimiseScript.d.ts +96 -0
- package/dist/lib/resources/aws/database/clickhouseOptimiseScript.js +160 -0
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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:
|
|
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:
|
|
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 = '
|
|
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
|
-
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
-
/**
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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
|
-
/**
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
-
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
+
"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.
|
|
84
|
-
"@fjall/util": "^39.0.
|
|
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
|
},
|