@everystack/events 0.4.2 → 0.4.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.
- package/CHANGELOG.md +8 -0
- package/package.json +2 -2
- package/src/dispatchers.ts +107 -12
- package/src/index.ts +3 -0
- package/src/kvs-client.ts +59 -0
- package/src/kvs-metrics.ts +154 -0
- package/src/kvs-purge.ts +52 -0
- package/src/listener.ts +4 -21
- package/src/types.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to `@everystack/events` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.3] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- sign KVS purges with an explicit SigV4a signer
|
|
12
|
+
- KVS client shares the signer's credentials; prove the send path signs
|
|
13
|
+
- cache_purge retries ETag conflicts and fails loudly
|
|
14
|
+
|
|
7
15
|
## [0.4.2] - 2026-10-15
|
|
8
16
|
|
|
9
17
|
_Internal changes only._
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/events",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "PostgreSQL LISTEN/NOTIFY bridge for serverless — WebSocket fan-out + Lambda invocation",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@everystack/model": "0.4.
|
|
67
|
+
"@everystack/model": "0.4.23"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@aws-sdk/client-apigatewaymanagementapi": "3.1053.0",
|
package/src/dispatchers.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type {
|
|
|
3
3
|
RpcActionConfig, JobActionConfig, CachePurgeActionConfig,
|
|
4
4
|
} from './types';
|
|
5
5
|
import { resolveTemplate, filterByTenant, needsRowFetch, applyFilter } from './actions';
|
|
6
|
+
import { emitKvsMetrics, MAX_LOGGED_UNPURGED_PATHS, resolveKvsMetricsOrDefault, type KvsMetricsSetting } from './kvs-metrics';
|
|
7
|
+
import { backoffMs, DEFAULT_MAX_ATTEMPTS, isEtagConflict, KvsPurgeError, realSleep } from './kvs-purge';
|
|
6
8
|
|
|
7
9
|
// --- Interfaces for injectable dependencies ---
|
|
8
10
|
|
|
@@ -16,12 +18,34 @@ export interface LambdaClient {
|
|
|
16
18
|
|
|
17
19
|
export interface KvsClient {
|
|
18
20
|
describe(kvsArn: string): Promise<{ ETag: string }>;
|
|
19
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Write one key with IfMatch. Return the store's new ETag when the answer
|
|
23
|
+
* carries it, so the next path skips a Describe; return nothing and the
|
|
24
|
+
* next path describes first.
|
|
25
|
+
*/
|
|
26
|
+
put(kvsArn: string, key: string, value: string, etag: string): Promise<void | { ETag?: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CachePurgeOptions {
|
|
30
|
+
/** Wait between conflict retries. Test-only. */
|
|
31
|
+
sleep?: (ms: number) => Promise<void>;
|
|
32
|
+
/** Attempts per path before giving up. Default 5. */
|
|
33
|
+
maxAttempts?: number;
|
|
34
|
+
/**
|
|
35
|
+
* One EMF line per cache_purge dispatch, success and failure. `false` turns
|
|
36
|
+
* it off. A bad setting logs one error line and the defaults are used.
|
|
37
|
+
*/
|
|
38
|
+
metrics?: KvsMetricsSetting;
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
export interface DispatchOptions {
|
|
23
42
|
workerFunctionName?: string;
|
|
24
43
|
defaultKvsArn?: string | null;
|
|
44
|
+
/** Called once per failed cache_purge. A throw or rejection is ignored. */
|
|
45
|
+
onError?: (error: KvsPurgeError) => void | Promise<void>;
|
|
46
|
+
metrics?: KvsMetricsSetting;
|
|
47
|
+
/** Wait between conflict retries. Test-only. */
|
|
48
|
+
sleep?: (ms: number) => Promise<void>;
|
|
25
49
|
}
|
|
26
50
|
|
|
27
51
|
// --- Individual dispatchers ---
|
|
@@ -79,7 +103,13 @@ export async function dispatchJob(
|
|
|
79
103
|
|
|
80
104
|
/**
|
|
81
105
|
* Dispatch a cache_purge action: invalidate CloudFront KVS paths.
|
|
82
|
-
* Lightweight
|
|
106
|
+
* Lightweight: runs directly in the listener, no Lambda invocation.
|
|
107
|
+
*
|
|
108
|
+
* Same rules as `invalidateKvsPaths` in packages/server/src/kvs.ts: paths are
|
|
109
|
+
* deduped; each put writes with IfMatch; on an ETag conflict that path
|
|
110
|
+
* re-reads the ETag and retries with a jittered backoff (5 attempts). A path
|
|
111
|
+
* that cannot be written stops the purge and throws a KvsPurgeError listing
|
|
112
|
+
* it and every later path. One EMF line per dispatch, after the retries.
|
|
83
113
|
*/
|
|
84
114
|
export async function dispatchCachePurge(
|
|
85
115
|
action: EventAction,
|
|
@@ -87,19 +117,82 @@ export async function dispatchCachePurge(
|
|
|
87
117
|
row: Record<string, unknown> | null,
|
|
88
118
|
kvsClient: KvsClient | null,
|
|
89
119
|
defaultKvsArn: string | null,
|
|
120
|
+
options: CachePurgeOptions = {},
|
|
90
121
|
): Promise<void> {
|
|
91
122
|
const resolved = resolveTemplate(action.config, event, row) as CachePurgeActionConfig;
|
|
92
123
|
const kvsArn = resolved.kvsArn || defaultKvsArn;
|
|
93
|
-
if (!kvsArn || !kvsClient) return;
|
|
124
|
+
if (!kvsArn || !kvsClient || resolved.paths.length === 0) return;
|
|
94
125
|
|
|
95
|
-
|
|
96
|
-
|
|
126
|
+
// mirror of packages/server/src/kvs.ts, keep in sync
|
|
127
|
+
const metrics = resolveKvsMetricsOrDefault(options.metrics);
|
|
128
|
+
const sleep = options.sleep ?? realSleep;
|
|
129
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
130
|
+
const unique = [...new Set(resolved.paths)];
|
|
131
|
+
const value = String(Math.floor(Date.now() / 1000));
|
|
97
132
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
etag
|
|
133
|
+
let written = 0;
|
|
134
|
+
try {
|
|
135
|
+
// The ETag moves after every put, ours or another writer's. A put that
|
|
136
|
+
// returns the new ETag saves the next Describe; anything else reads it fresh.
|
|
137
|
+
let etag: string | undefined;
|
|
138
|
+
for (; written < unique.length; written++) {
|
|
139
|
+
for (let attempt = 1; ; attempt++) {
|
|
140
|
+
if (etag === undefined) etag = (await kvsClient.describe(kvsArn)).ETag;
|
|
141
|
+
try {
|
|
142
|
+
const out = await kvsClient.put(kvsArn, unique[written], value, etag);
|
|
143
|
+
etag = out ? out.ETag : undefined;
|
|
144
|
+
break;
|
|
145
|
+
} catch (err) {
|
|
146
|
+
etag = undefined;
|
|
147
|
+
if (!isEtagConflict(err) || attempt >= maxAttempts) throw err;
|
|
148
|
+
await sleep(backoffMs(attempt));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const unpurged = unique.slice(written);
|
|
154
|
+
emitKvsMetrics(metrics, {
|
|
155
|
+
kvsArn,
|
|
156
|
+
metrics: { KvsPurgeFailure: 1, KvsPurgePaths: written, KvsPurgeUnpurgedPaths: unpurged.length },
|
|
157
|
+
unpurgedPaths: unpurged,
|
|
158
|
+
});
|
|
159
|
+
throw new KvsPurgeError(
|
|
160
|
+
`KVS purge stopped: ${written} of ${unique.length} paths written; `
|
|
161
|
+
+ `${unpurged.length} ${unpurged.length === 1 ? 'path' : 'paths'} not purged`,
|
|
162
|
+
{ unpurgedPaths: unpurged, writtenPaths: unique.slice(0, written), cause: err },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
emitKvsMetrics(metrics, {
|
|
167
|
+
kvsArn,
|
|
168
|
+
metrics: { KvsPurgeFailure: 0, KvsPurgePaths: unique.length, KvsPurgeUnpurgedPaths: 0 },
|
|
169
|
+
unpurgedPaths: [],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A failed cache_purge must be loud and must not block the event's other
|
|
175
|
+
* actions: log one line, hand the error to onError, and never reject.
|
|
176
|
+
*/
|
|
177
|
+
async function reportCachePurgeFailure(
|
|
178
|
+
action: EventAction,
|
|
179
|
+
err: unknown,
|
|
180
|
+
onError: DispatchOptions['onError'],
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
try {
|
|
183
|
+
if (err instanceof KvsPurgeError) {
|
|
184
|
+
const shown = err.unpurgedPaths.slice(0, MAX_LOGGED_UNPURGED_PATHS);
|
|
185
|
+
const more = err.unpurgedPaths.length - shown.length;
|
|
186
|
+
console.error(
|
|
187
|
+
`cache_purge action ${action.id}: ${err.message}. Unpurged: ${shown.join(', ')}`
|
|
188
|
+
+ (more > 0 ? ` (+${more} more)` : ''),
|
|
189
|
+
);
|
|
190
|
+
if (onError) await onError(err);
|
|
191
|
+
} else {
|
|
192
|
+
console.error(`cache_purge action ${action.id} failed: ${(err as Error)?.message ?? String(err)}`);
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// A broken hook or log must not turn into a rejection on the listener.
|
|
103
196
|
}
|
|
104
197
|
}
|
|
105
198
|
|
|
@@ -162,8 +255,10 @@ export async function dispatchActions(
|
|
|
162
255
|
break;
|
|
163
256
|
case 'cache_purge':
|
|
164
257
|
tasks.push(
|
|
165
|
-
dispatchCachePurge(action, event, row, kvsClient, options.defaultKvsArn ?? null
|
|
166
|
-
.
|
|
258
|
+
dispatchCachePurge(action, event, row, kvsClient, options.defaultKvsArn ?? null, {
|
|
259
|
+
metrics: options.metrics,
|
|
260
|
+
sleep: options.sleep,
|
|
261
|
+
}).catch((err) => reportCachePurgeFailure(action, err, options.onError))
|
|
167
262
|
);
|
|
168
263
|
break;
|
|
169
264
|
case 'webhook':
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,9 @@ export { createListener, createListenerInternal } from './listener';
|
|
|
2
2
|
export { generateTriggerSQL, generateEventActionsTriggerSQL } from './triggers';
|
|
3
3
|
export { matchEventActions, needsRowFetch, filterByTenant, applyFilter, resolveTemplate, createActionCache } from './actions';
|
|
4
4
|
export { dispatchActions, dispatchRpc, dispatchJob, dispatchCachePurge } from './dispatchers';
|
|
5
|
+
export type { KvsClient, DispatchOptions, CachePurgeOptions } from './dispatchers';
|
|
6
|
+
export { KvsPurgeError } from './kvs-purge';
|
|
7
|
+
export type { KvsMetricsOptions, KvsMetricsSetting } from './kvs-metrics';
|
|
5
8
|
export { createConnectHandler, createDisconnectHandler, createDefaultHandler } from './handler/index';
|
|
6
9
|
export { createEventsClient, EventsProvider, useEventsClient, useSignal, useSubscription } from './client/index';
|
|
7
10
|
export type {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { KvsClient } from './dispatchers';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a KVS client that signs with an explicit SigV4a signer.
|
|
5
|
+
*
|
|
6
|
+
* Duplicate of `createKvsClient` in packages/server/src/kvs.ts (events does not
|
|
7
|
+
* depend on @everystack/server). Keep the two in step.
|
|
8
|
+
*
|
|
9
|
+
* KVS endpoints require SigV4a. A side-effect `import('@aws-sdk/signature-v4a')`
|
|
10
|
+
* registers the signer into one copy of @smithy/signature-v4, but the KVS client
|
|
11
|
+
* can carry a nested copy whose container is empty, and every request then fails
|
|
12
|
+
* with "Neither CRT nor JS SigV4a implementation is available". When
|
|
13
|
+
* `config.signer` is set the SDK uses it directly and reads no container.
|
|
14
|
+
*
|
|
15
|
+
* The credentials provider and sha256 come from a throwaway client, so the
|
|
16
|
+
* signer uses the same default chain and hash the SDK would have used.
|
|
17
|
+
* The `as string` imports keep these optional peers out of type resolution.
|
|
18
|
+
*/
|
|
19
|
+
export async function createKvsSdkClient(region?: string): Promise<any> {
|
|
20
|
+
const { SignatureV4a } = await import('@aws-sdk/signature-v4a' as string);
|
|
21
|
+
const { CloudFrontKeyValueStoreClient } = await import('@aws-sdk/client-cloudfront-keyvaluestore' as string);
|
|
22
|
+
|
|
23
|
+
const { credentials, sha256 } = new CloudFrontKeyValueStoreClient({ region }).config;
|
|
24
|
+
const signer = new SignatureV4a({
|
|
25
|
+
credentials,
|
|
26
|
+
region: '*',
|
|
27
|
+
service: 'cloudfront-keyvaluestore',
|
|
28
|
+
sha256,
|
|
29
|
+
});
|
|
30
|
+
// Same provider on the kept client, so there is one credential chain, not two.
|
|
31
|
+
return new CloudFrontKeyValueStoreClient({ region, signer, credentials });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The `KvsClient` the cache_purge dispatcher uses, over a KVS SDK client.
|
|
36
|
+
* `put` returns the ETag the PutKey answer carries, so the next path in the
|
|
37
|
+
* same purge skips its Describe.
|
|
38
|
+
*/
|
|
39
|
+
export async function kvsDispatchClient(client: any): Promise<KvsClient> {
|
|
40
|
+
const {
|
|
41
|
+
DescribeKeyValueStoreCommand,
|
|
42
|
+
PutKeyCommand,
|
|
43
|
+
} = await import('@aws-sdk/client-cloudfront-keyvaluestore' as string);
|
|
44
|
+
return {
|
|
45
|
+
describe: async (kvsArn: string) => {
|
|
46
|
+
const desc = await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }));
|
|
47
|
+
return { ETag: desc.ETag! };
|
|
48
|
+
},
|
|
49
|
+
put: async (kvsArn: string, key: string, value: string, etag: string) => {
|
|
50
|
+
const out = await client.send(new PutKeyCommand({
|
|
51
|
+
KvsARN: kvsArn,
|
|
52
|
+
Key: key,
|
|
53
|
+
Value: value,
|
|
54
|
+
IfMatch: etag,
|
|
55
|
+
}));
|
|
56
|
+
return { ETag: out?.ETag };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudWatch embedded metric format (EMF) lines for the cache_purge path.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of packages/server/src/kvs-metrics.ts, keep in sync. events does not
|
|
5
|
+
* depend on @everystack/server, so the same line shape is copied here with
|
|
6
|
+
* `source: 'events'` and without the check metric (events has no check).
|
|
7
|
+
*
|
|
8
|
+
* A line written to stdout in Lambda becomes a CloudWatch metric with no SDK
|
|
9
|
+
* call and no extra IAM. It is written with `process.stdout.write`, never a
|
|
10
|
+
* `console` method: under the Lambda JSON log format, Lambda wraps console
|
|
11
|
+
* output in its own JSON event and CloudWatch does not extract metrics from
|
|
12
|
+
* the wrapped object.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_KVS_METRICS_NAMESPACE = 'Everystack/Cache';
|
|
16
|
+
export const MAX_KVS_METRIC_DIMENSIONS = 2;
|
|
17
|
+
/** The unpurgedPaths property keeps this many entries; the metric keeps the full count. */
|
|
18
|
+
export const MAX_LOGGED_UNPURGED_PATHS = 20;
|
|
19
|
+
|
|
20
|
+
export type KvsMetricName = 'KvsPurgeFailure' | 'KvsPurgePaths' | 'KvsPurgeUnpurgedPaths';
|
|
21
|
+
|
|
22
|
+
export interface KvsMetricsOptions {
|
|
23
|
+
/** CloudWatch namespace. Default `Everystack/Cache`. */
|
|
24
|
+
namespace?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Replaces the default dimension (`FunctionName` from
|
|
27
|
+
* AWS_LAMBDA_FUNCTION_NAME). At most 2 keys; `{}` means no dimension.
|
|
28
|
+
*/
|
|
29
|
+
dimensions?: Record<string, string>;
|
|
30
|
+
/** Written as a property so one request can be found in Logs Insights. */
|
|
31
|
+
requestId?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `false` turns emission off. */
|
|
35
|
+
export type KvsMetricsSetting = false | KvsMetricsOptions;
|
|
36
|
+
|
|
37
|
+
export interface ResolvedKvsMetrics {
|
|
38
|
+
namespace: string;
|
|
39
|
+
dimensions: Record<string, string>;
|
|
40
|
+
requestId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface KvsMetricPoint {
|
|
44
|
+
kvsArn: string;
|
|
45
|
+
metrics: Partial<Record<KvsMetricName, number>>;
|
|
46
|
+
unpurgedPaths?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// mirror of packages/server/src/kvs-metrics.ts, keep in sync
|
|
50
|
+
const PROPERTY_NAMES = ['_aws', 'kvsArn', 'source', 'unpurgedPaths', 'requestId'];
|
|
51
|
+
const METRIC_NAMES: KvsMetricName[] = ['KvsPurgeFailure', 'KvsPurgePaths', 'KvsPurgeUnpurgedPaths'];
|
|
52
|
+
// The server's check metric stays reserved so one setting is valid in both packages.
|
|
53
|
+
const RESERVED = new Set<string>([...PROPERTY_NAMES, ...METRIC_NAMES, 'KvsPurgeCheckFailure']);
|
|
54
|
+
|
|
55
|
+
// mirror of packages/server/src/kvs-metrics.ts, keep in sync
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the namespace and dimensions once, at call time. Returns null when
|
|
58
|
+
* metrics are off. Throws on more than 2 dimensions or a reserved dimension
|
|
59
|
+
* name; the purge catches that through resolveKvsMetricsOrDefault.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveKvsMetrics(
|
|
62
|
+
setting?: KvsMetricsSetting,
|
|
63
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
64
|
+
): ResolvedKvsMetrics | null {
|
|
65
|
+
if (setting === false) return null;
|
|
66
|
+
const options = setting ?? {};
|
|
67
|
+
|
|
68
|
+
let dimensions: Record<string, string>;
|
|
69
|
+
if (options.dimensions) {
|
|
70
|
+
const names = Object.keys(options.dimensions);
|
|
71
|
+
if (names.length > MAX_KVS_METRIC_DIMENSIONS) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`KVS metrics: at most ${MAX_KVS_METRIC_DIMENSIONS} dimensions, got ${names.length} `
|
|
74
|
+
+ `(${names.join(', ')}). Put high-cardinality values in properties, not dimensions.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const reserved = names.filter((n) => RESERVED.has(n));
|
|
78
|
+
if (reserved.length > 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`KVS metrics: dimension name ${reserved.join(', ')} is reserved for a property or metric.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
dimensions = { ...options.dimensions };
|
|
84
|
+
} else {
|
|
85
|
+
const fn = env.AWS_LAMBDA_FUNCTION_NAME;
|
|
86
|
+
dimensions = fn ? { FunctionName: fn } : {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
namespace: options.namespace ?? DEFAULT_KVS_METRICS_NAMESPACE,
|
|
91
|
+
dimensions,
|
|
92
|
+
...(options.requestId ? { requestId: options.requestId } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// mirror of packages/server/src/kvs-metrics.ts, keep in sync
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the setting for a purge. A bad setting must never stop a purge
|
|
99
|
+
* (stale beats down): log one error line naming the problem and fall back to
|
|
100
|
+
* the default metrics.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveKvsMetricsOrDefault(
|
|
103
|
+
setting?: KvsMetricsSetting,
|
|
104
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
105
|
+
): ResolvedKvsMetrics | null {
|
|
106
|
+
try {
|
|
107
|
+
return resolveKvsMetrics(setting, env);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(`KVS metrics setting ignored, using the defaults: ${(err as Error).message}`);
|
|
110
|
+
return resolveKvsMetrics(undefined, env);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// mirror of packages/server/src/kvs-metrics.ts, keep in sync
|
|
115
|
+
/** Build one EMF object. Dimensions and metrics are top-level members. */
|
|
116
|
+
export function buildKvsEmf(
|
|
117
|
+
resolved: ResolvedKvsMetrics,
|
|
118
|
+
point: KvsMetricPoint,
|
|
119
|
+
now: number = Date.now(),
|
|
120
|
+
): Record<string, unknown> {
|
|
121
|
+
const names = METRIC_NAMES.filter((n) => point.metrics[n] !== undefined);
|
|
122
|
+
return {
|
|
123
|
+
_aws: {
|
|
124
|
+
Timestamp: now,
|
|
125
|
+
CloudWatchMetrics: [{
|
|
126
|
+
Namespace: resolved.namespace,
|
|
127
|
+
Dimensions: [Object.keys(resolved.dimensions)],
|
|
128
|
+
Metrics: names.map((Name) => ({ Name, Unit: 'Count' })),
|
|
129
|
+
}],
|
|
130
|
+
},
|
|
131
|
+
...resolved.dimensions,
|
|
132
|
+
...Object.fromEntries(names.map((n) => [n, point.metrics[n]])),
|
|
133
|
+
kvsArn: point.kvsArn,
|
|
134
|
+
source: 'events',
|
|
135
|
+
...(point.unpurgedPaths
|
|
136
|
+
? { unpurgedPaths: point.unpurgedPaths.slice(0, MAX_LOGGED_UNPURGED_PATHS) }
|
|
137
|
+
: {}),
|
|
138
|
+
...(resolved.requestId ? { requestId: resolved.requestId } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// mirror of packages/server/src/kvs-metrics.ts, keep in sync
|
|
143
|
+
/**
|
|
144
|
+
* Write one EMF line to stdout. Never throws: a metric that cannot be written
|
|
145
|
+
* must not turn a purge, or a purge failure, into a different error.
|
|
146
|
+
*/
|
|
147
|
+
export function emitKvsMetrics(resolved: ResolvedKvsMetrics | null, point: KvsMetricPoint): void {
|
|
148
|
+
if (!resolved) return;
|
|
149
|
+
try {
|
|
150
|
+
process.stdout.write(JSON.stringify(buildKvsEmf(resolved, point)) + '\n');
|
|
151
|
+
} catch {
|
|
152
|
+
// Nothing to do: the metric is lost, the caller's outcome is not.
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/kvs-purge.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The retry rules and the error type of a KVS purge.
|
|
3
|
+
*
|
|
4
|
+
* events does not depend on @everystack/server, so these are copied from
|
|
5
|
+
* packages/server/src/kvs.ts. The error has the same name and shape there and
|
|
6
|
+
* here, so one consumer guard (`err.name === 'KvsPurgeError'`) covers both.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// mirror of packages/server/src/kvs.ts, keep in sync
|
|
10
|
+
export const DEFAULT_MAX_ATTEMPTS = 5;
|
|
11
|
+
const BACKOFF_BASE_MS = 25;
|
|
12
|
+
const BACKOFF_CAP_MS = 400;
|
|
13
|
+
|
|
14
|
+
// mirror of packages/server/src/kvs.ts, keep in sync
|
|
15
|
+
/** A purge that stopped part way. `unpurgedPaths` are the keys NOT written. */
|
|
16
|
+
export class KvsPurgeError extends Error {
|
|
17
|
+
readonly unpurgedPaths: string[];
|
|
18
|
+
readonly writtenPaths: string[];
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
details: { unpurgedPaths: string[]; writtenPaths: string[]; cause: unknown },
|
|
23
|
+
) {
|
|
24
|
+
super(message, { cause: details.cause });
|
|
25
|
+
this.name = 'KvsPurgeError';
|
|
26
|
+
this.unpurgedPaths = details.unpurgedPaths;
|
|
27
|
+
this.writtenPaths = details.writtenPaths;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// mirror of packages/server/src/kvs.ts, keep in sync
|
|
32
|
+
/**
|
|
33
|
+
* A write refused because another writer moved the ETag first. The KVS API
|
|
34
|
+
* models this as ConflictException (409); 412 PreconditionFailed is matched
|
|
35
|
+
* too in case the service ever answers with the HTTP precondition status.
|
|
36
|
+
*/
|
|
37
|
+
export function isEtagConflict(err: unknown): boolean {
|
|
38
|
+
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } } | null;
|
|
39
|
+
const status = e?.$metadata?.httpStatusCode;
|
|
40
|
+
return status === 409 || status === 412
|
|
41
|
+
|| e?.name === 'ConflictException'
|
|
42
|
+
|| e?.name === 'PreconditionFailed'
|
|
43
|
+
|| e?.name === 'PreconditionFailedException';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// mirror of packages/server/src/kvs.ts, keep in sync
|
|
47
|
+
export function backoffMs(attempt: number): number {
|
|
48
|
+
const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
|
|
49
|
+
return ceiling / 2 + Math.random() * (ceiling / 2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
package/src/listener.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { EventPayload, EventAction, EventActionsOptions, ListenerOptions, L
|
|
|
2
2
|
import { matchEventActions, createActionCache } from './actions';
|
|
3
3
|
import { dispatchActions } from './dispatchers';
|
|
4
4
|
import type { KvsClient } from './dispatchers';
|
|
5
|
+
import { createKvsSdkClient, kvsDispatchClient } from './kvs-client';
|
|
5
6
|
|
|
6
7
|
// --- Subscription state (in-memory) ---
|
|
7
8
|
|
|
@@ -358,6 +359,8 @@ export function createListenerInternal(options: InternalListenerOptions) {
|
|
|
358
359
|
dispatchActions(matched, ev, pgClient, lambdaClient, kvsClient, {
|
|
359
360
|
workerFunctionName: eventActions?.workerFunctionName,
|
|
360
361
|
defaultKvsArn: eventActions?.defaultKvsArn,
|
|
362
|
+
onError: eventActions?.onError,
|
|
363
|
+
metrics: eventActions?.metrics,
|
|
361
364
|
});
|
|
362
365
|
}
|
|
363
366
|
}
|
|
@@ -430,27 +433,7 @@ export function createListener(options: ListenerOptions) {
|
|
|
430
433
|
let kvsClient: KvsClient | null = null;
|
|
431
434
|
if (options.eventActions?.defaultKvsArn) {
|
|
432
435
|
try {
|
|
433
|
-
await
|
|
434
|
-
const {
|
|
435
|
-
CloudFrontKeyValueStoreClient,
|
|
436
|
-
DescribeKeyValueStoreCommand,
|
|
437
|
-
PutKeyCommand,
|
|
438
|
-
} = await import('@aws-sdk/client-cloudfront-keyvaluestore' as string);
|
|
439
|
-
const client = new CloudFrontKeyValueStoreClient({});
|
|
440
|
-
kvsClient = {
|
|
441
|
-
describe: async (kvsArn: string) => {
|
|
442
|
-
const desc = await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }));
|
|
443
|
-
return { ETag: desc.ETag! };
|
|
444
|
-
},
|
|
445
|
-
put: async (kvsArn: string, key: string, value: string, etag: string) => {
|
|
446
|
-
await client.send(new PutKeyCommand({
|
|
447
|
-
KvsARN: kvsArn,
|
|
448
|
-
Key: key,
|
|
449
|
-
Value: value,
|
|
450
|
-
IfMatch: etag,
|
|
451
|
-
}));
|
|
452
|
-
},
|
|
453
|
-
};
|
|
436
|
+
kvsClient = await kvsDispatchClient(await createKvsSdkClient());
|
|
454
437
|
} catch {
|
|
455
438
|
// KVS SDK not available — skip cache_purge actions
|
|
456
439
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { KvsMetricsSetting } from './kvs-metrics';
|
|
2
|
+
import type { KvsPurgeError } from './kvs-purge';
|
|
3
|
+
|
|
1
4
|
// --- Core ---
|
|
2
5
|
|
|
3
6
|
export interface EventPayload {
|
|
@@ -33,6 +36,10 @@ export interface ListenerRoutes {
|
|
|
33
36
|
export interface EventActionsOptions {
|
|
34
37
|
workerFunctionName?: string;
|
|
35
38
|
defaultKvsArn?: string | null;
|
|
39
|
+
/** Called once per failed cache_purge. A throw or rejection is ignored. */
|
|
40
|
+
onError?: (error: KvsPurgeError) => void | Promise<void>;
|
|
41
|
+
/** One EMF line per cache_purge dispatch (`source: 'events'`). `false` turns it off. */
|
|
42
|
+
metrics?: KvsMetricsSetting;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface ListenerOptions {
|