@fjall/components-infrastructure 19.0.0 → 21.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.
@@ -26,7 +26,7 @@ import { FjallLogger } from "../../utils/validationLogger.js";
26
26
  import { VALIDATION_PATTERNS } from "@fjall/generator";
27
27
  import { evaluateBakeGuard } from "@fjall/util/docker";
28
28
  import { toKebab, buildParameterPath } from "@fjall/util";
29
- import { SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_ECR_REPO_NAME } from "@fjall/util/migration";
29
+ import { SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_ECR_REPO_NAME, SCHEMA_GATE_START_TIMEOUT_SECONDS } from "@fjall/util/migration";
30
30
  import { CONSTRUCTS_VERSION } from "../../utils/engineCompat.js";
31
31
  import { validateEc2ServiceSizing, validateEc2TaskMemoryFit, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
32
32
  import { validateSharedEc2CapacityConfig } from "../../resources/aws/compute/ecsCapacityConfig.js";
@@ -271,14 +271,6 @@ export function validateEcsProps(props) {
271
271
  }
272
272
  }
273
273
  const DEFAULT_MIGRATE_CONTAINER_NAME = "migrate";
274
- /**
275
- * How long dependent containers wait for the gate to reach SUCCESS before
276
- * giving up (stopping the task pre-RUNNING). ECS declares this on the
277
- * depended-ON container. 120s is the verified Fargate ceiling
278
- * (design 2026-08-10 § 4); the runner's worst-case retry budget
279
- * (3 connects × 10s + 2 sleeps × 5s, PG and CH in parallel) fits inside it.
280
- */
281
- const SCHEMA_GATE_START_TIMEOUT_SECONDS = 120;
282
274
  /**
283
275
  * Postgres-family scheme test for the gate's v1 eligibility filter. The URL
284
276
  * base is `getConnectionString()` output whose host may be an unresolved CDK
@@ -732,15 +724,13 @@ function assertMigrationSecretsResolvable(service, effectiveMigrations) {
732
724
  * container gains a `dependsOn` SUCCESS edge on it.
733
725
  * @internal Exported for testing only
734
726
  */
735
- export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope, chGate, schemaGate) {
727
+ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope, chSchemaVersionEnv, schemaGate) {
736
728
  const userContainers = service.containers && service.containers.length > 0
737
729
  ? service.containers
738
730
  : undefined;
739
731
  const expanded = service.migrations
740
732
  ? expandMigrationsSugar(service, userContainers)
741
733
  : userContainers;
742
- const chSchemaVersionEnv = chGate?.environment;
743
- const chGateSecretsImport = chGate?.secretsImport;
744
734
  const mergeSchemaEnv = (authored) => mergePgSchemaVersionEnv({
745
735
  authored,
746
736
  resolved: schemaVersionEnv,
@@ -755,18 +745,6 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
755
745
  annotationsScope,
756
746
  overrideRemedy: "set `schemaGate: false` to silence this warning."
757
747
  });
758
- const mergeChGateSecretsImport = (authored) => {
759
- if (chGateSecretsImport === undefined)
760
- return authored;
761
- if (Object.keys(chGateSecretsImport).length === 0)
762
- return authored;
763
- const merged = { ...(authored ?? {}) };
764
- for (const [k, v] of Object.entries(chGateSecretsImport)) {
765
- if (merged[k] === undefined)
766
- merged[k] = v;
767
- }
768
- return merged;
769
- };
770
748
  const mergeAllSchemaEnv = (authored) => mergeChSchemaEnv(mergeSchemaEnv(authored));
771
749
  // Prepend the synthetic gate container and wire every other container to
772
750
  // wait on its SUCCESS. In init-container mode the gate itself waits on the
@@ -817,7 +795,7 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
817
795
  port: c.port,
818
796
  environment: mergeAllSchemaEnv(c.environment),
819
797
  secrets: c.secrets,
820
- secretsImport: mergeChGateSecretsImport(c.secretsImport),
798
+ secretsImport: c.secretsImport,
821
799
  command: c.command,
822
800
  entryPoint: c.entryPoint,
823
801
  essential: c.essential,
@@ -830,14 +808,10 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
830
808
  }));
831
809
  }
832
810
  const fallbackEnv = mergeAllSchemaEnv(undefined);
833
- const fallbackSecretsImport = mergeChGateSecretsImport(undefined);
834
811
  return wireSchemaGate([
835
812
  {
836
813
  name: `${service.name}Container`,
837
- ...(fallbackEnv !== undefined && { environment: fallbackEnv }),
838
- ...(fallbackSecretsImport !== undefined && {
839
- secretsImport: fallbackSecretsImport
840
- })
814
+ ...(fallbackEnv !== undefined && { environment: fallbackEnv })
841
815
  }
842
816
  ]);
