@fjall/components-infrastructure 39.0.1 → 39.0.3

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.
@@ -0,0 +1,41 @@
1
+ import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
2
+ import { type ICluster } from "aws-cdk-lib/aws-ecs";
3
+ /** Where the user data installs the gate on the instance. */
4
+ export declare const ECS_CAPACITY_PROVIDER_GATE_PATH = "/usr/local/bin/fjall-ecs-capacity-provider-gate";
5
+ /** Every line the gate prints starts with this, so a console read can find it. */
6
+ export declare const ECS_CAPACITY_PROVIDER_GATE_LOG_PREFIX = "fjall-ecs-capacity-provider-gate:";
7
+ /**
8
+ * Holds an instance's first ECS registration until a capacity provider naming
9
+ * its Auto Scaling group is ACTIVE and associated with the cluster.
10
+ *
11
+ * ECS binds a container instance to a capacity provider only if the provider
12
+ * already exists when the instance calls RegisterContainerInstance. An
13
+ * instance that registers first stays unbound, and a service whose strategy
14
+ * names only capacity providers never places a task on it. CloudFormation
15
+ * creates the provider after the group is complete, so the group's first
16
+ * instances race it (fjall-deploy-tests #94: the provider was created in the
17
+ * same second the instance registered, and no task was ever created). The
18
+ * ECS agent starts after cloud-final, so user data that waits here delays the
19
+ * registration.
20
+ *
21
+ * Arguments: cluster name, then optional overall budget, poll interval and
22
+ * error budget in seconds. It prints only the gate's own lines, AWS error
23
+ * codes, and the group, provider and cluster names. It exits 0 on every path:
24
+ * a gate that cannot decide lets the agent register rather than hold the
25
+ * boot. It never contains `${`, so a renderer that substitutes `${…}` (as
26
+ * `Fn::Sub` does) passes it through unchanged.
27
+ */
28
+ export declare const ECS_CAPACITY_PROVIDER_GATE_SCRIPT: string;
29
+ /**
30
+ * Makes the group's instances wait for their capacity provider before the ECS
31
+ * agent registers them (see {@link ECS_CAPACITY_PROVIDER_GATE_SCRIPT}), and
32
+ * grants the two reads the wait needs.
33
+ *
34
+ * It applies wherever `cluster.addAsgCapacityProvider` writes the
35
+ * `ECS_CLUSTER` line as shell, which is every group that is not Windows.
36
+ * Called before that, the block lands ahead of the line, so it is guarded by
37
+ * `|| true`: user data may run under `set -e` (ClickHouse's does), and a
38
+ * failure here would stop it before `ECS_CLUSTER` is written, sending the
39
+ * agent to the default cluster.
40
+ */
41
+ export declare function addEcsCapacityProviderGate(autoScalingGroup: AutoScalingGroup, cluster: ICluster): void;
@@ -0,0 +1,116 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { OperatingSystemType } from "aws-cdk-lib/aws-ec2";
3
+ import { PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ /** Where the user data installs the gate on the instance. */
5
+ export const ECS_CAPACITY_PROVIDER_GATE_PATH = "/usr/local/bin/fjall-ecs-capacity-provider-gate";
6
+ /** Every line the gate prints starts with this, so a console read can find it. */
7
+ export const ECS_CAPACITY_PROVIDER_GATE_LOG_PREFIX = "fjall-ecs-capacity-provider-gate:";
8
+ const HEREDOC_DELIMITER = "FJALL_ECS_CAPACITY_PROVIDER_GATE";
9
+ /**
10
+ * Holds an instance's first ECS registration until a capacity provider naming
11
+ * its Auto Scaling group is ACTIVE and associated with the cluster.
12
+ *
13
+ * ECS binds a container instance to a capacity provider only if the provider
14
+ * already exists when the instance calls RegisterContainerInstance. An
15
+ * instance that registers first stays unbound, and a service whose strategy
16
+ * names only capacity providers never places a task on it. CloudFormation
17
+ * creates the provider after the group is complete, so the group's first
18
+ * instances race it (fjall-deploy-tests #94: the provider was created in the
19
+ * same second the instance registered, and no task was ever created). The
20
+ * ECS agent starts after cloud-final, so user data that waits here delays the
21
+ * registration.
22
+ *
23
+ * Arguments: cluster name, then optional overall budget, poll interval and
24
+ * error budget in seconds. It prints only the gate's own lines, AWS error
25
+ * codes, and the group, provider and cluster names. It exits 0 on every path:
26
+ * a gate that cannot decide lets the agent register rather than hold the
27
+ * boot. It never contains `${`, so a renderer that substitutes `${…}` (as
28
+ * `Fn::Sub` does) passes it through unchanged.
29
+ */
30
+ export const ECS_CAPACITY_PROVIDER_GATE_SCRIPT = String.raw `#!/bin/bash
31
+ cluster=$1 budget=$2 interval=$3 error_budget=$4
32
+ [ -n "$budget" ] || budget=600
33
+ [ -n "$interval" ] || interval=5
34
+ [ -n "$error_budget" ] || error_budget=120
35
+ say() { echo "${ECS_CAPACITY_PROVIDER_GATE_LOG_PREFIX} $*"; }
36
+ [ -n "$cluster" ] || { say "no cluster named, not waiting"; exit 0; }
37
+ command -v aws >/dev/null 2>&1 || { say "no AWS CLI, not waiting"; exit 0; }
38
+ err=$(mktemp) || { say "no temporary file, not waiting"; exit 0; }
39
+ trap 'rm -f "$err"' EXIT
40
+ imds() { curl -sf --max-time 5 -H "X-aws-ec2-metadata-token: $token" "http://169.254.169.254/latest/meta-data/$1"; }
41
+ ecs() { aws ecs "$@" --region "$region" --output text --cli-connect-timeout 5 --cli-read-timeout 10 2>"$err"; }
42
+ failed() {
43
+ code=$(sed -n 's/.*An error occurred (\([A-Za-z0-9.]*\)).*/\1/p' "$err" | head -n 1)
44
+ [ -n "$code" ] || code=CallFailed
45
+ }
46
+ asg= region= since= last= waiting=
47
+ while :; do
48
+ code=
49
+ if [ -z "$asg" ] || [ -z "$region" ]; then
50
+ token=$(curl -sf --max-time 5 -X PUT http://169.254.169.254/latest/api/token -H "X-aws-ec2-metadata-token-ttl-seconds: 300")
51
+ asg=$(imds tags/instance/aws:autoscaling:groupName) region=$(imds placement/region)
52
+ [ -n "$asg" ] && [ -n "$region" ] || code=InstanceMetadataUnavailable
53
+ fi
54
+ if [ -n "$code" ]; then :
55
+ elif ! listed=$(ecs describe-clusters --clusters "$cluster" --query 'clusters[0].capacityProviders'); then failed
56
+ else
57
+ names=
58
+ for name in $listed; do case $name in None|FARGATE|FARGATE_SPOT) ;; *) names="$names $name" ;; esac; done
59
+ if [ -z "$names" ]; then :
60
+ elif ! rows=$(ecs describe-capacity-providers --capacity-providers $names --query "capacityProviders[?status=='ACTIVE'].[name,autoScalingGroupProvider.autoScalingGroupArn]"); then failed
61
+ else
62
+ while read -r name group; do
63
+ case $group in "$asg"|*":autoScalingGroupName/$asg")
64
+ say "capacity provider $name names $asg on cluster $cluster after $SECONDS s, registering"; exit 0 ;;
65
+ esac
66
+ done <<EOF
67
+ $rows
68
+ EOF
69
+ fi
70
+ fi
71
+ if [ -n "$code" ]; then
72
+ [ -n "$since" ] || since=$SECONDS
73
+ [ "$code" = "$last" ] || { say "cannot check yet: $code"; last=$code; }
74
+ [ $((SECONDS - since)) -lt "$error_budget" ] || { say "gave up after $SECONDS s on $code, registering unbound"; exit 0; }
75
+ else
76
+ since= last=
77
+ [ -n "$waiting" ] || { say "waiting for a capacity provider naming $asg on cluster $cluster"; waiting=1; }
78
+ fi
79
+ [ "$SECONDS" -lt "$budget" ] || { say "gave up after $SECONDS s, no capacity provider names $asg on cluster $cluster, registering unbound"; exit 0; }
80
+ sleep "$interval"
81
+ done
82
+ `;
83
+ /**
84
+ * Makes the group's instances wait for their capacity provider before the ECS
85
+ * agent registers them (see {@link ECS_CAPACITY_PROVIDER_GATE_SCRIPT}), and
86
+ * grants the two reads the wait needs.
87
+ *
88
+ * It applies wherever `cluster.addAsgCapacityProvider` writes the
89
+ * `ECS_CLUSTER` line as shell, which is every group that is not Windows.
90
+ * Called before that, the block lands ahead of the line, so it is guarded by
91
+ * `|| true`: user data may run under `set -e` (ClickHouse's does), and a
92
+ * failure here would stop it before `ECS_CLUSTER` is written, sending the
93
+ * agent to the default cluster.
94
+ */
95
+ export function addEcsCapacityProviderGate(autoScalingGroup, cluster) {
96
+ if (autoScalingGroup.osType === OperatingSystemType.WINDOWS)
97
+ return;
98
+ autoScalingGroup.addUserData("{", `cat > ${ECS_CAPACITY_PROVIDER_GATE_PATH} <<'${HEREDOC_DELIMITER}'`, ECS_CAPACITY_PROVIDER_GATE_SCRIPT.trimEnd(), HEREDOC_DELIMITER, `chmod 755 ${ECS_CAPACITY_PROVIDER_GATE_PATH}`, `${ECS_CAPACITY_PROVIDER_GATE_PATH} '${cluster.clusterName}'`, "} || true");
99
+ autoScalingGroup.addToRolePolicy(new PolicyStatement({
100
+ actions: ["ecs:DescribeClusters"],
101
+ resources: [cluster.clusterArn]
102
+ }));
103
+ // A provider's name is not known until CloudFormation creates it, and
104
+ // naming it here would make the role's policy wait for the provider the
105
+ // instances are waiting for.
106
+ autoScalingGroup.addToRolePolicy(new PolicyStatement({
107
+ actions: ["ecs:DescribeCapacityProviders"],
108
+ resources: [
109
+ Stack.of(autoScalingGroup).formatArn({
110
+ service: "ecs",
111
+ resource: "capacity-provider",
112
+ resourceName: "*"
113
+ })
114
+ ]
115
+ }));
116
+ }
@@ -8,6 +8,7 @@ import { Alarm, ComparisonOperator, MathExpression } from "aws-cdk-lib/aws-cloud
8
8
  import { ApplicationScalingAction } from "aws-cdk-lib/aws-cloudwatch-actions";
9
9
  import { SecurityGroup } from "../networking/securityGroup.js";
10
10
  import { Ec2Instance } from "./ec2.js";
11
+ import { addEcsCapacityProviderGate } from "./ecsCapacityProviderGate.js";
11
12
  import { vpcHasNatGateways } from "../../../utils/vpcUtils.js";
12
13
  import { toPascalCase } from "../../../utils/capitaliseString.js";
13
14
  import { stackScopedExportName } from "../../../utils/exportNaming.js";
@@ -176,6 +177,9 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
176
177
  instanceMonitoring: ec2Config.instanceMonitoring ?? DEFAULT_EC2_INSTANCE_MONITORING,
177
178
  capacityRebalance: true,
178
179
  ecsClusterArn: ctx.cluster.clusterArn,
180
+ ...(ctx.props.alertsTopic !== undefined && {
181
+ alertsTopic: ctx.props.alertsTopic
182
+ }),
179
183
  ...(ec2Config.desiredCapacity !== undefined && {
180
184
  desiredCapacity: ec2Config.desiredCapacity
181
185
  }),
@@ -207,6 +211,7 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
207
211
  ...(ec2Config.tags !== undefined && { tags: ec2Config.tags })
208
212
  });
