@photon-grove/evt 0.1.0 → 0.1.1
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/README.md +50 -9
- package/dist/dynamodb/client.d.ts +83 -0
- package/dist/dynamodb/client.d.ts.map +1 -0
- package/dist/dynamodb/client.js +6 -0
- package/dist/dynamodb/client.js.map +1 -0
- package/dist/dynamodb/index.d.ts +5 -1
- package/dist/dynamodb/index.d.ts.map +1 -1
- package/dist/dynamodb/index.js +7 -3
- package/dist/dynamodb/index.js.map +1 -1
- package/dist/dynamodb/item.d.ts +2 -0
- package/dist/dynamodb/item.d.ts.map +1 -1
- package/dist/dynamodb/item.js +5 -4
- package/dist/dynamodb/item.js.map +1 -1
- package/dist/dynamodb/marker.d.ts +11 -0
- package/dist/dynamodb/marker.d.ts.map +1 -0
- package/dist/dynamodb/marker.js +56 -0
- package/dist/dynamodb/marker.js.map +1 -0
- package/dist/dynamodb/repository.d.ts +61 -0
- package/dist/dynamodb/repository.d.ts.map +1 -0
- package/dist/dynamodb/repository.js +731 -0
- package/dist/dynamodb/repository.js.map +1 -0
- package/dist/dynamodb/requests.d.ts +65 -0
- package/dist/dynamodb/requests.d.ts.map +1 -0
- package/dist/dynamodb/requests.js +179 -0
- package/dist/dynamodb/requests.js.map +1 -0
- package/dist/errors.d.ts +96 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +103 -2
- package/dist/errors.js.map +1 -1
- package/dist/features.d.ts +55 -0
- package/dist/features.d.ts.map +1 -0
- package/dist/features.js +126 -0
- package/dist/features.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/memory/index.d.ts +1 -1
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +1 -1
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/repository.d.ts +38 -2
- package/dist/memory/repository.d.ts.map +1 -1
- package/dist/memory/repository.js +95 -6
- package/dist/memory/repository.js.map +1 -1
- package/dist/repository.d.ts +30 -1
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +3 -4
- package/dist/repository.js.map +1 -1
- package/dist/store.d.ts +3 -3
- package/dist/store.js +3 -3
- package/package.json +12 -3
- package/src/dynamodb/client.ts +102 -0
- package/src/dynamodb/index.ts +26 -2
- package/src/dynamodb/item.ts +6 -4
- package/src/dynamodb/marker.ts +64 -0
- package/src/dynamodb/repository.ts +1030 -0
- package/src/dynamodb/requests.ts +256 -0
- package/src/errors.ts +153 -2
- package/src/features.ts +175 -0
- package/src/index.ts +24 -1
- package/src/memory/index.ts +9 -1
- package/src/memory/repository.ts +156 -6
- package/src/repository.ts +34 -6
- package/src/store.ts +3 -3
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
// The DynamoDB repository: one event-log table keyed by `pk` (the entity ID) and `sk` (the event
|
|
2
|
+
// sequence, 0 for the inline snapshot and -1 for the head item), as the storage format lays it
|
|
3
|
+
// out. Every commit is one TransactWriteItems call that opens with the feature-marker and
|
|
4
|
+
// rebuild-lock checks, and a failed one is classified by its cancellation reasons, then by reading
|
|
5
|
+
// the stream, the way the Go and Rust repositories classify it. Entity types with retention carry a
|
|
6
|
+
// `ttl` on every item, and the head item's is never lowered (storage format, "Retention").
|
|
7
|
+
//
|
|
8
|
+
// Pages, retries and the probe's reads each depend on the call before them, so they await in loops
|
|
9
|
+
// on purpose.
|
|
10
|
+
/* oxlint-disable no-await-in-loop */
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
CompactionUncoveredError,
|
|
14
|
+
ConflictError,
|
|
15
|
+
EvtError,
|
|
16
|
+
FeatureManifestChangedError,
|
|
17
|
+
HistoryDependentProjectionError,
|
|
18
|
+
RebuildInProgressError,
|
|
19
|
+
SequenceGapError,
|
|
20
|
+
StoreNotInitializedError,
|
|
21
|
+
StreamHeadDriftError,
|
|
22
|
+
StreamHeadsNotMigratedError,
|
|
23
|
+
TransientError,
|
|
24
|
+
} from '../errors.js'
|
|
25
|
+
import type {SerializedEvent, SerializedSnapshot} from '../event.js'
|
|
26
|
+
import {
|
|
27
|
+
type FeatureManifest,
|
|
28
|
+
STREAM_HEADS_FEATURE,
|
|
29
|
+
STREAM_HEADS_VERSION,
|
|
30
|
+
checkSupported,
|
|
31
|
+
declaresStreamHeads,
|
|
32
|
+
} from '../features.js'
|
|
33
|
+
import {int64Argument} from '../fields.js'
|
|
34
|
+
import {
|
|
35
|
+
type CallOptions,
|
|
36
|
+
type Commit,
|
|
37
|
+
type Repository,
|
|
38
|
+
type StoreInitializer,
|
|
39
|
+
checkBudget,
|
|
40
|
+
checkNotReserved,
|
|
41
|
+
commitSizes,
|
|
42
|
+
isReserved,
|
|
43
|
+
validateCommit,
|
|
44
|
+
} from '../repository.js'
|
|
45
|
+
import type {DynamoClient, Page, RequestOptions, TransactItem} from './client.js'
|
|
46
|
+
import {
|
|
47
|
+
HEAD_SORT_KEY,
|
|
48
|
+
type Item,
|
|
49
|
+
eventFromItem,
|
|
50
|
+
expiry,
|
|
51
|
+
headFromItem,
|
|
52
|
+
snapshotFromItem,
|
|
53
|
+
sortKey,
|
|
54
|
+
ttlFromItem,
|
|
55
|
+
} from './item.js'
|
|
56
|
+
import {FEATURES_PK, manifestFromItem, markerItem} from './marker.js'
|
|
57
|
+
import {
|
|
58
|
+
type CommitTtl,
|
|
59
|
+
commitTransaction,
|
|
60
|
+
controlKey,
|
|
61
|
+
headKey,
|
|
62
|
+
snapshotTransaction,
|
|
63
|
+
} from './requests.js'
|
|
64
|
+
|
|
65
|
+
/** How a {@link DynamoRepository} reads and initializes its store. */
|
|
66
|
+
export interface DynamoRepositoryOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Whether a write to a store that doesn't declare stream-heads initializes it, scanning the
|
|
69
|
+
* table to make sure it holds no data, instead of refusing with a
|
|
70
|
+
* {@link StoreNotInitializedError}. Off by default, so writers never need `dynamodb:Scan`:
|
|
71
|
+
* initialize a fresh store once, at deploy time, with {@link DynamoRepository.initialize}.
|
|
72
|
+
*/
|
|
73
|
+
lazyInitialization?: boolean
|
|
74
|
+
/**
|
|
75
|
+
* Whether event and snapshot reads are strongly consistent. On by default. Head reads, the
|
|
76
|
+
* failure-path probe, compaction and the feature marker always read consistently.
|
|
77
|
+
*/
|
|
78
|
+
consistentRead?: boolean
|
|
79
|
+
/**
|
|
80
|
+
* How long the items of each entity type live, in whole milliseconds, keyed by entity type.
|
|
81
|
+
* Every event, snapshot and head item of a type with a positive retention carries a DynamoDB
|
|
82
|
+
* `ttl`, the write time plus the retention in Unix seconds, for the table's TTL to delete. A
|
|
83
|
+
* type that isn't listed, or has 0, is kept forever. TTL deletes items in no order, so retain
|
|
84
|
+
* only short-lived streams that nothing replays. Declare the retained types in the store's
|
|
85
|
+
* feature manifest too, from Go or Rust, so that their writers refuse read fences on them.
|
|
86
|
+
*/
|
|
87
|
+
retention?: Readonly<Record<string, number>> | ReadonlyMap<string, number>
|
|
88
|
+
/**
|
|
89
|
+
* The clock that stamps `ttl`s and decides which have passed, in Unix milliseconds as
|
|
90
|
+
* `Date.now` gives them, never seconds. `Date.now` by default.
|
|
91
|
+
*/
|
|
92
|
+
clock?: () => number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A declaration is a read-modify-write; racing declarations retry this many times.
|
|
96
|
+
const MAX_DECLARE_ATTEMPTS = 8
|
|
97
|
+
|
|
98
|
+
// Each page of a scan that only has to find one item.
|
|
99
|
+
const SCAN_PROBE_PAGE_SIZE = 100
|
|
100
|
+
|
|
101
|
+
// The most events one latest(n) page reads.
|
|
102
|
+
const LATEST_PAGE_SIZE = 100
|
|
103
|
+
|
|
104
|
+
// The most deletes in one compaction transaction, beside its marker check.
|
|
105
|
+
const COMPACTION_BATCH_SIZE = 99
|
|
106
|
+
|
|
107
|
+
// How many streams' loaded `ttl`s a repository remembers before it forgets them all.
|
|
108
|
+
const MAX_TTL_FLOORS = 4096
|
|
109
|
+
|
|
110
|
+
// How often a snapshot write sends its head guard again after another writer moved the head.
|
|
111
|
+
const MAX_SNAPSHOT_GUARD_RETRIES = 4
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A {@link Repository} on a DynamoDB event-log table, through the AWS SDK's low-level `DynamoDB`
|
|
115
|
+
* client or anything shaped like it ({@link DynamoClient}).
|
|
116
|
+
*
|
|
117
|
+
* The repository validates the store's feature manifest at its first write and caches the version
|
|
118
|
+
* it validated. Every commit and snapshot write checks that version, so when another writer
|
|
119
|
+
* declares a feature, the next write fails, validates the manifest again, and either refuses an
|
|
120
|
+
* unsupported feature or fails once with a retryable {@link FeatureManifestChangedError}.
|
|
121
|
+
*/
|
|
122
|
+
export class DynamoRepository implements Repository, StoreInitializer {
|
|
123
|
+
readonly #client: DynamoClient
|
|
124
|
+
readonly #table: string
|
|
125
|
+
readonly #lazy: boolean
|
|
126
|
+
readonly #consistentRead: boolean
|
|
127
|
+
readonly #retention: ReadonlyMap<string, number>
|
|
128
|
+
readonly #clock: () => number
|
|
129
|
+
|
|
130
|
+
// The largest `ttl` this repository loaded for each stream, which a commit's head `ttl` covers.
|
|
131
|
+
readonly #ttlFloors = new Map<string, number>()
|
|
132
|
+
|
|
133
|
+
// The manifest version this writer validated, or null until it validates.
|
|
134
|
+
#validated: bigint | null = null
|
|
135
|
+
// Serializes validation, so concurrent writes read and declare the manifest once.
|
|
136
|
+
#lock: Promise<unknown> = Promise.resolve()
|
|
137
|
+
|
|
138
|
+
constructor(client: DynamoClient, eventsTable: string, options: DynamoRepositoryOptions = {}) {
|
|
139
|
+
this.#client = client
|
|
140
|
+
this.#table = eventsTable
|
|
141
|
+
this.#lazy = options.lazyInitialization ?? false
|
|
142
|
+
this.#consistentRead = options.consistentRead ?? true
|
|
143
|
+
this.#retention = retentionMap(options.retention)
|
|
144
|
+
this.#clock = options.clock ?? Date.now
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The events table this repository writes. */
|
|
148
|
+
get eventsTable(): string {
|
|
149
|
+
return this.#table
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Declares stream-heads on a store that holds no data, and resolves `true` when this call
|
|
154
|
+
* declared it. It scans the table with strongly consistent reads, so run it once, at deploy
|
|
155
|
+
* time, with a role that may Scan. Concurrent initializations let exactly one declare.
|
|
156
|
+
*/
|
|
157
|
+
async initialize(options?: CallOptions): Promise<boolean> {
|
|
158
|
+
options?.signal?.throwIfAborted()
|
|
159
|
+
const request = requestOptions(options)
|
|
160
|
+
|
|
161
|
+
return this.#exclusive(async () => {
|
|
162
|
+
try {
|
|
163
|
+
const manifest = await this.#readManifest(request)
|
|
164
|
+
checkSupported(manifest)
|
|
165
|
+
if (declaresStreamHeads(manifest)) {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
if (!(await this.#storeIsEmpty(request))) {
|
|
169
|
+
throw new StreamHeadsNotMigratedError()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Another initializer may have declared a feature this package doesn't model.
|
|
173
|
+
const {manifest: declared, wrote} = await this.#declareOnEmptyStore(request)
|
|
174
|
+
checkSupported(declared)
|
|
175
|
+
|
|
176
|
+
return wrote
|
|
177
|
+
} finally {
|
|
178
|
+
// A declaration, or its retraction, moved the version: the next write reads it again.
|
|
179
|
+
this.#validated = null
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async commit(commit: Commit, options?: CallOptions): Promise<void> {
|
|
185
|
+
options?.signal?.throwIfAborted()
|
|
186
|
+
validateCommit(commit)
|
|
187
|
+
checkNotReserved(commit.entityId)
|
|
188
|
+
if (commit.events.length === 0) {
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// One `ttl` for the whole commit, so its head's is never below one of its own items.
|
|
193
|
+
const entityType = commit.events[0]!.entityType
|
|
194
|
+
const ttl = this.#ttlFor(entityType)
|
|
195
|
+
|
|
196
|
+
// Every backend budgets a commit the same way, so an oversized one fails before any request.
|
|
197
|
+
checkBudget(commitSizes(commit, (type) => (type === entityType ? ttl : undefined)))
|
|
198
|
+
|
|
199
|
+
const request = requestOptions(options)
|
|
200
|
+
const version = await this.#markerVersion(request)
|
|
201
|
+
const items = commitTransaction(this.#table, commit, version, this.#commitTtl(commit, ttl))
|
|
202
|
+
try {
|
|
203
|
+
await this.#client.transactWriteItems({TransactItems: items}, request)
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw await this.#classifyCommit(commit, error, request)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async events(entityId: string, options?: CallOptions): Promise<SerializedEvent[]> {
|
|
210
|
+
return this.eventsAfter(entityId, 0n, options)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async eventsAfter(
|
|
214
|
+
entityId: string,
|
|
215
|
+
after: bigint,
|
|
216
|
+
options?: CallOptions,
|
|
217
|
+
): Promise<SerializedEvent[]> {
|
|
218
|
+
options?.signal?.throwIfAborted()
|
|
219
|
+
int64Argument(after, 'after')
|
|
220
|
+
|
|
221
|
+
// A negative `after` would reach the head item (sk -1) and the snapshot (sk 0).
|
|
222
|
+
const from = after > 0n ? after : 0n
|
|
223
|
+
const events: SerializedEvent[] = []
|
|
224
|
+
const pages = this.#query(entityId, from, true, undefined, requestOptions(options))
|
|
225
|
+
for await (const page of pages) {
|
|
226
|
+
events.push(...this.#decodeEvents(page))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return events
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async latest(entityId: string, n: number, options?: CallOptions): Promise<SerializedEvent[]> {
|
|
233
|
+
options?.signal?.throwIfAborted()
|
|
234
|
+
if (!Number.isSafeInteger(n) || n < 1) {
|
|
235
|
+
throw new EvtError(
|
|
236
|
+
'invalidArgument',
|
|
237
|
+
`latest(n) requires a whole number of at least one event: got n = ${n}`,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const limit = Math.min(n, LATEST_PAGE_SIZE)
|
|
242
|
+
const events: SerializedEvent[] = []
|
|
243
|
+
for await (const page of this.#query(entityId, 0n, false, limit, requestOptions(options))) {
|
|
244
|
+
events.push(...this.#decodeEvents(page))
|
|
245
|
+
if (events.length >= n) {
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return events.slice(0, n)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async snapshot(entityId: string, options?: CallOptions): Promise<SerializedSnapshot | null> {
|
|
254
|
+
options?.signal?.throwIfAborted()
|
|
255
|
+
|
|
256
|
+
return this.#readSnapshot(entityId, this.#consistentRead, requestOptions(options))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async putSnapshot(snapshot: SerializedSnapshot, options?: CallOptions): Promise<boolean> {
|
|
260
|
+
options?.signal?.throwIfAborted()
|
|
261
|
+
checkNotReserved(snapshot.entityId)
|
|
262
|
+
int64Argument(snapshot.sequence, 'snapshot.sequence')
|
|
263
|
+
const eventSequence = int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
|
|
264
|
+
|
|
265
|
+
// Stamped now, as the snapshot's own items are, so it expires with its stream.
|
|
266
|
+
const ttl = this.#ttlFor(snapshot.entityType)
|
|
267
|
+
const request = requestOptions(options)
|
|
268
|
+
const version = await this.#markerVersion(request)
|
|
269
|
+
for (let retries = 0, refresh = true; ; retries++) {
|
|
270
|
+
const guarded = ttl === undefined ? undefined : {ttl, refresh}
|
|
271
|
+
try {
|
|
272
|
+
await this.#client.transactWriteItems(
|
|
273
|
+
{TransactItems: snapshotTransaction(this.#table, snapshot, version, guarded)},
|
|
274
|
+
request,
|
|
275
|
+
)
|
|
276
|
+
this.#observeTtl(snapshot.entityId, ttl)
|
|
277
|
+
|
|
278
|
+
return true
|
|
279
|
+
} catch (error) {
|
|
280
|
+
const codes = cancellationCodes(error)
|
|
281
|
+
const failed = codes?.indexOf(CONDITION_FAILED) ?? -1
|
|
282
|
+
if (failed === 0) {
|
|
283
|
+
throw await this.#markerCheckFailed(error, request)
|
|
284
|
+
}
|
|
285
|
+
if (failed === 2) {
|
|
286
|
+
// A newer snapshot is stored, and stays.
|
|
287
|
+
return false
|
|
288
|
+
}
|
|
289
|
+
if (failed !== 1) {
|
|
290
|
+
if (codes?.some((code) => THROTTLED.has(code))) {
|
|
291
|
+
throw new TransientError('the snapshot write was throttled', {cause: error})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
throw error
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The head guard failed. A refreshing guard can fail on its ttl clause alone, when another
|
|
298
|
+
// writer moved the head: the head covers the snapshot, so send it again, refreshing only
|
|
299
|
+
// when the stored ttl is still below the snapshot's.
|
|
300
|
+
const head = await this.#readHead(snapshot.entityId, request)
|
|
301
|
+
const refreshed = guarded?.refresh === true && eventSequence > 0n
|
|
302
|
+
if (refreshed && head !== null && head.head >= eventSequence) {
|
|
303
|
+
if (retries >= MAX_SNAPSHOT_GUARD_RETRIES) {
|
|
304
|
+
throw new TransientError(
|
|
305
|
+
`the head item of ${snapshot.entityId} kept moving under its snapshot write`,
|
|
306
|
+
{cause: error},
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
refresh = (head.ttl ?? 0) < ttl!
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The head doesn't cover the snapshot: a stream whose log is ahead of its head.
|
|
315
|
+
const derived = await this.#deriveHead(snapshot.entityId, request)
|
|
316
|
+
throw drift(
|
|
317
|
+
snapshot.entityId,
|
|
318
|
+
head?.head ?? null,
|
|
319
|
+
derived > eventSequence ? derived : eventSequence,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async compactBelow(entityId: string, through: bigint, options?: CallOptions): Promise<number> {
|
|
326
|
+
options?.signal?.throwIfAborted()
|
|
327
|
+
checkNotReserved(entityId)
|
|
328
|
+
if (int64Argument(through, 'through') < 1n) {
|
|
329
|
+
return 0
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const request = requestOptions(options)
|
|
333
|
+
const snapshot = await this.#readSnapshot(entityId, true, request)
|
|
334
|
+
let check = this.#compactionCheck(await this.#readManifest(request), entityId, snapshot)
|
|
335
|
+
if (snapshot === null || snapshot.eventSequence < through) {
|
|
336
|
+
throw new CompactionUncoveredError(entityId, through, snapshot?.eventSequence ?? null)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const keys: Item[] = []
|
|
340
|
+
for await (const page of this.#rangeKeys(entityId, through, request)) {
|
|
341
|
+
keys.push(...page)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Each batch of deletes checks the manifest the writer validated. A declaration between
|
|
345
|
+
// batches stops the later ones, but can't roll back those already deleted.
|
|
346
|
+
let deleted = 0
|
|
347
|
+
for (let start = 0; start < keys.length; start += COMPACTION_BATCH_SIZE) {
|
|
348
|
+
const batch = keys.slice(start, start + COMPACTION_BATCH_SIZE)
|
|
349
|
+
for (let attempt = 0; ; attempt++) {
|
|
350
|
+
const deletes: TransactItem[] = batch.map((key) => ({
|
|
351
|
+
Delete: {TableName: this.#table, Key: key},
|
|
352
|
+
}))
|
|
353
|
+
try {
|
|
354
|
+
await this.#client.transactWriteItems({TransactItems: [check, ...deletes]}, request)
|
|
355
|
+
deleted += batch.length
|
|
356
|
+
break
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (cancellationCodes(error)?.indexOf(CONDITION_FAILED) !== 0) {
|
|
359
|
+
throw error
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Validate the new manifest first, so a declaration that refuses compaction says so.
|
|
363
|
+
check = this.#compactionCheck(await this.#readManifest(request), entityId, snapshot)
|
|
364
|
+
if (attempt >= MAX_DECLARE_ATTEMPTS) {
|
|
365
|
+
throw new FeatureManifestChangedError({cause: error})
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return deleted
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Validates the manifest for compaction, which never declares stream-heads, and builds the check
|
|
375
|
+
// each batch carries on the version it read.
|
|
376
|
+
#compactionCheck(
|
|
377
|
+
manifest: FeatureManifest | null,
|
|
378
|
+
entityId: string,
|
|
379
|
+
snapshot: SerializedSnapshot | null,
|
|
380
|
+
): TransactItem {
|
|
381
|
+
checkSupported(manifest)
|
|
382
|
+
if (manifest === null || !declaresStreamHeads(manifest)) {
|
|
383
|
+
throw this.#lazy ? new StreamHeadsNotMigratedError() : new StoreNotInitializedError()
|
|
384
|
+
}
|
|
385
|
+
if (snapshot && manifest.historyDependent.includes(snapshot.entityType)) {
|
|
386
|
+
throw new HistoryDependentProjectionError(snapshot.entityType, entityId)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
ConditionCheck: {
|
|
391
|
+
TableName: this.#table,
|
|
392
|
+
Key: controlKey(FEATURES_PK),
|
|
393
|
+
ConditionExpression: 'version = :observed',
|
|
394
|
+
ExpressionAttributeValues: {':observed': {N: manifest.version.toString()}},
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// The `ttl` an item of the entity type written now carries, or undefined without retention.
|
|
400
|
+
#ttlFor(entityType: string): number | undefined {
|
|
401
|
+
const retention = this.#retention.get(entityType)
|
|
402
|
+
const ttl = retention === undefined ? undefined : expiry(this.#clock(), retention)
|
|
403
|
+
|
|
404
|
+
// An expiry at or before the epoch is no ttl at all, as Go writes none for 0.
|
|
405
|
+
return ttl !== undefined && ttl > 0 ? ttl : undefined
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The commit's `ttl`s: its own on its items, and on its head the larger of its own and what this
|
|
409
|
+
// writer loaded for the stream, except on a create, which has nothing to cover.
|
|
410
|
+
#commitTtl(commit: Commit, ttl: number | undefined): CommitTtl | undefined {
|
|
411
|
+
if (ttl === undefined) {
|
|
412
|
+
return undefined
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const create = commit.events[0]!.sequence <= 1n
|
|
416
|
+
const floor = create ? 0 : (this.#ttlFloors.get(commit.entityId) ?? 0)
|
|
417
|
+
|
|
418
|
+
return {items: ttl, head: Math.max(ttl, floor)}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Remembers a loaded item's `ttl` for its stream. The memory is bounded: a full one is cleared,
|
|
422
|
+
// which costs at most a conflict and a retry, whose load observes the `ttl` again.
|
|
423
|
+
#observeTtl(entityId: string, ttl: number | undefined): void {
|
|
424
|
+
if (ttl === undefined || ttl <= 0) {
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const current = this.#ttlFloors.get(entityId)
|
|
429
|
+
if (current === undefined && this.#ttlFloors.size >= MAX_TTL_FLOORS) {
|
|
430
|
+
this.#ttlFloors.clear()
|
|
431
|
+
}
|
|
432
|
+
if (current === undefined || ttl > current) {
|
|
433
|
+
this.#ttlFloors.set(entityId, ttl)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#decodeEvents(items: Item[]): SerializedEvent[] {
|
|
438
|
+
const events: SerializedEvent[] = []
|
|
439
|
+
for (const item of items) {
|
|
440
|
+
const event = eventFromItem(item)
|
|
441
|
+
if (event.sequence >= 1n) {
|
|
442
|
+
this.#observeTtl(event.entityId, ttlFromItem(item))
|
|
443
|
+
events.push(event)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return events
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// The manifest version the next write checks, validating it first when this writer hasn't.
|
|
451
|
+
#markerVersion(request: RequestOptions): Promise<bigint> {
|
|
452
|
+
return this.#exclusive(async () => {
|
|
453
|
+
const version = await this.#validate(true, request)
|
|
454
|
+
if (version === null) {
|
|
455
|
+
throw new RangeError('a declaring validation always caches a version')
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return version
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Reads and validates the manifest, unless this writer cached a validation. A store that doesn't
|
|
463
|
+
// declare stream-heads is refused as uninitialized, unless the writer initializes lazily: then
|
|
464
|
+
// one that holds data is refused as unmigrated, and an empty one is declared when `declare` is
|
|
465
|
+
// set. Resolves to the cached version, or null when nothing was cached. The caller holds #lock.
|
|
466
|
+
async #validate(declare: boolean, request: RequestOptions): Promise<bigint | null> {
|
|
467
|
+
if (this.#validated !== null) {
|
|
468
|
+
return this.#validated
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
let manifest = await this.#readManifest(request)
|
|
472
|
+
checkSupported(manifest)
|
|
473
|
+
if (!declaresStreamHeads(manifest)) {
|
|
474
|
+
if (!this.#lazy) {
|
|
475
|
+
throw new StoreNotInitializedError()
|
|
476
|
+
}
|
|
477
|
+
if (!(await this.#storeIsEmpty(request))) {
|
|
478
|
+
throw new StreamHeadsNotMigratedError()
|
|
479
|
+
}
|
|
480
|
+
if (!declare) {
|
|
481
|
+
return null
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Another writer may have declared a feature this package doesn't model.
|
|
485
|
+
const declared = await this.#declareOnEmptyStore(request)
|
|
486
|
+
checkSupported(declared.manifest)
|
|
487
|
+
manifest = declared.manifest
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.#validated = manifest!.version
|
|
491
|
+
|
|
492
|
+
return this.#validated
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// A failed marker check: the manifest changed under this writer. It validates the new one at
|
|
496
|
+
// once, so an unsupported feature refuses the write, and anything else is retryable.
|
|
497
|
+
async #markerCheckFailed(cause: unknown, request: RequestOptions): Promise<EvtError> {
|
|
498
|
+
try {
|
|
499
|
+
// Dropped under the lock, so a validation already running can't cache the old version again.
|
|
500
|
+
await this.#exclusive(() => {
|
|
501
|
+
this.#validated = null
|
|
502
|
+
|
|
503
|
+
return this.#validate(false, request)
|
|
504
|
+
})
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (error instanceof EvtError) {
|
|
507
|
+
return error
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
throw error
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return new FeatureManifestChangedError({cause})
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async #classifyCommit(commit: Commit, error: unknown, request: RequestOptions): Promise<unknown> {
|
|
517
|
+
const codes = cancellationCodes(error)
|
|
518
|
+
if (codes === null) {
|
|
519
|
+
return error
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const failed = codes.indexOf(CONDITION_FAILED)
|
|
523
|
+
if (failed === -1) {
|
|
524
|
+
if (codes.includes('TransactionConflict')) {
|
|
525
|
+
return new ConflictError(
|
|
526
|
+
commit.entityId,
|
|
527
|
+
'transaction conflict: another writer was committing at the same time',
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
if (codes.some((code) => THROTTLED.has(code))) {
|
|
531
|
+
return new TransientError('the commit was throttled', {cause: error})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return error
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The control checks outrank every stream failure: the marker, then the rebuild lock.
|
|
538
|
+
if (failed === 0) {
|
|
539
|
+
return this.#markerCheckFailed(error, request)
|
|
540
|
+
}
|
|
541
|
+
if (codes[1] === CONDITION_FAILED) {
|
|
542
|
+
return new RebuildInProgressError()
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// The stream's head update, then its event puts and its snapshot put.
|
|
546
|
+
const head = 2
|
|
547
|
+
const eventsEnd = head + 1 + commit.events.length
|
|
548
|
+
const eventFailed = codes.slice(head + 1, eventsEnd).includes(CONDITION_FAILED)
|
|
549
|
+
const {entityId} = commit
|
|
550
|
+
const first = commit.events[0]!.sequence
|
|
551
|
+
const retained = this.#retention.has(commit.events[0]!.entityType)
|
|
552
|
+
|
|
553
|
+
if (codes[head] === CONDITION_FAILED) {
|
|
554
|
+
const verdict = await this.#probe(entityId, retained, request)
|
|
555
|
+
if (verdict.drift) {
|
|
556
|
+
return verdict.drift
|
|
557
|
+
}
|
|
558
|
+
if (verdict.healthy && first > 1n && verdict.stored < first - 1n) {
|
|
559
|
+
return new SequenceGapError(entityId, verdict.stored, first)
|
|
560
|
+
}
|
|
561
|
+
} else if (codes[head] === 'None' && eventFailed) {
|
|
562
|
+
// The head update passed and an event put failed: an event sits above the head. With
|
|
563
|
+
// retention, TTL can delete the head before an expired event, so a create passes its head
|
|
564
|
+
// condition and fails on the leftover item: a conflict until TTL deletes the rest.
|
|
565
|
+
const verdict = await this.#probe(entityId, retained, request)
|
|
566
|
+
if (!retained || !(await this.#remainingItemsExpired(entityId, request))) {
|
|
567
|
+
return verdict.drift ?? drift(entityId, verdict.head, verdict.derived)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return new ConflictError(
|
|
572
|
+
entityId,
|
|
573
|
+
'conditional check failed: the sequence or snapshot changed since it was read',
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Classifies a stream whose head update failed, as the failure-path probe does: a stored head
|
|
578
|
+
// below the head its log derives is drift, an equal one is healthy, and one above it is either a
|
|
579
|
+
// writer that committed in between (derive again) or drift. With retention, TTL deletes items in
|
|
580
|
+
// no order, so a head above its log, or a missing head over items that have all expired, is a
|
|
581
|
+
// conflict that heals once TTL deletes the rest.
|
|
582
|
+
async #probe(entityId: string, retained: boolean, request: RequestOptions): Promise<Verdict> {
|
|
583
|
+
const derived = await this.#deriveHead(entityId, request)
|
|
584
|
+
const head = (await this.#readHead(entityId, request))?.head ?? null
|
|
585
|
+
const stored = head ?? 0n
|
|
586
|
+
if (stored < derived) {
|
|
587
|
+
if (retained && head === null && (await this.#remainingItemsExpired(entityId, request))) {
|
|
588
|
+
return {healthy: false, drift: null, stored, head, derived}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {healthy: false, drift: drift(entityId, head, derived), stored, head, derived}
|
|
592
|
+
}
|
|
593
|
+
if (stored === derived) {
|
|
594
|
+
return {healthy: true, drift: null, stored, head, derived}
|
|
595
|
+
}
|
|
596
|
+
if (retained) {
|
|
597
|
+
// The head outlived part of the log it counted: TTL deleted the tail first.
|
|
598
|
+
return {healthy: false, drift: null, stored, head, derived}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const second = await this.#deriveHead(entityId, request)
|
|
602
|
+
const drifted = second < stored ? drift(entityId, head, second) : null
|
|
603
|
+
|
|
604
|
+
return {healthy: false, drift: drifted, stored, head, derived: second}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// A stream's head as its log derives it, with strongly consistent reads: the higher of its
|
|
608
|
+
// snapshot's event sequence and its highest event sequence.
|
|
609
|
+
async #deriveHead(entityId: string, request: RequestOptions): Promise<bigint> {
|
|
610
|
+
const snapshot = await this.#client.getItem(
|
|
611
|
+
{
|
|
612
|
+
TableName: this.#table,
|
|
613
|
+
Key: controlKey(entityId),
|
|
614
|
+
ConsistentRead: true,
|
|
615
|
+
ProjectionExpression: 'eventSeq',
|
|
616
|
+
},
|
|
617
|
+
request,
|
|
618
|
+
)
|
|
619
|
+
const eventSeq = (snapshot.Item as Item | undefined)?.['eventSeq']
|
|
620
|
+
let derived = eventSeq && 'N' in eventSeq ? BigInt(eventSeq.N) : 0n
|
|
621
|
+
|
|
622
|
+
const latest = await this.#client.query(
|
|
623
|
+
{
|
|
624
|
+
TableName: this.#table,
|
|
625
|
+
KeyConditionExpression: 'pk = :pk AND sk > :sk',
|
|
626
|
+
ExpressionAttributeValues: {':pk': {S: entityId}, ':sk': {N: '0'}},
|
|
627
|
+
ConsistentRead: true,
|
|
628
|
+
ProjectionExpression: 'sk',
|
|
629
|
+
ScanIndexForward: false,
|
|
630
|
+
Limit: 1,
|
|
631
|
+
},
|
|
632
|
+
request,
|
|
633
|
+
)
|
|
634
|
+
const [item] = (latest.Items ?? []) as Item[]
|
|
635
|
+
const sequence = item && sortKey(item)
|
|
636
|
+
if (sequence !== undefined && sequence > derived) {
|
|
637
|
+
derived = sequence
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return derived
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Whether every item still stored for the stream, its snapshot and its events, has a `ttl` that
|
|
644
|
+
// has passed, so TTL will delete it. An item without a `ttl` never expires.
|
|
645
|
+
async #remainingItemsExpired(entityId: string, request: RequestOptions): Promise<boolean> {
|
|
646
|
+
const now = Math.floor(this.#clock() / 1000)
|
|
647
|
+
const expired = (item: Item): boolean => {
|
|
648
|
+
const ttl = ttlFromItem(item)
|
|
649
|
+
|
|
650
|
+
return ttl !== undefined && ttl <= now
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const snapshot = await this.#client.getItem(
|
|
654
|
+
{
|
|
655
|
+
TableName: this.#table,
|
|
656
|
+
Key: controlKey(entityId),
|
|
657
|
+
ConsistentRead: true,
|
|
658
|
+
// The key too, so a snapshot without a ttl doesn't read back empty, as an absent one does.
|
|
659
|
+
ProjectionExpression: 'sk, #ttl',
|
|
660
|
+
ExpressionAttributeNames: {'#ttl': 'ttl'},
|
|
661
|
+
},
|
|
662
|
+
request,
|
|
663
|
+
)
|
|
664
|
+
const item = snapshot.Item as Item | undefined
|
|
665
|
+
if (item && Object.keys(item).length > 0 && !expired(item)) {
|
|
666
|
+
return false
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let start: Item | undefined
|
|
670
|
+
do {
|
|
671
|
+
const page = await this.#client.query(
|
|
672
|
+
{
|
|
673
|
+
TableName: this.#table,
|
|
674
|
+
KeyConditionExpression: 'pk = :pk AND sk > :sk',
|
|
675
|
+
ExpressionAttributeNames: {'#ttl': 'ttl'},
|
|
676
|
+
ExpressionAttributeValues: {':pk': {S: entityId}, ':sk': {N: '0'}},
|
|
677
|
+
ConsistentRead: true,
|
|
678
|
+
ProjectionExpression: 'sk, #ttl',
|
|
679
|
+
...(start && {ExclusiveStartKey: start}),
|
|
680
|
+
},
|
|
681
|
+
request,
|
|
682
|
+
)
|
|
683
|
+
if (((page.Items ?? []) as Item[]).some((event) => !expired(event))) {
|
|
684
|
+
return false
|
|
685
|
+
}
|
|
686
|
+
start = page.LastEvaluatedKey as Item | undefined
|
|
687
|
+
} while (start !== undefined)
|
|
688
|
+
|
|
689
|
+
return true
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// The stored head and its `ttl`, or null when the stream has no head item.
|
|
693
|
+
async #readHead(
|
|
694
|
+
entityId: string,
|
|
695
|
+
request: RequestOptions,
|
|
696
|
+
): Promise<{head: bigint; ttl: number | undefined} | null> {
|
|
697
|
+
const output = await this.#client.getItem(
|
|
698
|
+
{TableName: this.#table, Key: headKey(entityId), ConsistentRead: true},
|
|
699
|
+
request,
|
|
700
|
+
)
|
|
701
|
+
const item = output.Item as Item | undefined
|
|
702
|
+
if (!item || Object.keys(item).length === 0) {
|
|
703
|
+
return null
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// A head item without its attribute reads as head 0, as Go reads it, so it classifies as drift.
|
|
707
|
+
const head =
|
|
708
|
+
item['head'] === undefined ? {head: 0n, ttl: ttlFromItem(item)} : headFromItem(item)
|
|
709
|
+
this.#observeTtl(entityId, head.ttl)
|
|
710
|
+
|
|
711
|
+
return head
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async #readSnapshot(
|
|
715
|
+
entityId: string,
|
|
716
|
+
consistent: boolean,
|
|
717
|
+
request: RequestOptions,
|
|
718
|
+
): Promise<SerializedSnapshot | null> {
|
|
719
|
+
const output = await this.#client.getItem(
|
|
720
|
+
{TableName: this.#table, Key: controlKey(entityId), ConsistentRead: consistent},
|
|
721
|
+
request,
|
|
722
|
+
)
|
|
723
|
+
const item = output.Item as Item | undefined
|
|
724
|
+
if (!item || Object.keys(item).length === 0) {
|
|
725
|
+
return null
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
this.#observeTtl(entityId, ttlFromItem(item))
|
|
729
|
+
|
|
730
|
+
return snapshotFromItem(item)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// The marker row, with a strongly consistent read, or null when the store has none.
|
|
734
|
+
async #readManifest(request: RequestOptions): Promise<FeatureManifest | null> {
|
|
735
|
+
const output = await this.#client.getItem(
|
|
736
|
+
{TableName: this.#table, Key: controlKey(FEATURES_PK), ConsistentRead: true},
|
|
737
|
+
request,
|
|
738
|
+
)
|
|
739
|
+
const item = output.Item as Item | undefined
|
|
740
|
+
|
|
741
|
+
return item && Object.keys(item).length > 0 ? manifestFromItem(item) : null
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Applies a change to the manifest in one conditional write that bumps its version by one, and
|
|
745
|
+
// retries against a racing declaration. A change that reports false needs no write: the
|
|
746
|
+
// manifest already says what it would.
|
|
747
|
+
async #declare(
|
|
748
|
+
change: (draft: Draft) => boolean,
|
|
749
|
+
request: RequestOptions,
|
|
750
|
+
): Promise<{manifest: FeatureManifest; wrote: boolean}> {
|
|
751
|
+
for (let attempt = 0; ; attempt++) {
|
|
752
|
+
const stored = await this.#readManifest(request)
|
|
753
|
+
const draft: Draft = {
|
|
754
|
+
features: new Map(stored?.features),
|
|
755
|
+
historyDependent: [...(stored?.historyDependent ?? [])],
|
|
756
|
+
retention: [...(stored?.retention ?? [])],
|
|
757
|
+
}
|
|
758
|
+
if (!change(draft)) {
|
|
759
|
+
return {manifest: stored ?? {...draft, version: 0n}, wrote: false}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const manifest: FeatureManifest = {...draft, version: (stored?.version ?? 0n) + 1n}
|
|
763
|
+
const condition =
|
|
764
|
+
stored === null
|
|
765
|
+
? {ConditionExpression: 'attribute_not_exists(pk)'}
|
|
766
|
+
: {
|
|
767
|
+
ConditionExpression: 'version = :observed',
|
|
768
|
+
ExpressionAttributeValues: {':observed': {N: stored.version.toString()}},
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
await this.#client.putItem(
|
|
772
|
+
{TableName: this.#table, Item: markerItem(manifest), ...condition},
|
|
773
|
+
request,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
return {manifest, wrote: true}
|
|
777
|
+
} catch (error) {
|
|
778
|
+
if (!isConditionalCheckFailed(error) || attempt >= MAX_DECLARE_ATTEMPTS) {
|
|
779
|
+
throw error
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Declares stream-heads on a store that held no data when it was scanned, then closes the window
|
|
786
|
+
// since: the declaration commits first, so every marker-reading writer sees it, and only then is
|
|
787
|
+
// the table scanned for a stream without a head item, which retracts it. When another
|
|
788
|
+
// initializer declared first, its manifest is the one reported, as it stands now.
|
|
789
|
+
async #declareOnEmptyStore(
|
|
790
|
+
request: RequestOptions,
|
|
791
|
+
): Promise<{manifest: FeatureManifest; wrote: boolean}> {
|
|
792
|
+
const declared = await this.#declare((draft) => {
|
|
793
|
+
if (draft.features.get(STREAM_HEADS_FEATURE) === STREAM_HEADS_VERSION) {
|
|
794
|
+
return false
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
draft.features.set(STREAM_HEADS_FEATURE, STREAM_HEADS_VERSION)
|
|
798
|
+
|
|
799
|
+
return true
|
|
800
|
+
}, request)
|
|
801
|
+
|
|
802
|
+
if (!declared.wrote) {
|
|
803
|
+
// The winner ran its own verification, which may have retracted its declaration.
|
|
804
|
+
const current = await this.#readManifest(request)
|
|
805
|
+
if (!declaresStreamHeads(current)) {
|
|
806
|
+
throw new StreamHeadsNotMigratedError()
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return {manifest: current!, wrote: false}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (await this.#hasHeadlessStream(request)) {
|
|
813
|
+
await this.#declare((draft) => draft.features.delete(STREAM_HEADS_FEATURE), request)
|
|
814
|
+
throw new StreamHeadsNotMigratedError()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return declared
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Whether the table holds no item outside the reserved namespaces, by a strongly consistent scan
|
|
821
|
+
// that stops at the first one it finds.
|
|
822
|
+
async #storeIsEmpty(request: RequestOptions): Promise<boolean> {
|
|
823
|
+
for await (const items of this.#scan('pk', request)) {
|
|
824
|
+
if (items.some((item) => !reservedItem(item))) {
|
|
825
|
+
return false
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return true
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Whether any stream has items but no head item. A scan isn't atomic across items, so a stream
|
|
833
|
+
// that looks headless is confirmed by reading its head item.
|
|
834
|
+
async #hasHeadlessStream(request: RequestOptions): Promise<boolean> {
|
|
835
|
+
const streams = new Set<string>()
|
|
836
|
+
const headed = new Set<string>()
|
|
837
|
+
for await (const items of this.#scan('pk, sk', request)) {
|
|
838
|
+
for (const item of items) {
|
|
839
|
+
const pk = item['pk']
|
|
840
|
+
if (pk === undefined || !('S' in pk) || isReserved(pk.S)) {
|
|
841
|
+
continue
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
streams.add(pk.S)
|
|
845
|
+
if (sortKey(item) === HEAD_SORT_KEY) {
|
|
846
|
+
headed.add(pk.S)
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
for (const entityId of streams) {
|
|
852
|
+
if (!headed.has(entityId) && (await this.#readHead(entityId, request)) === null) {
|
|
853
|
+
return true
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return false
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async *#scan(projection: string, request: RequestOptions): AsyncGenerator<Item[]> {
|
|
861
|
+
let start: Item | undefined
|
|
862
|
+
do {
|
|
863
|
+
const page: Page = await this.#client.scan(
|
|
864
|
+
{
|
|
865
|
+
TableName: this.#table,
|
|
866
|
+
ConsistentRead: true,
|
|
867
|
+
ProjectionExpression: projection,
|
|
868
|
+
Limit: SCAN_PROBE_PAGE_SIZE,
|
|
869
|
+
...(start && {ExclusiveStartKey: start}),
|
|
870
|
+
},
|
|
871
|
+
request,
|
|
872
|
+
)
|
|
873
|
+
yield (page.Items ?? []) as Item[]
|
|
874
|
+
start = page.LastEvaluatedKey as Item | undefined
|
|
875
|
+
} while (start !== undefined)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// The pages of a stream's events after `after`, in either order.
|
|
879
|
+
async *#query(
|
|
880
|
+
entityId: string,
|
|
881
|
+
after: bigint,
|
|
882
|
+
ascending: boolean,
|
|
883
|
+
limit: number | undefined,
|
|
884
|
+
request: RequestOptions,
|
|
885
|
+
): AsyncGenerator<Item[]> {
|
|
886
|
+
let start: Item | undefined
|
|
887
|
+
do {
|
|
888
|
+
const page = await this.#client.query(
|
|
889
|
+
{
|
|
890
|
+
TableName: this.#table,
|
|
891
|
+
KeyConditionExpression: 'pk = :pk AND sk > :sk',
|
|
892
|
+
ExpressionAttributeValues: {':pk': {S: entityId}, ':sk': {N: after.toString()}},
|
|
893
|
+
ConsistentRead: this.#consistentRead,
|
|
894
|
+
ScanIndexForward: ascending,
|
|
895
|
+
...(limit !== undefined && {Limit: limit}),
|
|
896
|
+
...(start && {ExclusiveStartKey: start}),
|
|
897
|
+
},
|
|
898
|
+
request,
|
|
899
|
+
)
|
|
900
|
+
yield (page.Items ?? []) as Item[]
|
|
901
|
+
start = page.LastEvaluatedKey as Item | undefined
|
|
902
|
+
} while (start !== undefined)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// The keys of the stream's events 1 to `through`, read consistently.
|
|
906
|
+
async *#rangeKeys(
|
|
907
|
+
entityId: string,
|
|
908
|
+
through: bigint,
|
|
909
|
+
request: RequestOptions,
|
|
910
|
+
): AsyncGenerator<Item[]> {
|
|
911
|
+
let start: Item | undefined
|
|
912
|
+
do {
|
|
913
|
+
const page = await this.#client.query(
|
|
914
|
+
{
|
|
915
|
+
TableName: this.#table,
|
|
916
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :lo AND :hi',
|
|
917
|
+
ExpressionAttributeValues: {
|
|
918
|
+
':pk': {S: entityId},
|
|
919
|
+
':lo': {N: '1'},
|
|
920
|
+
':hi': {N: through.toString()},
|
|
921
|
+
},
|
|
922
|
+
ConsistentRead: true,
|
|
923
|
+
ProjectionExpression: 'pk, sk',
|
|
924
|
+
...(start && {ExclusiveStartKey: start}),
|
|
925
|
+
},
|
|
926
|
+
request,
|
|
927
|
+
)
|
|
928
|
+
const items = (page.Items ?? []) as Item[]
|
|
929
|
+
yield items.flatMap(({pk, sk}) => (pk && sk ? [{pk, sk}] : []))
|
|
930
|
+
start = page.LastEvaluatedKey as Item | undefined
|
|
931
|
+
} while (start !== undefined)
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Runs `task` after every task queued before it, so validation reads and declares once.
|
|
935
|
+
#exclusive<T>(task: () => Promise<T>): Promise<T> {
|
|
936
|
+
const result = this.#lock.then(task, task)
|
|
937
|
+
this.#lock = result.catch(() => undefined)
|
|
938
|
+
|
|
939
|
+
return result
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const CONDITION_FAILED = 'ConditionalCheckFailed'
|
|
944
|
+
|
|
945
|
+
// The cancellation reasons of a transaction DynamoDB throttled, which Go and Rust retry too.
|
|
946
|
+
const THROTTLED = new Set(['ThrottlingError', 'ProvisionedThroughputExceeded'])
|
|
947
|
+
|
|
948
|
+
interface Draft {
|
|
949
|
+
features: Map<string, number>
|
|
950
|
+
historyDependent: string[]
|
|
951
|
+
retention: string[]
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
interface Verdict {
|
|
955
|
+
healthy: boolean
|
|
956
|
+
drift: StreamHeadDriftError | null
|
|
957
|
+
/** The stored head, 0 when the stream has none. */
|
|
958
|
+
stored: bigint
|
|
959
|
+
head: bigint | null
|
|
960
|
+
derived: bigint
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function drift(entityId: string, head: bigint | null, derived: bigint): StreamHeadDriftError {
|
|
964
|
+
return new StreamHeadDriftError(entityId, head, derived)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// The retention option as a map of the entity types with a positive retention, each checked to be
|
|
968
|
+
// whole milliseconds.
|
|
969
|
+
function retentionMap(
|
|
970
|
+
retention: Readonly<Record<string, number>> | ReadonlyMap<string, number> | undefined,
|
|
971
|
+
): ReadonlyMap<string, number> {
|
|
972
|
+
const entries = isMap(retention) ? [...retention.entries()] : Object.entries(retention ?? {})
|
|
973
|
+
const retained = new Map<string, number>()
|
|
974
|
+
for (const [entityType, milliseconds] of entries) {
|
|
975
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
|
|
976
|
+
throw new EvtError(
|
|
977
|
+
'invalidArgument',
|
|
978
|
+
`the retention of ${entityType} must be whole, non-negative milliseconds: ${milliseconds}`,
|
|
979
|
+
)
|
|
980
|
+
}
|
|
981
|
+
if (milliseconds > 0) {
|
|
982
|
+
retained.set(entityType, milliseconds)
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return retained
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// A map by its methods, so one from another realm counts too.
|
|
990
|
+
function isMap<K, V>(value: object | undefined): value is ReadonlyMap<K, V> {
|
|
991
|
+
const map = value as Partial<ReadonlyMap<K, V>> | undefined
|
|
992
|
+
|
|
993
|
+
return typeof map?.get === 'function' && typeof map.entries === 'function'
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function reservedItem(item: Item): boolean {
|
|
997
|
+
const pk = item['pk']
|
|
998
|
+
|
|
999
|
+
return pk !== undefined && 'S' in pk && isReserved(pk.S)
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function requestOptions(options: CallOptions | undefined): RequestOptions {
|
|
1003
|
+
return options?.signal ? {abortSignal: options.signal} : {}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// The reason codes of a cancelled transaction, one per action, or null for any other error.
|
|
1007
|
+
function cancellationCodes(error: unknown): string[] | null {
|
|
1008
|
+
if (!isNamed(error, 'TransactionCanceledException')) {
|
|
1009
|
+
return null
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const reasons = (error as {CancellationReasons?: unknown}).CancellationReasons
|
|
1013
|
+
if (!Array.isArray(reasons)) {
|
|
1014
|
+
return null
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return reasons.map((reason) => {
|
|
1018
|
+
const code = (reason as {Code?: unknown} | null)?.Code
|
|
1019
|
+
|
|
1020
|
+
return typeof code === 'string' ? code : ''
|
|
1021
|
+
})
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function isConditionalCheckFailed(error: unknown): boolean {
|
|
1025
|
+
return isNamed(error, 'ConditionalCheckFailedException')
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function isNamed(error: unknown, name: string): boolean {
|
|
1029
|
+
return typeof error === 'object' && error !== null && (error as {name?: unknown}).name === name
|
|
1030
|
+
}
|