843
817
  }
@@ -919,12 +893,12 @@ export class EcsCompute extends Construct {
919
893
  this.schemaGateImageTags = schemaGateImageTags;
920
894
  const services = props.services.map((service) => {
921
895
  const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
922
- const chGate = this.resolveClickHouseSchemaVersionEnv(service);
923
- const schemaGateSpec = this.resolveSchemaGateMaterialisation(service, schemaVersionEnv, chGate);
896
+ const chSchemaVersionEnv = this.resolveClickHouseSchemaVersionEnv(service);
897
+ const schemaGateSpec = this.resolveSchemaGateMaterialisation(service, schemaVersionEnv, chSchemaVersionEnv);
924
898
  if (schemaGateSpec?.defaultImageTag !== undefined) {
925
899
  schemaGateImageTags.set(service.name, schemaGateSpec.defaultImageTag);
926
900
  }
927
- const containers = buildContainerConfigs(service, schemaVersionEnv, this, chGate, schemaGateSpec);
901
+ const containers = buildContainerConfigs(service, schemaVersionEnv, this, chSchemaVersionEnv, schemaGateSpec);
928
902
  const { scalingType, minCapacity, maxCapacity, queueScaling } = resolveScalingConfig(service.scaling);
929
903
  const cloudMapService = service.serviceDiscovery !== undefined
930
904
  ? App.getInstance().registerService({
@@ -1041,17 +1015,16 @@ export class EcsCompute extends Construct {
1041
1015
  }
1042
1016
  /**
1043
1017
  * ClickHouse mirror of `resolveSchemaVersionEnv`. Returns the
1044
- * `EXPECTED_CH_SCHEMA_VERSION` env entry alongside the schema-admin
1045
- * credentials the boot gate needs to authenticate against
1046
- * `_schema_migrations`, or `undefined` when the service is not gated.
1018
+ * `EXPECTED_CH_SCHEMA_VERSION` env entry, or `undefined` when the service
1019
+ * is not gated. Env only: app containers receive no ClickHouse credentials
1020
+ * from the gate the materialised gate container authenticates as the
1021
+ * read-only gate identity via `getSchemaGateContribution()`, composed in
1022
+ * `resolveSchemaGateMaterialisation`.
1047
1023
  *
1048
1024
  * - `schemaGate: false` → returns `undefined` (auditable opt-out — same flag
1049
1025
  * covers BOTH PG and CH gates)
1050
1026
  * - No migrated CH in connections → returns `undefined`
1051
- * - Exactly one migrated CH → returns env `EXPECTED_CH_SCHEMA_VERSION` +
1052
- * `SCHEMA_ADMIN_USER`, plus `secretsImport.SCHEMA_ADMIN_PASSWORD`. The
1053
- * creds piggyback on the same gate resolution so adding the gate to a
1054
- * service can't ship without the creds it needs to use it.
1027
+ * - Exactly one migrated CH → returns `{ EXPECTED_CH_SCHEMA_VERSION }`
1055
1028
  * - Two or more migrated CHs → throws via `resolveClickHouseDatabaseForService`
1056
1029
  */
1057
1030
  resolveClickHouseSchemaVersionEnv(service) {
@@ -1063,16 +1036,7 @@ export class EcsCompute extends Construct {
1063
1036
  const version = db.getExpectedSchemaVersion();
1064
1037
  if (version === undefined)
1065
1038
  return undefined;
1066
- const environment = {
1067
- [EXPECTED_CH_SCHEMA_VERSION_ENV]: version
1068
- };
1069
- const secretsImport = {};
1070
- const gateContribution = db.getSchemaGateContribution();
1071
- if (gateContribution !== undefined) {
1072
- Object.assign(environment, gateContribution.environment);
1073
- Object.assign(secretsImport, gateContribution.secretsImport);
1074
- }
1075
- return { environment, secretsImport };
1039
+ return { [EXPECTED_CH_SCHEMA_VERSION_ENV]: version };
1076
1040
  }
1077
1041
  /**
1078
1042
  * Decide whether the synthetic `fjall-schema-gate` container materialises
@@ -1095,17 +1059,19 @@ export class EcsCompute extends Construct {
1095
1059
  * - The default image URI cannot be derived because the constructs package
1096
1060
  * version is unresolvable and no `schemaGate.image` override is set
1097
1061
  *
1098
- * The relational half re-resolves the migrated DB for its
1099
- * `getSchemaGateContribution()` (credential-free URL base + user/password
1100
- * secret imports). The CH half composes `CLICKHOUSE_URL` / database name /
1101
- * CA-cert import from the resolved CH database `chGate` alone carries
1102
- * only the version + schema-admin entries the app containers get.
1062
+ * Both halves re-resolve the migrated DB for its
1063
+ * `getSchemaGateContribution()`: the relational half for the
1064
+ * credential-free URL base + user/password secret imports, the CH half for
1065
+ * the read-only gate identity (`FJALL_SCHEMA_GATE_CH_USER` +
1066
+ * `FJALL_SCHEMA_GATE_CH_PASSWORD`) composed with `CLICKHOUSE_URL` /
1067
+ * database name / CA-cert import — `chSchemaVersionEnv` alone carries only
1068
+ * the version entry the app containers get.
1103
1069
  */
1104
- resolveSchemaGateMaterialisation(service, schemaVersionEnv, chGate) {
1070
+ resolveSchemaGateMaterialisation(service, schemaVersionEnv, chSchemaVersionEnv) {
1105
1071
  const normalised = normaliseSchemaGate(service.schemaGate);
1106
1072
  if (!normalised.materialise)
1107
1073
  return undefined;
1108
- if (schemaVersionEnv === undefined && chGate === undefined) {
1074
+ if (schemaVersionEnv === undefined && chSchemaVersionEnv === undefined) {
1109
1075
  return undefined;
1110
1076
  }
1111
1077
  if (service.migrations?.mode === "post-deploy") {
@@ -1154,14 +1120,15 @@ export class EcsCompute extends Construct {
1154
1120
  eligibleHalves += 1;
1155
1121
  }
1156
1122
  }
1157
- if (chGate !== undefined) {
1123
+ if (chSchemaVersionEnv !== undefined) {
1158
1124
  const db = resolveClickHouseDatabaseForService(service);
1159
1125
  if (db !== undefined) {
1160
- Object.assign(environment, chGate.environment, {
1126
+ const contribution = db.getSchemaGateContribution();
1127
+ Object.assign(environment, chSchemaVersionEnv, contribution?.environment, {
1161
1128
  [SCHEMA_GATE_CH_URL_ENV]: db.getUrl(),
1162
1129
  [SCHEMA_GATE_CH_DATABASE_ENV]: db.getDatabaseName()
1163
1130
  });
1164
- Object.assign(secretsImport, chGate.secretsImport);
1131
+ Object.assign(secretsImport, contribution?.secretsImport);
1165
1132
  const caCert = db.getTlsCaCertImport();
1166
1133
  if (caCert !== undefined) {
1167
1134
  secretsImport[SCHEMA_GATE_CH_CA_CERT_ENV] = caCert;
@@ -1285,7 +1252,7 @@ export class EcsCompute extends Construct {
1285
1252
  schemaGate: entry.schemaGate
1286
1253
  };
1287
1254
  const schemaVersionEnv = this.resolveSchemaVersionEnv(gateSubject);
1288
- const chGate = this.resolveClickHouseSchemaVersionEnv(gateSubject);
1255
+ const chSchemaVersionEnv = this.resolveClickHouseSchemaVersionEnv(gateSubject);
1289
1256
  const environment = mergeChSchemaVersionEnv({
1290
1257
  authored: mergePgSchemaVersionEnv({
1291
1258
  authored: entry.environment,
@@ -1294,23 +1261,12 @@ export class EcsCompute extends Construct {
1294
1261
  annotationsScope: this,
1295
1262
  materialisedGateNote: false
1296
1263
  }),
1297
- resolved: chGate?.environment,
1264
+ resolved: chSchemaVersionEnv,
1298
1265
  subject,
1299
1266
  annotationsScope: this,
1300
1267
  overrideRemedy: "remove the env entry to accept the resolved value."
1301
1268
  });
1302
- let secrets = entry.secrets;
1303
- if (chGate !== undefined && Object.keys(chGate.secretsImport).length > 0) {
1304
- const merged = {};
1305
- for (const [key, secretImport] of Object.entries(chGate.secretsImport)) {
1306
- if (entry.secrets?.[key] !== undefined)
1307
- continue;
1308
- const secret = resolveImportedSecret(this, `${id}${toPascalCase(entry.name)}${key}Secret`, secretImport);
1309
- merged[key] = EcsSecret.fromSecretsManager(secret, secretImport.field);
1310
- }
1311
- secrets = { ...merged, ...(entry.secrets ?? {}) };
1312
- }
1313
- return { environment, secrets };
1269
+ return { environment, secrets: entry.secrets };
1314
1270
  }
1315
1271
  /**
1316
1272
  * A sibling service that consumes a hook-migrated database materialises a
@@ -262,8 +262,9 @@ export interface IClickHouseDatabase extends IDatabase, IConnectable, IMigration
262
262
  getUser(name: string): Secret;
263
263
  /**
264
264
  * Returns the Fjall `Secret` wrapper for the named user, or `undefined`
265
- * when the user is not declared. Use for optional secret contributions
266
- * (e.g. `SCHEMA_ADMIN_PASSWORD` only when a `schema_admin` user exists).
265
+ * when the user is not declared in `schemaAdmin:` / `managedPasswords:`.
266
+ * Framework identities (`fjall_schema_gate`, `fjall_maintenance`) are not
267
+ * reachable here — their secrets are wired by the construct itself.
267
268
  */
268
269
  tryGetUser(name: string): Secret | undefined;
269
270
  /**
@@ -320,19 +321,19 @@ export interface IClickHouseDatabase extends IDatabase, IConnectable, IMigration
320
321
  */
321
322
  getExpectedSchemaVersion(): string | undefined;
322
323
  /**
323
- * Schema-admin credentials for the boot-time schema-version gate run by
324
- * every container that connects to this database. Returns the username
325
- * (plaintext env) and the password-secret import (Secrets-Manager-backed
326
- * env) so callers can authenticate against `_schema_migrations`. Returns
327
- * `undefined` when no `migrations:` config is declared on this database —
328
- * the gate is the only consumer, so without migrations there's nothing
329
- * for it to verify.
324
+ * ClickHouse credentials for the materialised boot-time schema gate
325
+ * (`fjall-schema-gate` container). The gate authenticates as the read-only
326
+ * gate identity (`fjall_schema_gate`), never as the schema admin: env
327
+ * `FJALL_SCHEMA_GATE_CH_USER` carries the fixed name and secret import
328
+ * `FJALL_SCHEMA_GATE_CH_PASSWORD` its password. Returns `undefined` when
329
+ * no `migrations:` config is declared on this database the gate is the
330
+ * only consumer, so without migrations there's nothing for it to verify.
330
331
  *
331
332
  * Distinct from `getMigrationContributions()`: that's the per-migration
332
- * task contract (env + secrets + IAM + egress for running migrations);
333
- * this is the narrower per-runtime-service contract (env + secret only)
334
- * needed by the boot gate. Both contributions name the same secret —
335
- * synth-time merging is the caller's concern.
333
+ * task contract (env + secrets + IAM + egress for running migrations as
334
+ * the schema admin); this is the narrower gate-container contract (env +
335
+ * secret only). App containers receive neither they get the
336
+ * `EXPECTED_CH_SCHEMA_VERSION` env entry alone.
336
337
  */
337
338
  getSchemaGateContribution(): {
338
339
  readonly environment: Record<string, string>;
@@ -315,7 +315,8 @@ export default class EcsCluster extends Construct {
315
315
  targetGroup,
316
316
  config: typeof serviceProps.alarms === "object" ? serviceProps.alarms : {},
317
317
  alarmTopic: this.props.alertsTopic,
318
- applicationId: this.props.applicationId
318
+ applicationId: this.props.applicationId,
319
+ containerInsightsEnabled: this.props.containerInsights === true
319
320
  });
320
321
  if (serviceProps.logAlarms && serviceProps.logAlarms.length > 0) {
321
322
  createLogPatternAlarms({
@@ -751,9 +751,11 @@ export interface EcsClusterProps {
751
751
  /**
752
752
  * CloudWatch Container Insights (default off). Enabling it bills
753
753
  * ~$0.30/metric-month of custom `ECS/ContainerInsights` metrics per cluster
754
- * plus performance-log ingestion; nothing in the constructs consumes that
755
- * namespace (alarms sit on `AWS/ECS` and `Fjall/ECS`), so it is an explicit
756
- * opt-in for clusters whose operators read the per-task console breakdown.
754
+ * plus performance-log ingestion. The per-service running-tasks alarm is
755
+ * the one construct consumer of that namespace (`RunningTaskCount` exists
756
+ * nowhere else) and is skipped when insights are off; every other alarm
757
+ * sits on `AWS/ECS` and `Fjall/ECS`. Explicit opt-in for clusters whose
758
+ * operators want the running-tasks alarm or the per-task console breakdown.
757
759
  */
758
760
  containerInsights?: true;
759
761
  }
@@ -20,6 +20,8 @@
20
20
  export interface ClickHouseBackupScriptParams {
21
21
  /** Fully-formed `clickhouse-client …` invocation, minus `--query`. */
22
22
  readonly client: string;
23
+ /** The migration principal the activity pre-flight defers to. */
24
+ readonly schemaAdminName: string;
23
25
  /** Database the backup covers, and the restore compares against. */
24
26
  readonly databaseName: string;
25
27
  /** `https://<bucket>.s3.<region>.amazonaws.com/backup/` — trailing slash. */
@@ -99,5 +101,15 @@ export interface ClickHouseBackupScriptParams {
99
101
  * sets `set -eu`, but ONLY when TLS is active — so a script depending on
100
102
  * `set -e` would abort on the first failure with TLS on and blunder past it
101
103
  * with TLS off. Explicit branches behave identically either way.
104
+ *
105
+ * Statements go through the maintenance runners (`clickhouseMaintenanceScript`)
106
+ * rather than the bare client, so a `Code: 202` from the maintenance
107
+ * identity's concurrency cap is waited out instead of failing the run.
108
+ * Which runner is deliberate per statement: `fjall_ch_run` for the scratch
109
+ * drop and the BACKUP itself, whose failures have no marker of their own;
110
+ * `fjall_ch_try` for every statement whose failure the script already
111
+ * reports (the restore's FAILED branch, the SKIPPED probe branch) or
112
+ * tolerates (the row/table reads, the trailing drop) — a second failed-status
113
+ * marker there would page the same event twice.
102
114
  */
103
115
  export declare function buildClickHouseBackupScript(params: ClickHouseBackupScriptParams): string;
@@ -18,7 +18,9 @@
18
18
  * behaviour the alarm is built on.
19
19
  */
20
20
  import { CLICKHOUSE_BACKUP_SCRATCH_DATABASE, CLICKHOUSE_BACKUP_VERIFY_FREE_SPACE_MARGIN, CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER, CLICKHOUSE_BACKUP_VERIFY_OK_MARKER, CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER, clickHouseBackupVerifyTimeoutSeconds } from "./clickhouseConstants.js";
21
+ import { buildMaintenancePreamble, maintenanceOkMarker, maintenanceRun, maintenanceTry } from "./clickhouseMaintenanceScript.js";
21
22
  const BYTES_PER_GIB = 1024 ** 3;
23
+ const JOB = "backup";
22
24
  /**
23
25
  * Builds the backup + restore-verify script.
24
26
  *
@@ -83,23 +85,39 @@ const BYTES_PER_GIB = 1024 ** 3;
83
85
  * sets `set -eu`, but ONLY when TLS is active — so a script depending on
84
86
  * `set -e` would abort on the first failure with TLS on and blunder past it
85
87
  * with TLS off. Explicit branches behave identically either way.
88
+ *
89
+ * Statements go through the maintenance runners (`clickhouseMaintenanceScript`)
90
+ * rather than the bare client, so a `Code: 202` from the maintenance
91
+ * identity's concurrency cap is waited out instead of failing the run.
92
+ * Which runner is deliberate per statement: `fjall_ch_run` for the scratch
93
+ * drop and the BACKUP itself, whose failures have no marker of their own;
94
+ * `fjall_ch_try` for every statement whose failure the script already
95
+ * reports (the restore's FAILED branch, the SKIPPED probe branch) or
96
+ * tolerates (the row/table reads, the trailing drop) — a second failed-status
97
+ * marker there would page the same event twice.
86
98
  */
87
99
  export function buildClickHouseBackupScript(params) {
88
- const { client, databaseName, backupDestUrl, diskFreeCriticalGib, storageGb, verify } = params;
100
+ const { client, schemaAdminName, databaseName, backupDestUrl, diskFreeCriticalGib, storageGb, verify } = params;
101
+ const preamble = buildMaintenancePreamble({
102
+ client,
103
+ job: JOB,
104
+ schemaAdminName
105
+ });
89
106
  const backupStatements = [
90
107
  "STAMP=$(date +%Y%m%d-%H%M%S)",
91
108
  `DEST="${backupDestUrl}backup-$STAMP/"`,
92
- `${client} --query "BACKUP DATABASE ${databaseName} TO S3('$DEST')" || exit 1`
109
+ `${maintenanceRun("backup", `BACKUP DATABASE ${databaseName} TO S3('$DEST')`)} || exit 1`
93
110
  ];
94
111
  if (!verify) {
95
- return backupStatements.join("; ");
112
+ return `${preamble}\n${[...backupStatements, maintenanceOkMarker(JOB)].join("; ")}`;
96
113
  }
97
114
  const scratch = CLICKHOUSE_BACKUP_SCRATCH_DATABASE;
98
115
  const requiredFreeBytes = Math.round(diskFreeCriticalGib *
99
116
  BYTES_PER_GIB *
100
117
  CLICKHOUSE_BACKUP_VERIFY_FREE_SPACE_MARGIN);
101
118
  const verifyTimeoutSeconds = clickHouseBackupVerifyTimeoutSeconds(storageGb);
102
- const dropScratch = `${client} --query "DROP DATABASE IF EXISTS ${scratch} SYNC"`;
119
+ const dropScratchSql = `DROP DATABASE IF EXISTS ${scratch} SYNC`;
120
+ const dropScratchTolerated = maintenanceTry("drop_scratch", dropScratchSql);
103
121
  const sourceRowsQuery = `SELECT sum(rows) FROM system.parts WHERE database = '${databaseName}' AND active`;
104
122
  const fitVerdictQuery = `SELECT if((SELECT free_space FROM system.disks WHERE name = 'default')` +
105
123
  ` - (SELECT sum(bytes_on_disk) FROM system.parts WHERE database = '${databaseName}' AND active)` +
@@ -107,17 +125,19 @@ export function buildClickHouseBackupScript(params) {
107
125
  const missingTablesQuery = `SELECT count() FROM (SELECT name FROM system.tables WHERE database = '${databaseName}' AND name NOT LIKE '.inner%'` +
108
126
  ` EXCEPT SELECT name FROM system.tables WHERE database = '${scratch}' AND name NOT LIKE '.inner%')`;
109
127
  const restoredRowsQuery = `SELECT sum(rows) FROM system.parts WHERE database = '${scratch}' AND active`;
110
- return [
111
- `${dropScratch} || exit 1`,
112
- `SRCROWS=$(${client} --query "${sourceRowsQuery}") || SRCROWS=unknown`,
128
+ const body = [
129
+ `${maintenanceRun("drop_scratch", dropScratchSql)} || exit 1`,
130
+ `SRCROWS=$(${maintenanceTry("source_rows", sourceRowsQuery)}) || SRCROWS=unknown`,
113
131
  ...backupStatements,
114
- `FIT=$(${client} --query "${fitVerdictQuery}") || { echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST free-space probe failed — verify did not run"; exit 1; }`,
115
- `if [ "$FIT" != "FITS" ]; then echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST restore would not leave ${requiredFreeBytes} bytes free"; exit 0; fi`,
116
- `timeout ${verifyTimeoutSeconds} ${client} --query "RESTORE DATABASE ${databaseName} AS ${scratch} FROM S3('$DEST')" || { echo "${CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER} $DEST restore raised or exceeded ${verifyTimeoutSeconds}s"; ${dropScratch} || true; exit 1; }`,
117
- `MISSING=$(${client} --query "${missingTablesQuery}") || MISSING=unknown`,
118
- `ROWS=$(${client} --query "${restoredRowsQuery}") || ROWS=0`,
119
- `${dropScratch} || true`,
132
+ `FIT=$(${maintenanceTry("free_space_probe", fitVerdictQuery)}) || { echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST free-space probe failed — verify did not run"; exit 1; }`,
133
+ `if [ "$FIT" != "FITS" ]; then echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST restore would not leave ${requiredFreeBytes} bytes free"; ${maintenanceOkMarker(JOB)}; exit 0; fi`,
134
+ `${maintenanceTry("restore", `RESTORE DATABASE ${databaseName} AS ${scratch} FROM S3('$DEST')`, verifyTimeoutSeconds)} || { echo "${CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER} $DEST restore raised or exceeded ${verifyTimeoutSeconds}s"; ${dropScratchTolerated} || true; exit 1; }`,
135
+ `MISSING=$(${maintenanceTry("missing_tables", missingTablesQuery)}) || MISSING=unknown`,
136
+ `ROWS=$(${maintenanceTry("restored_rows", restoredRowsQuery)}) || ROWS=0`,
137
+ `${dropScratchTolerated} || true`,
120
138
  `if [ "$MISSING" != "0" ] || { [ "$ROWS" = "0" ] && [ "$SRCROWS" != "0" ]; }; then echo "${CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER} $DEST missing_tables=$MISSING restored_rows=$ROWS source_rows=$SRCROWS"; exit 1; fi`,
121
- `echo "${CLICKHOUSE_BACKUP_VERIFY_OK_MARKER} $DEST restored_rows=$ROWS source_rows=$SRCROWS"`
139
+ `echo "${CLICKHOUSE_BACKUP_VERIFY_OK_MARKER} $DEST restored_rows=$ROWS source_rows=$SRCROWS"`,
140
+ maintenanceOkMarker(JOB)
122
141
  ].join("; ");
142
+ return `${preamble}\n${body}`;
123
143
  }
@@ -303,8 +303,36 @@ export declare const BACKUP_TASK_CPU_UNITS = 256;
303
303
  export declare const BACKUP_RETENTION_DAYS = 14;
304
304
  /** Scratch database the backup task restores into to prove the backup it just
305
305
  * wrote can be read back. Dropped on the way in and on the way out; never
306
- * queried by the application. */
307
- export declare const CLICKHOUSE_BACKUP_SCRATCH_DATABASE = "fjall_backup_verify";
306
+ * queried by the application. Canonical source is `@fjall/util/migration`
307
+ * because `@fjall/clickhouse` grants `fjall_maintenance` DROP on exactly
308
+ * this database — coupled values per `.claude/rules/code-quality.md §
309
+ * "Coupled values: shared source at 2 occurrences"`. */
310
+ export { CLICKHOUSE_BACKUP_SCRATCH_DATABASE } from "@fjall/util/migration";
311
+ /** The two scheduled maintenance jobs that authenticate as
312
+ * `fjall_maintenance`. Drives the per-job log-group → metric-filter wiring
313
+ * in `createClickHouseAlarms` and the `job=` field of the status markers. */
314
+ export declare const CLICKHOUSE_MAINTENANCE_JOBS: readonly ["optimise", "backup"];
315
+ export type ClickHouseMaintenanceJob = (typeof CLICKHOUSE_MAINTENANCE_JOBS)[number];
316
+ /** Total wall-clock budget a maintenance statement may spend waiting out
317
+ * `Code: 202` (TOO_MANY_SIMULTANEOUS_QUERIES) from the maintenance
318
+ * identity's `max_concurrent_queries_for_user = 1` cap before the job
319
+ * reports `status=failed reason=busy`. Ten minutes: long enough to outlast
320
+ * the sibling sidecar's per-statement gaps, short enough that a wedged
321
+ * holder pages inside the hour the failed-status alarm samples. */
322
+ export declare const MAINTENANCE_BUSY_BUDGET_SECONDS = 600;
323
+ /** Prefix of the maintenance sidecars' status log markers. The CloudWatch
324
+ * metric filters match `${prefix}${status}` as a whole token, so the
325
+ * three statuses must remain distinct full words after the `=`. */
326
+ export declare const MAINTENANCE_STATUS_MARKER_PREFIX = "fjall:maintenance:status=";
327
+ export declare const MAINTENANCE_STATUS: {
328
+ readonly ok: "ok";
329
+ readonly deferred: "deferred";
330
+ readonly failed: "failed";
331
+ };
332
+ export type MaintenanceStatus = (typeof MAINTENANCE_STATUS)[keyof typeof MAINTENANCE_STATUS];
333
+ /** The log token a maintenance status marker line starts with — shared by
334
+ * the script builders (emit) and the alarm factory (match). */
335
+ export declare function maintenanceStatusMarker(status: MaintenanceStatus): string;
308
336
  /** Multiple of the critical free-space floor the verify restore must leave
309
337
  * clear to proceed.
310
338
  *
@@ -345,8 +345,36 @@ export const BACKUP_TASK_CPU_UNITS = 256;
345
345
  export const BACKUP_RETENTION_DAYS = 14;
346
346
  /** Scratch database the backup task restores into to prove the backup it just
347
347
  * wrote can be read back. Dropped on the way in and on the way out; never
348
- * queried by the application. */
349
- export const CLICKHOUSE_BACKUP_SCRATCH_DATABASE = "fjall_backup_verify";
348
+ * queried by the application. Canonical source is `@fjall/util/migration`
349
+ * because `@fjall/clickhouse` grants `fjall_maintenance` DROP on exactly
350
+ * this database — coupled values per `.claude/rules/code-quality.md §
351
+ * "Coupled values: shared source at 2 occurrences"`. */
352
+ export { CLICKHOUSE_BACKUP_SCRATCH_DATABASE } from "@fjall/util/migration";
353
+ /** The two scheduled maintenance jobs that authenticate as
354
+ * `fjall_maintenance`. Drives the per-job log-group → metric-filter wiring
355
+ * in `createClickHouseAlarms` and the `job=` field of the status markers. */
356
+ export const CLICKHOUSE_MAINTENANCE_JOBS = ["optimise", "backup"];
357
+ /** Total wall-clock budget a maintenance statement may spend waiting out
358
+ * `Code: 202` (TOO_MANY_SIMULTANEOUS_QUERIES) from the maintenance
359
+ * identity's `max_concurrent_queries_for_user = 1` cap before the job
360
+ * reports `status=failed reason=busy`. Ten minutes: long enough to outlast
361
+ * the sibling sidecar's per-statement gaps, short enough that a wedged
362
+ * holder pages inside the hour the failed-status alarm samples. */
363
+ export const MAINTENANCE_BUSY_BUDGET_SECONDS = 600;
364
+ /** Prefix of the maintenance sidecars' status log markers. The CloudWatch
365
+ * metric filters match `${prefix}${status}` as a whole token, so the
366
+ * three statuses must remain distinct full words after the `=`. */
367
+ export const MAINTENANCE_STATUS_MARKER_PREFIX = "fjall:maintenance:status=";
368
+ export const MAINTENANCE_STATUS = {
369
+ ok: "ok",
370
+ deferred: "deferred",
371
+ failed: "failed"
372
+ };
373
+ /** The log token a maintenance status marker line starts with — shared by
374
+ * the script builders (emit) and the alarm factory (match). */
375
+ export function maintenanceStatusMarker(status) {
376
+ return `${MAINTENANCE_STATUS_MARKER_PREFIX}${status}`;
377
+ }
350
378
  /** Multiple of the critical free-space floor the verify restore must leave
351
379
  * clear to proceed.
352
380
  *
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shell fragments shared by the two maintenance sidecars (OPTIMIZE FINAL and
3
+ * BACKUP), which both authenticate as `fjall_maintenance` under a
4
+ * `max_concurrent_queries_for_user = 1` cap.
5
+ *
6
+ * Two things the cap forces on every statement the sidecars issue:
7
+ *
8
+ * 1. **The migrate ↔ maintenance mutex.** A migration holds the DDL slot for
9
+ * minutes; a maintenance job that started underneath it would either
10
+ * contend for parts the migration is rewriting or — worse — make the
11
+ * migration's own probes lose the slot. So each job opens with an activity
12
+ * pre-flight (`buildControlPlaneActivityQuery` against the schema admin)
13
+ * and exits `deferred` (0, a metric but no page) when a migration is under
14
+ * way. The runner asks the mirror question about `fjall_maintenance`.
15
+ *
16
+ * 2. **`Code: 202` is a wait, not a failure.** With a cap of one, the second
17
+ * query from the same identity is refused with TOO_MANY_SIMULTANEOUS_
18
+ * QUERIES. That is the sibling sidecar (or this job's own previous
19
+ * statement still winding down), so the statement runner sleeps with
20
+ * bounded backoff and retries until `MAINTENANCE_BUSY_BUDGET_SECONDS` is
21
+ * spent — only then is it a failure, and it says `reason=busy` so the
22
+ * responder knows to look for the holder rather than the statement.
23
+ *
24
+ * Every marker goes to stderr. The backup script captures several statement
25
+ * results with `$(…)`, and a marker on stdout would land in the captured
26
+ * value; ECS awslogs carries both streams to the same log group, which is
27
+ * where the metric filters read them.
28
+ *
29
+ * `sh` only (the ClickHouse image's `/bin/sh` is dash): no `$RANDOM`, no
30
+ * arrays, no `pipefail`. Jitter comes from the nanosecond clock instead —
31
+ * `%s%N` rather than `%N`, because a bare `%N` is zero-padded and a leading
32
+ * zero makes dash read the number as octal.
33
+ *
34
+ * The TLS preamble these fragments follow sets `set -eu` ONLY when TLS is
35
+ * active, so nothing here relies on `-e`: every failure path is an explicit
36
+ * `||` / `if` branch and the function bodies use `… && return 0` shapes that
37
+ * behave identically with or without it.
38
+ */
39
+ import { type ClickHouseMaintenanceJob } from "./clickhouseConstants.js";
40
+ /** Runs a statement; on failure emits `status=failed reason=<label>` (or
41
+ * `reason=busy label=<label>` when the busy budget ran out) and returns 1.
42
+ * For statements whose failure the job treats as fatal. */
43
+ export declare const MAINTENANCE_RUN_FN = "fjall_ch_run";
44
+ /** Runs a statement with the same `Code: 202` retry loop but emits NO
45
+ * failed-status marker on a non-busy failure — the caller owns that branch
46
+ * (a tolerated read, or a statement with its own marker such as the backup
47
+ * restore). The busy-exhausted marker is still emitted: a statement that
48
+ * never got a slot is always a job failure. */
49
+ export declare const MAINTENANCE_TRY_FN = "fjall_ch_try";
50
+ export interface MaintenancePreambleParams {
51
+ /** Fully-formed `clickhouse-client …` invocation, minus `--query`. */
52
+ readonly client: string;
53
+ readonly job: ClickHouseMaintenanceJob;
54
+ /** The migration principal — the other side of the mutex. */
55
+ readonly schemaAdminName: string;
56
+ }
57
+ /**
58
+ * Newline-joined preamble: sets `FJALL_JOB`, defines the two statement
59
+ * runners, then runs the activity pre-flight. Exits the script itself on
60
+ * `deferred` (0) or a probe failure (1), so the job body that follows only
61
+ * ever runs when no migration is active.
62
+ */
63
+ export declare function buildMaintenancePreamble(params: MaintenancePreambleParams): string;
64
+ /** `fjall_ch_run <label> "<sql>" [timeoutSeconds]` — see `MAINTENANCE_RUN_FN`. */
65
+ export declare function maintenanceRun(label: string, sql: string, timeoutSeconds?: number): string;
66
+ /** `fjall_ch_try <label> "<sql>" [timeoutSeconds]` — see `MAINTENANCE_TRY_FN`. */
67
+ export declare function maintenanceTry(label: string, sql: string, timeoutSeconds?: number): string;
68
+ /** The job's terminal success line. */
69
+ export declare function maintenanceOkMarker(job: ClickHouseMaintenanceJob): string;