Skip to content

Commit b51e999

Browse files
feat(microvm): add lifecycle hook service
1 parent 0563c7c commit b51e999

34 files changed

Lines changed: 3323 additions & 2 deletions
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { ConditionalCheckFailedException, DeleteItemCommand, type DynamoDBClient } from '@aws-sdk/client-dynamodb';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
import {
5+
AwsSdkDynamoDbRunnerConfigApi,
6+
createAwsDynamoDbRunnerConfigConsumer,
7+
type AwsDynamoDbRunnerConfigApi,
8+
} from './runner-config-consumer';
9+
10+
const dynamoDbEnvironment = {
11+
RUNNER_CONFIG_STORAGE_PROVIDER: 'aws_dynamodb',
12+
RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME: 'runner-state',
13+
} as const;
14+
15+
function namedError(name: string, message = 'provider detail'): Error {
16+
const error = new Error(message);
17+
error.name = name;
18+
return error;
19+
}
20+
21+
describe('AWS SDK DynamoDB runner config API', () => {
22+
afterEach(() => {
23+
vi.useRealTimers();
24+
});
25+
26+
it('atomically deletes an unexpired composite-key item and returns its previous value', async () => {
27+
vi.useFakeTimers();
28+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
29+
const send = vi.fn().mockResolvedValue({ Attributes: { value: { S: 'encoded-jit' } } });
30+
const api = new AwsSdkDynamoDbRunnerConfigApi({ send } as unknown as DynamoDBClient);
31+
const signal = new AbortController().signal;
32+
33+
await expect(api.deleteItem('runner-state', 'microvm-123', signal)).resolves.toBe('encoded-jit');
34+
35+
expect(send.mock.calls[0][0]).toBeInstanceOf(DeleteItemCommand);
36+
expect(send.mock.calls[0][0].input).toEqual({
37+
TableName: 'runner-state',
38+
Key: {
39+
scope: { S: 'microvm-123' },
40+
id: { S: 'config' },
41+
},
42+
ConditionExpression: 'attribute_exists(#expires_at) AND #expires_at > :now',
43+
ExpressionAttributeNames: { '#expires_at': 'expires_at' },
44+
ExpressionAttributeValues: { ':now': { N: '1767225600' } },
45+
ReturnValues: 'ALL_OLD',
46+
});
47+
expect(send.mock.calls[0][1]).toEqual({ abortSignal: signal });
48+
});
49+
50+
it('treats missing or expired records as unavailable after the conditional delete', async () => {
51+
const conditional = new ConditionalCheckFailedException({
52+
$metadata: {},
53+
message: 'record is absent or expired',
54+
});
55+
const api = new AwsSdkDynamoDbRunnerConfigApi({
56+
send: vi.fn().mockRejectedValue(conditional),
57+
} as unknown as DynamoDBClient);
58+
59+
await expect(api.deleteItem('runner-state', 'microvm-123', new AbortController().signal)).resolves.toBeUndefined();
60+
});
61+
62+
it('supports a deserialized conditional error without exposing its message', async () => {
63+
const api = new AwsSdkDynamoDbRunnerConfigApi({
64+
send: vi.fn().mockRejectedValue(namedError('ConditionalCheckFailedException', 'expired-secret-detail')),
65+
} as unknown as DynamoDBClient);
66+
67+
await expect(api.deleteItem('runner-state', 'microvm-123', new AbortController().signal)).resolves.toBeUndefined();
68+
});
69+
70+
it('propagates non-conditional provider errors', async () => {
71+
const error = namedError('AccessDeniedException');
72+
const api = new AwsSdkDynamoDbRunnerConfigApi({
73+
send: vi.fn().mockRejectedValue(error),
74+
} as unknown as DynamoDBClient);
75+
76+
await expect(api.deleteItem('runner-state', 'microvm-123', new AbortController().signal)).rejects.toBe(error);
77+
});
78+
79+
it('returns undefined when no item was deleted', async () => {
80+
const api = new AwsSdkDynamoDbRunnerConfigApi({
81+
send: vi.fn().mockResolvedValue({}),
82+
} as unknown as DynamoDBClient);
83+
84+
await expect(api.deleteItem('runner-state', 'microvm-123', new AbortController().signal)).resolves.toBeUndefined();
85+
});
86+
87+
it.each([{ Attributes: {} }, { Attributes: { value: { N: '1' } } }, { Attributes: { value: { S: '' } } }])(
88+
'rejects a deleted item without a string value %#',
89+
async (response) => {
90+
const api = new AwsSdkDynamoDbRunnerConfigApi({
91+
send: vi.fn().mockResolvedValue(response),
92+
} as unknown as DynamoDBClient);
93+
94+
await expect(api.deleteItem('runner-state', 'microvm-123', new AbortController().signal)).rejects.toThrow(
95+
'runner configuration record has an invalid value',
96+
);
97+
},
98+
);
99+
});
100+
101+
describe('DynamoDB runner config consumer', () => {
102+
afterEach(() => {
103+
vi.useRealTimers();
104+
});
105+
106+
it('polls until an atomic delete returns the stored configuration', async () => {
107+
const deleteItem = vi
108+
.fn<AwsDynamoDbRunnerConfigApi['deleteItem']>()
109+
.mockResolvedValueOnce(undefined)
110+
.mockResolvedValueOnce('encoded-jit');
111+
const consumer = createAwsDynamoDbRunnerConfigConsumer(dynamoDbEnvironment, {
112+
api: { deleteItem },
113+
callTimeoutMs: 100,
114+
configTimeoutMs: 500,
115+
pollIntervalMs: 1,
116+
});
117+
118+
await expect(
119+
consumer.consume('microvm-123', {
120+
deadlineMs: Date.now() + 1_000,
121+
signal: new AbortController().signal,
122+
}),
123+
).resolves.toBe('encoded-jit');
124+
expect(deleteItem).toHaveBeenCalledTimes(2);
125+
expect(deleteItem).toHaveBeenCalledWith('runner-state', 'microvm-123', expect.any(AbortSignal));
126+
});
127+
128+
it('retries transient provider failures', async () => {
129+
const deleteItem = vi
130+
.fn<AwsDynamoDbRunnerConfigApi['deleteItem']>()
131+
.mockRejectedValueOnce(namedError('ProvisionedThroughputExceededException'))
132+
.mockResolvedValueOnce('encoded-jit');
133+
const consumer = createAwsDynamoDbRunnerConfigConsumer(dynamoDbEnvironment, {
134+
api: { deleteItem },
135+
callTimeoutMs: 100,
136+
configTimeoutMs: 500,
137+
pollIntervalMs: 1,
138+
});
139+
140+
await expect(
141+
consumer.consume('microvm-123', {
142+
deadlineMs: Date.now() + 1_000,
143+
signal: new AbortController().signal,
144+
}),
145+
).resolves.toBe('encoded-jit');
146+
expect(deleteItem).toHaveBeenCalledTimes(2);
147+
});
148+
149+
it('sanitizes non-retryable provider failures', async () => {
150+
const api: AwsDynamoDbRunnerConfigApi = {
151+
deleteItem: vi.fn().mockRejectedValue(namedError('AccessDeniedException', 'encoded-jit-secret')),
152+
};
153+
const consumer = createAwsDynamoDbRunnerConfigConsumer(dynamoDbEnvironment, {
154+
api,
155+
callTimeoutMs: 100,
156+
configTimeoutMs: 100,
157+
pollIntervalMs: 1,
158+
});
159+
160+
const pending = consumer.consume('microvm-123', {
161+
deadlineMs: Date.now() + 1_000,
162+
signal: new AbortController().signal,
163+
});
164+
await expect(pending).rejects.toThrow('failed to consume runner configuration from DynamoDB');
165+
await expect(pending).rejects.not.toThrow('encoded-jit-secret');
166+
});
167+
168+
it('validates the complete composite key before calling DynamoDB', async () => {
169+
const api: AwsDynamoDbRunnerConfigApi = { deleteItem: vi.fn() };
170+
const consumer = createAwsDynamoDbRunnerConfigConsumer(dynamoDbEnvironment, {
171+
api,
172+
callTimeoutMs: 100,
173+
configTimeoutMs: 100,
174+
pollIntervalMs: 1,
175+
});
176+
177+
await expect(
178+
consumer.consume('invalid/scope', {
179+
deadlineMs: Date.now() + 1_000,
180+
signal: new AbortController().signal,
181+
}),
182+
).rejects.toThrow('runnerId is invalid');
183+
expect(api.deleteItem).not.toHaveBeenCalled();
184+
});
185+
186+
it('times out while missing or expired items remain unavailable', async () => {
187+
vi.useFakeTimers();
188+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
189+
const api: AwsDynamoDbRunnerConfigApi = { deleteItem: vi.fn().mockResolvedValue(undefined) };
190+
const consumer = createAwsDynamoDbRunnerConfigConsumer(dynamoDbEnvironment, {
191+
api,
192+
callTimeoutMs: 10,
193+
configTimeoutMs: 20,
194+
pollIntervalMs: 5,
195+
});
196+
const pending = consumer.consume('microvm-123', {
197+
deadlineMs: Date.now() + 100,
198+
signal: new AbortController().signal,
199+
});
200+
const rejection = expect(pending).rejects.toThrow(
201+
'runner configuration did not become available before the deadline',
202+
);
203+
204+
await vi.runAllTimersAsync();
205+
206+
await rejection;
207+
expect(api.deleteItem).toHaveBeenCalled();
208+
});
209+
210+
it('stops a provider call immediately when the caller aborts', async () => {
211+
const api: AwsDynamoDbRunnerConfigApi = {
212+
deleteItem: vi.fn().mockReturnValue(new Promise(() => undefined)),
213+
};
214+
const controller = new AbortController();
215+
const consumer = createAwsDynamoDbRunnerConfigConsumer(dynamoDbEnvironment, {
216+
api,
217+
callTimeoutMs: 10_000,
218+
configTimeoutMs: 10_000,
219+
pollIntervalMs: 1,
220+
});
221+
const pending = consumer.consume('microvm-123', {
222+
deadlineMs: Date.now() + 10_000,
223+
signal: controller.signal,
224+
});
225+
226+
controller.abort();
227+
228+
await expect(pending).rejects.toThrow('runner configuration consumption was cancelled');
229+
});
230+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {
2+
ConditionalCheckFailedException,
3+
DeleteItemCommand,
4+
DynamoDBClient,
5+
type DeleteItemCommandOutput,
6+
} from '@aws-sdk/client-dynamodb';
7+
8+
import type {
9+
AwsDynamoDbRunnerConfigStorageEnvironment,
10+
RunnerConfigConsumer,
11+
RunnerConfigConsumeOptions,
12+
} from '../../core';
13+
import {
14+
delay,
15+
isRetryableProviderError,
16+
resolvePollingOptions,
17+
throwIfCancelled,
18+
validateConsumeOptions,
19+
validateDynamoDbRunnerConfigKey,
20+
validateDynamoDbTableName,
21+
withCallDeadline,
22+
type RunnerConfigPollingOptions,
23+
} from '../../runner-config-consumer-common';
24+
import { EXPIRES_AT_ATTRIBUTE, ID_ATTRIBUTE, RUNNER_CONFIG_ID, SCOPE_ATTRIBUTE, VALUE_ATTRIBUTE } from './keys';
25+
26+
export interface AwsDynamoDbRunnerConfigApi {
27+
deleteItem(tableName: string, scope: string, signal: AbortSignal): Promise<string | undefined>;
28+
}
29+
30+
export class AwsSdkDynamoDbRunnerConfigApi implements AwsDynamoDbRunnerConfigApi {
31+
private client?: DynamoDBClient;
32+
33+
public constructor(client?: DynamoDBClient) {
34+
this.client = client;
35+
}
36+
37+
private getClient(): DynamoDBClient {
38+
// Do not use the Lambda tracing wrapper here: lifecycle hooks run inside
39+
// the runner image and may be snapshotted before their first request.
40+
this.client ??= new DynamoDBClient({ maxAttempts: 1 });
41+
return this.client;
42+
}
43+
44+
public async deleteItem(tableName: string, scope: string, signal: AbortSignal): Promise<string | undefined> {
45+
let response: DeleteItemCommandOutput;
46+
try {
47+
response = await this.getClient().send(
48+
new DeleteItemCommand({
49+
TableName: tableName,
50+
Key: {
51+
[SCOPE_ATTRIBUTE]: { S: scope },
52+
[ID_ATTRIBUTE]: { S: RUNNER_CONFIG_ID },
53+
},
54+
ConditionExpression: 'attribute_exists(#expires_at) AND #expires_at > :now',
55+
ExpressionAttributeNames: { '#expires_at': EXPIRES_AT_ATTRIBUTE },
56+
ExpressionAttributeValues: { ':now': { N: Math.floor(Date.now() / 1_000).toString() } },
57+
ReturnValues: 'ALL_OLD',
58+
}),
59+
{ abortSignal: signal },
60+
);
61+
} catch (error) {
62+
if (
63+
error instanceof ConditionalCheckFailedException ||
64+
(error !== null &&
65+
typeof error === 'object' &&
66+
'name' in error &&
67+
error.name === 'ConditionalCheckFailedException')
68+
) {
69+
return undefined;
70+
}
71+
throw error;
72+
}
73+
if (response.Attributes === undefined) {
74+
return undefined;
75+
}
76+
const value = response.Attributes[VALUE_ATTRIBUTE];
77+
if (value?.S === undefined || value.S.length === 0) {
78+
throw new Error('runner configuration record has an invalid value');
79+
}
80+
return value.S;
81+
}
82+
}
83+
84+
export interface AwsDynamoDbRunnerConfigConsumerOptions extends RunnerConfigPollingOptions {
85+
api?: AwsDynamoDbRunnerConfigApi;
86+
}
87+
88+
export function createAwsDynamoDbRunnerConfigConsumer(
89+
environment: AwsDynamoDbRunnerConfigStorageEnvironment,
90+
options: AwsDynamoDbRunnerConfigConsumerOptions = {},
91+
): RunnerConfigConsumer {
92+
return new AwsDynamoDbRunnerConfigConsumer(
93+
environment.RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME,
94+
options.api ?? new AwsSdkDynamoDbRunnerConfigApi(),
95+
options,
96+
);
97+
}
98+
99+
class AwsDynamoDbRunnerConfigConsumer implements RunnerConfigConsumer {
100+
private readonly callTimeoutMs: number;
101+
private readonly configTimeoutMs: number;
102+
private readonly pollIntervalMs: number;
103+
104+
public constructor(
105+
private readonly tableName: string,
106+
private readonly api: AwsDynamoDbRunnerConfigApi,
107+
options: AwsDynamoDbRunnerConfigConsumerOptions,
108+
) {
109+
const polling = resolvePollingOptions(options);
110+
this.callTimeoutMs = polling.callTimeoutMs;
111+
this.configTimeoutMs = polling.configTimeoutMs;
112+
this.pollIntervalMs = polling.pollIntervalMs;
113+
}
114+
115+
public async consume(runnerId: string, options: RunnerConfigConsumeOptions): Promise<string> {
116+
validateConsumeOptions(options);
117+
const tableName = validateDynamoDbTableName(this.tableName);
118+
validateDynamoDbRunnerConfigKey(runnerId, RUNNER_CONFIG_ID);
119+
const pollDeadline = Math.min(Date.now() + this.configTimeoutMs, options.deadlineMs);
120+
121+
while (Date.now() < pollDeadline) {
122+
throwIfCancelled(options.signal);
123+
try {
124+
const runnerConfig = await withCallDeadline(options.signal, pollDeadline, this.callTimeoutMs, (callSignal) =>
125+
this.api.deleteItem(tableName, runnerId, callSignal),
126+
);
127+
if (runnerConfig !== undefined) {
128+
return runnerConfig;
129+
}
130+
} catch (error) {
131+
if (options.signal.aborted) {
132+
throw new Error('runner configuration consumption was cancelled');
133+
}
134+
if (!isRetryableProviderError(error)) {
135+
throw new Error('failed to consume runner configuration from DynamoDB');
136+
}
137+
}
138+
139+
const remaining = pollDeadline - Date.now();
140+
if (remaining > 0) {
141+
await delay(Math.min(this.pollIntervalMs, remaining), options.signal);
142+
}
143+
}
144+
145+
throw new Error('runner configuration did not become available before the deadline');
146+
}
147+
}

0 commit comments

Comments
 (0)