209
213
  const asg = ec2Instance.getAutoScalingGroup();
214
+ addEcsCapacityProviderGate(asg, ctx.cluster);
210
215
  const provider = new AsgCapacityProvider(ctx.scope, `${anchor}AsgCapacityProvider`, {
211
216
  autoScalingGroup: asg,
212
217
  enableManagedDraining: true,
@@ -170,11 +170,12 @@ export interface Ec2CapacityConfig {
170
170
  availabilityZones?: string[];
171
171
  /**
172
172
  * Pairs the EC2 capacity ASG with a standalone EBS data volume that
173
- * re-attaches across instance refreshes. Forwarded to `Ec2Instance` which
174
- * locates and detaches the volume via TERMINATING/LAUNCHING lifecycle
175
- * hooks. Implies a singleton service — a second service declaring the
176
- * same slot is rejected at synth (`assertSharedAsgConfigMatches`); give
177
- * it its own slot instead.
173
+ * re-attaches across instance refreshes. Forwarded to `Ec2Instance`, whose
174
+ * LAUNCHING lifecycle hook attaches the volume to a new instance once the
175
+ * instance that held it has terminated. Implies a singleton service — a
176
+ * second service declaring the same slot is rejected at synth
177
+ * (`assertSharedAsgConfigMatches`); give it its own slot instead. Cannot be
178
+ * combined with `warmPool`.
178
179
  */
179
180
  persistentDataVolume?: Ec2InstancePersistentDataVolumeConfig;
180
181
  /**
@@ -3,6 +3,7 @@ import { type Bucket } from "aws-cdk-lib/aws-s3";
3
3
  import { PolicyStatement, type IRole } from "aws-cdk-lib/aws-iam";
4
4
  import { type IVpc } from "aws-cdk-lib/aws-ec2";
5
5
  import type { RetentionDays } from "aws-cdk-lib/aws-logs";
6
+ import { LogGroup } from "../logging/logGroup.js";
6
7
  import { type BackupTier } from "../../../utils/backupTierMapping.js";
7
8
  import { type IQueue } from "aws-cdk-lib/aws-sqs";
8
9
  import { type ITable } from "aws-cdk-lib/aws-dynamodb";
@@ -127,6 +128,8 @@ export declare class SingletonFunction extends singletonFunction {
127
128
  get executionRole(): IRole | undefined;
128
129
  }
129
130
  export declare class LambdaFunction extends Function {
131
+ /** The explicit log group this function writes to. */
132
+ readonly functionLogGroup: LogGroup;
130
133
  private functionUrlValue?;
131
134
  private readonly asyncFailureQueueProp?;
132
135
  private asyncFailureQueueWired?;
@@ -172,6 +172,8 @@ export class SingletonFunction extends singletonFunction {
172
172
  }
173
173
  }
174
174
  export class LambdaFunction extends Function {
175
+ /** The explicit log group this function writes to. */
176
+ functionLogGroup;
175
177
  functionUrlValue;
176
178
  asyncFailureQueueProp;
177
179
  asyncFailureQueueWired;
@@ -187,6 +189,12 @@ export class LambdaFunction extends Function {
187
189
  // Grant-based: Lambda encrypts the environment through a KMS grant the
188
190
  // deploying role creates, so the execution role needs no key policy.
189
191
  const encryptionKey = StackEncryptionKey.forTier(scope, appBackupTier);
192
+ const functionLogGroup = new LogGroup(scope, `${id}LogGroup`, {
193
+ retention: props.logGroupRetention ?? DEFAULT_FRAMEWORK_LOG_RETENTION,
194
+ ...(encryptionKey !== undefined && {
195
+ encryptionKey: encryptionKey.forLogGroups()
196
+ })
197
+ });
190
198
  super(scope, id, {
191
199
  ...cdkProps,
192
200
  vpcSubnets,
@@ -201,13 +209,9 @@ export class LambdaFunction extends Function {
201
209
  : undefined,
202
210
  description: props.lambdaDescription ?? `${id} Lambda`,
203
211
  environment: props.environment,
204
- logGroup: new LogGroup(scope, `${id}LogGroup`, {
205
- retention: props.logGroupRetention ?? DEFAULT_FRAMEWORK_LOG_RETENTION,
206
- ...(encryptionKey !== undefined && {
207
- encryptionKey: encryptionKey.forLogGroups()
208
- })
209
- })
212
+ logGroup: functionLogGroup
210
213
  });
214
+ this.functionLogGroup = functionLogGroup;
211
215
  this.asyncFailureQueueProp = props.asyncFailureQueue;
212
216
  addPoliciesToRole(this, props.inlinePolicy);
213
217
  applyRoleDescription(this, props.roleDescription);
@@ -13,6 +13,13 @@ import { SQSQueue } from "../messaging/sqs.js";
13
13
  * not silently break the test query.
14
14
  */
15
15
  export declare const PERSISTENT_DATA_VOLUME_LAUNCHING_DESCRIPTION = "PersistentDataVolume launching handler";
16
+ /**
17
+ * How long a launching instance waits for its data volume. The Lambda's full
18
+ * attempt must fit inside it, and the instance's user data waits this long
19
+ * for the device to appear: the hook ABANDONs the instance when it expires,
20
+ * so waiting any longer on the instance buys nothing.
21
+ */
22
+ export declare const PERSISTENT_DATA_VOLUME_LAUNCHING_HEARTBEAT_SECONDS = 900;
16
23
  export declare const PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE = "fjall:Lifecycle";
17
24
  export declare const PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE = "data-volume";
18
25
  export declare const PERSISTENT_DATA_VOLUME_TAG_OWNER_LOGICAL_ID = "fjall:OwnerLogicalId";
@@ -66,11 +73,11 @@ export interface PersistentDataVolumeProps {
66
73
  * Standalone EBS data volume + ASG `EC2_INSTANCE_LAUNCHING` hook that
67
74
  * re-attaches the same physical volume across instance refreshes.
68
75
  *
69
- * Pairs with the TERMINATING-side DetachVolume branch in
70
- * `ec2GracefulTerminationLambda.source.cjs` (see `DetachVolumeCommand` import
71
- * + invocation) wired by `ec2GracefulTerminationHandler.ts` when
72
- * `dataVolumeOwnerLogicalId` is forwarded from `Ec2Instance`. The two
73
- * Lambdas form a closed re-attach contract.
76
+ * Nothing detaches the volume. The instance holding it releases it when it
77
+ * terminates, after `Ec2GracefulTerminationHandler` has proven its tasks
78
+ * stopped; the LAUNCHING Lambda waits for that release and then attaches the
79
+ * volume to the new instance. A forced detach would pull the filesystem out
80
+ * from under a database that is still running.
74
81
  *
75
82
  * Volume tagging: three tags carry the re-attach identity —
76
83
  * `fjall:Lifecycle = data-volume` (constant; gates IAM scoping)
@@ -82,9 +89,7 @@ export interface PersistentDataVolumeProps {
82
89
  * StackId and cannot collide with the new deploy's volume)
83
90
  *
84
91
  * The LAUNCHING Lambda filters DescribeVolumes by all three tags AND ABANDONs
85
- * on multi-match. The TERMINATING handler MUST be configured with the same
86
- * `ownerLogicalId` (and is in the same stack, so shares the StackId
87
- * automatically) to locate the volume for detach.
92
+ * on multi-match.
88
93
  *
89
94
  * Why three tags. The first two are stable per logical construct; without
90
95
  * the StackId discriminator, every prior-iteration orphan (CREATE_ROLLBACK,
@@ -105,12 +110,10 @@ export declare class PersistentDataVolume extends Construct {
105
110
  readonly queue: SQSQueue;
106
111
  /**
107
112
  * Identifier used as the `fjall:OwnerLogicalId` tag on the volume and the
108
- * `OWNER_LOGICAL_ID` env on the Lambda. Consumers MUST pass this exact
109
- * value to the TERMINATING handler so it locates the same volume for
110
- * detach. Carries `props.stableOwnerId` verbatim; changing it across an
111
- * existing deployment breaks the re-attach chain (Lambda filter no longer
112
- * matches, new instance fails its launch hook) — recovery is a manual
113
- * snapshot-restore step.
113
+ * `OWNER_LOGICAL_ID` env on the Lambda. Carries `props.stableOwnerId`
114
+ * verbatim; changing it across an existing deployment breaks the re-attach
115
+ * chain (Lambda filter no longer matches, new instance fails its launch
116
+ * hook) — recovery is a manual snapshot-restore step.
114
117
  */
115
118
  readonly ownerLogicalId: string;
116
119
  readonly attachFailureAlarm: Alarm;
@@ -120,7 +123,6 @@ declare function validatePersistentDataVolumeProps(props: PersistentDataVolumePr
120
123
  export declare const _internals: {
121
124
  LAUNCHING_LAMBDA_SOURCE_FILE: string;
122
125
  LAMBDA_TIMEOUT_SECONDS: number;
123
- LAUNCHING_HOOK_HEARTBEAT_SECONDS: number;
124
126
  QUEUE_VISIBILITY_TIMEOUT_SECONDS: number;
125
127
  ALARM_EVALUATION_PERIODS: number;
126
128
  ALARM_DATAPOINTS_TO_ALARM: number;
@@ -24,10 +24,19 @@ const LAUNCHING_LAMBDA_SOURCE_FILE = "persistentDataVolumeLambda.source.cjs";
24
24
  * not silently break the test query.
25
25
  */
26
26
  export const PERSISTENT_DATA_VOLUME_LAUNCHING_DESCRIPTION = "PersistentDataVolume launching handler";
27
- const LAMBDA_TIMEOUT_SECONDS = 180;
28
- // Heartbeat must comfortably exceed the Lambda's 90s attach deadline.
29
- const LAUNCHING_HOOK_HEARTBEAT_SECONDS = 300;
30
- const QUEUE_VISIBILITY_TIMEOUT_SECONDS = 240;
27
+ // Covers the Lambda's release wait (480 s) and attach wait (90 s), each of
28
+ // which may end on a read that started at its deadline, plus the calls around
29
+ // them. Every call is bounded; a test pins the sum.
30
+ const LAMBDA_TIMEOUT_SECONDS = 700;
31
+ // SQS requires a visibility timeout of at least the function timeout.
32
+ const QUEUE_VISIBILITY_TIMEOUT_SECONDS = 720;
33
+ /**
34
+ * How long a launching instance waits for its data volume. The Lambda's full
35
+ * attempt must fit inside it, and the instance's user data waits this long
36
+ * for the device to appear: the hook ABANDONs the instance when it expires,
37
+ * so waiting any longer on the instance buys nothing.
38
+ */
39
+ export const PERSISTENT_DATA_VOLUME_LAUNCHING_HEARTBEAT_SECONDS = 900;
31
40
  export const PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE = "fjall:Lifecycle";
32
41
  export const PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE = "data-volume";
33
42
  export const PERSISTENT_DATA_VOLUME_TAG_OWNER_LOGICAL_ID = "fjall:OwnerLogicalId";
@@ -39,11 +48,11 @@ const ALARM_THRESHOLD = 1;
39
48
  * Standalone EBS data volume + ASG `EC2_INSTANCE_LAUNCHING` hook that
40
49
  * re-attaches the same physical volume across instance refreshes.
41
50
  *
42
- * Pairs with the TERMINATING-side DetachVolume branch in
43
- * `ec2GracefulTerminationLambda.source.cjs` (see `DetachVolumeCommand` import
44
- * + invocation) wired by `ec2GracefulTerminationHandler.ts` when
45
- * `dataVolumeOwnerLogicalId` is forwarded from `Ec2Instance`. The two
46
- * Lambdas form a closed re-attach contract.
51
+ * Nothing detaches the volume. The instance holding it releases it when it
52
+ * terminates, after `Ec2GracefulTerminationHandler` has proven its tasks
53
+ * stopped; the LAUNCHING Lambda waits for that release and then attaches the
54
+ * volume to the new instance. A forced detach would pull the filesystem out
55
+ * from under a database that is still running.
47
56
  *
48
57
  * Volume tagging: three tags carry the re-attach identity —
49
58
  * `fjall:Lifecycle = data-volume` (constant; gates IAM scoping)
@@ -55,9 +64,7 @@ const ALARM_THRESHOLD = 1;
55
64
  * StackId and cannot collide with the new deploy's volume)
56
65
  *
57
66
  * The LAUNCHING Lambda filters DescribeVolumes by all three tags AND ABANDONs
58
- * on multi-match. The TERMINATING handler MUST be configured with the same
59
- * `ownerLogicalId` (and is in the same stack, so shares the StackId
60
- * automatically) to locate the volume for detach.
67
+ * on multi-match.
61
68
  *
62
69
  * Why three tags. The first two are stable per logical construct; without
63
70
  * the StackId discriminator, every prior-iteration orphan (CREATE_ROLLBACK,
@@ -78,12 +85,10 @@ export class PersistentDataVolume extends Construct {
78
85
  queue;
79
86
  /**
80
87
  * Identifier used as the `fjall:OwnerLogicalId` tag on the volume and the
81
- * `OWNER_LOGICAL_ID` env on the Lambda. Consumers MUST pass this exact
82
- * value to the TERMINATING handler so it locates the same volume for
83
- * detach. Carries `props.stableOwnerId` verbatim; changing it across an
84
- * existing deployment breaks the re-attach chain (Lambda filter no longer
85
- * matches, new instance fails its launch hook) — recovery is a manual
86
- * snapshot-restore step.
88
+ * `OWNER_LOGICAL_ID` env on the Lambda. Carries `props.stableOwnerId`
89
+ * verbatim; changing it across an existing deployment breaks the re-attach
90
+ * chain (Lambda filter no longer matches, new instance fails its launch
91
+ * hook) — recovery is a manual snapshot-restore step.
87
92
  */
88
93
  ownerLogicalId;
89
94
  attachFailureAlarm;
@@ -179,7 +184,9 @@ export class PersistentDataVolume extends Construct {
179
184
  completeLifecyclePolicy
180
185
  ]
181
186
  });
182
- this.lambda.addSqsEventSource(this.queue.getQueue());
187
+ // One lifecycle action per invocation: a batch is processed in turn, so
188
+ // launches arriving together would share one timeout.
189
+ this.lambda.addSqsEventSource(this.queue.getQueue(), { batchSize: 1 });
183
190
  // Hook name embeds Aws.STACK_NAME (a pseudo-parameter Ref, NOT a resource
184
191
  // Ref — does not create a CFN dep) so that LifecycleHookName alone is
185
192
  // sufficient to discriminate across stacks in the EB pattern below. The
@@ -191,7 +198,7 @@ export class PersistentDataVolume extends Construct {
191
198
  hookName: launchingHookName,
192
199
  lifecycleTransition: LifecycleTransition.INSTANCE_LAUNCHING,
193
200
  defaultResult: DefaultResult.ABANDON,
194
- heartbeatTimeoutSeconds: LAUNCHING_HOOK_HEARTBEAT_SECONDS
201
+ heartbeatTimeoutSeconds: PERSISTENT_DATA_VOLUME_LAUNCHING_HEARTBEAT_SECONDS
195
202
  });
196
203
  // Pattern discriminates by LifecycleHookName only — the hook name carries
197
204
  // Aws.STACK_NAME so it is unique per stack. AutoScalingGroupName would
@@ -252,7 +259,6 @@ function validatePersistentDataVolumeProps(props) {
252
259
  export const _internals = {
253
260
  LAUNCHING_LAMBDA_SOURCE_FILE,
254
261
  LAMBDA_TIMEOUT_SECONDS,
255
- LAUNCHING_HOOK_HEARTBEAT_SECONDS,
256
262
  QUEUE_VISIBILITY_TIMEOUT_SECONDS,
257
263
  ALARM_EVALUATION_PERIODS,
258
264
  ALARM_DATAPOINTS_TO_ALARM,