lambder 7.3.1 → 8.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/CHANGELOG.md +1047 -3
- package/README.md +46 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/ContractTypePrinter.d.ts +85 -0
- package/dist/build/ContractTypePrinter.js +402 -0
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/moduleLocation.d.ts +11 -0
- package/dist/build/moduleLocation.js +6 -0
- package/dist/build/writeApiContract.d.ts +78 -0
- package/dist/build/writeApiContract.js +302 -0
- package/dist/build/writeApiSignatures.d.ts +114 -0
- package/dist/build/writeApiSignatures.js +217 -0
- package/dist/build/writeFileAtomically.d.ts +8 -0
- package/dist/build/writeFileAtomically.js +22 -0
- package/dist/build.d.ts +14 -0
- package/dist/build.js +11 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/LambderUploadRunner.d.ts +96 -0
- package/dist/client/LambderUploadRunner.js +234 -0
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +9 -3
- package/dist/client.js +6 -1
- package/dist/core/Lambder.d.ts +143 -79
- package/dist/core/Lambder.js +350 -231
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +21 -7
- package/dist/core/LambderFiles.js +62 -34
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +25 -8
- package/dist/index.js +13 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +43 -33
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
- package/dist/mock/lambderMockUploadMswHandler.js +28 -0
- package/dist/mock.d.ts +4 -1
- package/dist/mock.js +6 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
- package/dist/shared/contracts/LambderUploadBucket.js +74 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
- package/dist/shared/util/LambderContentDisposition.js +13 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTextDigest.d.ts +7 -5
- package/dist/shared/util/LambderTextDigest.js +11 -5
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +98 -53
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
- package/dist/shared/wire/LambderApiRefusal.js +42 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
- package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
- package/dist/shared/wire/LambderUploadRefusal.js +18 -0
- package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
- package/dist/shared/wire/LambderUploadSchemas.js +30 -0
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +80 -38
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
- package/dist/stores/LambderMemoryUploadBucket.js +219 -0
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
- package/dist/stores/LambderS3UploadBucket.js +144 -0
- package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
- package/dist/stores/LambderSdkInstallHint.js +14 -0
- package/dist/testing/LambderTestApp.d.ts +23 -25
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +3 -0
- package/dist/testing.js +2 -0
- package/package.json +26 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
import { createDynamoClientLoader, isConditionalCheckFailure } from "./LambderDdbSdk.js";
|
|
2
2
|
import { getCrypto } from "../shared/util/LambderNodeModules.js";
|
|
3
|
+
import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
|
|
3
4
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
4
5
|
import { compressText, restoreText, LambderCompressionError } from "../shared/wire/LambderCompressionCodec.js";
|
|
5
6
|
import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
|
|
6
7
|
import { LRUCache } from "lru-cache";
|
|
7
|
-
|
|
8
|
+
import { cacheMemoryKeyOf, decodeCacheSortKey, encodeCacheSortKey, normalizeCacheKey, normalizeCachePartition, } from "./LambderCacheKeys.js";
|
|
9
|
+
import { DEFAULT_MAX_VALUE_BYTES, DEFAULT_TTL_SECONDS, resolveCacheTtlSeconds, resolveGetOrSetOptions, serializeCacheValue, } from "./LambderCacheValues.js";
|
|
10
|
+
import { LambderCacheFiller } from "./LambderCacheFiller.js";
|
|
8
11
|
const DEFAULT_CHUNK_BYTES = 350 * 1024;
|
|
9
12
|
const MAX_SAFE_CHUNK_BYTES = 380 * 1024;
|
|
10
|
-
const DEFAULT_MAX_VALUE_BYTES = 32 * 1024 * 1024;
|
|
11
13
|
const DEFAULT_MEMORY_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* What one memory-layer entry costs beyond its bytes and its key: the entry
|
|
16
|
+
* object and the LRU's own bookkeeping for it. Counted so that many small
|
|
17
|
+
* values fill the budget at the rate they actually use memory.
|
|
18
|
+
*/
|
|
19
|
+
const MEMORY_ENTRY_OVERHEAD_BYTES = 256;
|
|
12
20
|
const META_SORT_KEY = "meta";
|
|
13
|
-
const LOCK_SORT_KEY = "lock";
|
|
14
21
|
const CHUNK_SORT_KEY_PREFIX = "chunk#";
|
|
15
22
|
/** Item-key prefix that separates entries addressed with a sort key from plain-key entries sharing the partition. */
|
|
16
23
|
const SORT_KEY_MARKER = "sk#";
|
|
17
|
-
/** Budget for one encoded sort key, leaving room for the marker and the longest item suffix inside DynamoDB's 1024-byte range key limit. */
|
|
18
|
-
const MAX_SORT_KEY_BYTES = 900;
|
|
19
24
|
const BATCH_WRITE_LIMIT = 25;
|
|
20
25
|
const MAX_BATCH_RETRIES = 8;
|
|
21
26
|
/** Every value compressed by default; see the `compression` option. */
|
|
22
27
|
const COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
28
|
+
/**
|
|
29
|
+
* How long this instance reads a key it wrote with consistent reads, in
|
|
30
|
+
* seconds (whole ones, so from four to five). DynamoDB's replicas apply a
|
|
31
|
+
* write well within that, and the margin costs little: only the keys this
|
|
32
|
+
* instance wrote are read at a consistent read's price.
|
|
33
|
+
*/
|
|
34
|
+
const RECENT_WRITE_SECONDS = 5;
|
|
35
|
+
/** Keys, and partitions, remembered as recently written at most; past it the ones closest to expiring go first (see LambderExpiringMap). */
|
|
36
|
+
const RECENT_WRITE_MAX_ENTRIES = 1_000;
|
|
23
37
|
/**
|
|
24
38
|
* A stored entry that cannot be trusted: chunks that do not add up to what
|
|
25
39
|
* the manifest describes, bytes that fail its checksum, or a restore the
|
|
@@ -29,9 +43,9 @@ const COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
|
29
43
|
* A type rather than "anything thrown while reading the entry", because the
|
|
30
44
|
* two are treated in opposite ways: a corrupt entry is dropped so the next
|
|
31
45
|
* reader refills it, while a throttled or failed Query is the table being
|
|
32
|
-
* busy. Deleting a healthy 2MB entry over one throttle
|
|
33
|
-
* until their TTL and
|
|
34
|
-
*
|
|
46
|
+
* busy. Deleting a healthy 2MB entry over one throttle would orphan its
|
|
47
|
+
* chunks until their TTL and send every later reader to the origin, the load
|
|
48
|
+
* the cache exists to absorb.
|
|
35
49
|
*/
|
|
36
50
|
class LambderCacheIntegrityError extends Error {
|
|
37
51
|
constructor(message) {
|
|
@@ -39,22 +53,17 @@ class LambderCacheIntegrityError extends Error {
|
|
|
39
53
|
this.name = "LambderCacheIntegrityError";
|
|
40
54
|
}
|
|
41
55
|
}
|
|
56
|
+
/** A read that proves the entry wrong, as opposed to the table answering badly: only such an entry is ever dropped. */
|
|
57
|
+
const isEntryFault = (error) => error instanceof LambderCacheIntegrityError || error instanceof LambderCompressionError;
|
|
42
58
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `<encoded>#` an unambiguous boundary for prefix queries. Escaping is
|
|
47
|
-
* per-character, so a prefix of the raw key stays a prefix of the encoded
|
|
48
|
-
* one; only the sort ORDER of keys that contain `#` or `~` shifts, since
|
|
49
|
-
* both encode into the `~` range.
|
|
59
|
+
* Whether a manifest item holds an entry: a value whose expiresAt has not
|
|
60
|
+
* passed. A fill's lease holds no value, and an expired value the table's TTL
|
|
61
|
+
* has not removed yet is a miss to every read, so neither is one.
|
|
50
62
|
*/
|
|
51
|
-
const
|
|
52
|
-
const decodeSortKey = (value) => value.replace(/~([01])/g, (_match, code) => code === "0" ? "~" : "#");
|
|
63
|
+
const holdsLiveValue = (item, nowSeconds) => item?.version?.S !== undefined && Number(item.expiresAt?.N) > nowSeconds;
|
|
53
64
|
// Node builtins are loaded lazily through LambderNodeModules so this module can
|
|
54
65
|
// sit in a frontend bundle's import graph (via the package root) without
|
|
55
|
-
// breaking; using the cache at runtime still requires Node.
|
|
56
|
-
// are shared with LambderDdbIdempotencyStore and LambderDdbSessionStore via
|
|
57
|
-
// ../shared/wire/LambderCompressionCodec.js.
|
|
66
|
+
// breaking; using the cache at runtime still requires Node.
|
|
58
67
|
const requireCrypto = async () => {
|
|
59
68
|
const crypto = await getCrypto();
|
|
60
69
|
if (!crypto)
|
|
@@ -70,17 +79,93 @@ const randomUUID = async () => {
|
|
|
70
79
|
return crypto.randomUUID();
|
|
71
80
|
};
|
|
72
81
|
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
82
|
+
/**
|
|
83
|
+
* What an instance's reads need to know about its own writes (set, delete,
|
|
84
|
+
* deletePartition, a fill's publish), so that its memory layer never keeps a
|
|
85
|
+
* value one of them replaced.
|
|
86
|
+
*
|
|
87
|
+
* The table does not answer in the order it applied writes, and an
|
|
88
|
+
* eventually consistent read answers from a replica that may not have
|
|
89
|
+
* applied the latest ones. So a value a read fetched can be one a write of
|
|
90
|
+
* this instance had already replaced, in two ways:
|
|
91
|
+
*
|
|
92
|
+
* - The read overlapped the write. `watch` counts, per key, the writes that
|
|
93
|
+
* finish while an operation on the key is in flight, and the operation
|
|
94
|
+
* keeps what it got only when none did. Only keys with an operation in
|
|
95
|
+
* flight are held, so a write of one key never costs another key its copy.
|
|
96
|
+
* - The read began after the write finished and reached a replica that had
|
|
97
|
+
* not applied it. For a few seconds after each write, `wroteRecently`
|
|
98
|
+
* answers true for the key (and for every key of a partition that
|
|
99
|
+
* deletePartition dropped), and the read goes to the leader instead.
|
|
100
|
+
*
|
|
101
|
+
* A write settles its key's memory copy as it finishes, keeping its own value
|
|
102
|
+
* or dropping the copy, so whatever an earlier read kept cannot outlive it.
|
|
103
|
+
*/
|
|
104
|
+
class LocalWriteLedger {
|
|
105
|
+
/** Per memory key with an operation in flight: how many there are, and how many writes of the key have finished since the first began. */
|
|
106
|
+
keysInFlight = new Map();
|
|
107
|
+
recentKeys;
|
|
108
|
+
recentPartitions;
|
|
109
|
+
now;
|
|
110
|
+
constructor(now) {
|
|
111
|
+
this.now = now;
|
|
112
|
+
this.recentKeys = new LambderExpiringMap({ now, maxEntries: RECENT_WRITE_MAX_ENTRIES });
|
|
113
|
+
this.recentPartitions = new LambderExpiringMap({ now, maxEntries: RECENT_WRITE_MAX_ENTRIES });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Runs `operation` on one key, handing it `overtaken`, which answers
|
|
117
|
+
* whether a write of the key (a deletePartition of its partition
|
|
118
|
+
* included) has finished since the operation began.
|
|
119
|
+
*/
|
|
120
|
+
async watch(memoryKey, operation) {
|
|
121
|
+
const activity = this.keysInFlight.get(memoryKey) ?? { operations: 0, finishedWrites: 0 };
|
|
122
|
+
this.keysInFlight.set(memoryKey, activity);
|
|
123
|
+
activity.operations += 1;
|
|
124
|
+
const startedAt = activity.finishedWrites;
|
|
125
|
+
try {
|
|
126
|
+
return await operation(() => activity.finishedWrites !== startedAt);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
activity.operations -= 1;
|
|
130
|
+
if (activity.operations === 0)
|
|
131
|
+
this.keysInFlight.delete(memoryKey);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** A write of one key has finished, landed or failed: the table may hold it either way. */
|
|
135
|
+
recordKeyWrite(memoryKey) {
|
|
136
|
+
const activity = this.keysInFlight.get(memoryKey);
|
|
137
|
+
if (activity)
|
|
138
|
+
activity.finishedWrites += 1;
|
|
139
|
+
this.recentKeys.set(memoryKey, true, this.recentUntil());
|
|
140
|
+
}
|
|
141
|
+
/** A deletePartition has finished, landed or failed: a write of every key in the partition. */
|
|
142
|
+
recordPartitionWrite(partition) {
|
|
143
|
+
const prefix = cacheMemoryKeyOf(partition, "");
|
|
144
|
+
for (const [memoryKey, activity] of this.keysInFlight) {
|
|
145
|
+
if (memoryKey.startsWith(prefix))
|
|
146
|
+
activity.finishedWrites += 1;
|
|
147
|
+
}
|
|
148
|
+
this.recentPartitions.set(partition, true, this.recentUntil());
|
|
149
|
+
}
|
|
150
|
+
/** Whether this instance wrote the entry a moment ago, recently enough that a replica may not have applied the write yet. */
|
|
151
|
+
wroteRecently(address) {
|
|
152
|
+
return this.recentKeys.get(address.memoryKey) !== undefined || this.recentPartitions.get(address.partition) !== undefined;
|
|
153
|
+
}
|
|
154
|
+
recentUntil() {
|
|
155
|
+
return Math.floor(this.now() / 1000) + RECENT_WRITE_SECONDS;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
73
158
|
/**
|
|
74
159
|
* Persistent JSON cache backed by DynamoDB.
|
|
75
160
|
*
|
|
76
161
|
* Values are Brotli-compressed by default (`compression` option; the manifest
|
|
77
162
|
* records each value's encoding, so the option can be switched on a live
|
|
78
163
|
* table). Values within the safe DynamoDB item budget are stored directly in
|
|
79
|
-
* the manifest for a single-request read; larger values are
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
164
|
+
* the manifest for a single-request read; larger values are split into
|
|
165
|
+
* versioned binary chunks. A manifest is written only after every chunk
|
|
166
|
+
* succeeds, so readers see either the previous complete version or the new
|
|
167
|
+
* one. DynamoDB TTL is cleanup only; every read also checks expiresAt because
|
|
168
|
+
* TTL deletion can lag.
|
|
84
169
|
*
|
|
85
170
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
86
171
|
* `expiresAt`. Items are prefixed `CACHE#<namespace>#` by default, so the
|
|
@@ -89,10 +174,22 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
|
|
89
174
|
*
|
|
90
175
|
* A key may also be a `{ pk, sk }` pair, which groups entries under one
|
|
91
176
|
* partition so `deletePartition` and `listSortKeys` can work on the group
|
|
92
|
-
* without knowing its members.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
177
|
+
* without knowing its members. Only the `pk` part is hashed into the
|
|
178
|
+
* DynamoDB partition key; the sort key is stored readable, which makes
|
|
179
|
+
* prefix queries possible. Plain-string keys use the bare item keys (`meta`,
|
|
180
|
+
* `chunk#...`) and grouped entries live beside them under
|
|
181
|
+
* `sk#<encoded sort key>#...`, so both forms can share a partition.
|
|
182
|
+
*
|
|
183
|
+
* getOrSet's fill lease lives on the entry's manifest item (see takeLease),
|
|
184
|
+
* and a fill publishes only while its lease is still there. `set` replaces
|
|
185
|
+
* the manifest, and `delete` and `deletePartition` remove it, lease and all,
|
|
186
|
+
* so a fill that started before any of them never stores over it. Both find
|
|
187
|
+
* what to remove on the leader (`delete` by the manifest's own key,
|
|
188
|
+
* `deletePartition` with a consistent Query), since a replica may not have
|
|
189
|
+
* seen a value or a lease the table accepted a moment before.
|
|
190
|
+
*
|
|
191
|
+
* Implements LambderCache, so code typed against the interface runs over
|
|
192
|
+
* LambderMemoryCache in a test.
|
|
96
193
|
*/
|
|
97
194
|
export class LambderDdbCache {
|
|
98
195
|
tableName;
|
|
@@ -105,8 +202,11 @@ export class LambderDdbCache {
|
|
|
105
202
|
compression;
|
|
106
203
|
maxValueBytes;
|
|
107
204
|
memory;
|
|
108
|
-
|
|
205
|
+
/** getOrSet's single-flight and fail-open, shared with LambderMemoryCache (see LambderCacheFiller). */
|
|
206
|
+
filler;
|
|
109
207
|
now;
|
|
208
|
+
/** This instance's own writes, as its reads and its memory layer need to know them (see LocalWriteLedger). */
|
|
209
|
+
localWrites;
|
|
110
210
|
constructor(options) {
|
|
111
211
|
if (!options.tableName.trim())
|
|
112
212
|
throw new Error("tableName is required");
|
|
@@ -128,13 +228,16 @@ export class LambderDdbCache {
|
|
|
128
228
|
? null
|
|
129
229
|
: new LRUCache({
|
|
130
230
|
maxSize: assertPositiveInteger(memoryMaxBytes, "memoryMaxBytes"),
|
|
131
|
-
|
|
231
|
+
// The key is a JS string, two bytes a character.
|
|
232
|
+
sizeCalculation: (entry, key) => entry.stored.byteLength + key.length * 2 + MEMORY_ENTRY_OVERHEAD_BYTES,
|
|
132
233
|
});
|
|
133
234
|
this.now = options.now ?? (() => Date.now());
|
|
235
|
+
this.localWrites = new LocalWriteLedger(this.now);
|
|
134
236
|
this.ready = createDynamoClientLoader({ user: "LambderDdbCache", region: options.region, client: options.client });
|
|
237
|
+
this.filler = new LambderCacheFiller(`DynamoDB cache failed open in ${this.namespace}`);
|
|
135
238
|
}
|
|
136
239
|
async get(key) {
|
|
137
|
-
return await this.getByAddress(
|
|
240
|
+
return await this.getByAddress(normalizeCacheKey(key));
|
|
138
241
|
}
|
|
139
242
|
async getByAddress(address) {
|
|
140
243
|
const cached = this.memory?.get(address.memoryKey);
|
|
@@ -149,58 +252,112 @@ export class LambderDdbCache {
|
|
|
149
252
|
}
|
|
150
253
|
if (cached)
|
|
151
254
|
this.memory?.delete(address.memoryKey);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
255
|
+
return await this.localWrites.watch(address.memoryKey, async (overtaken) => {
|
|
256
|
+
const pk = await this.partitionKey(address.partition);
|
|
257
|
+
// A key this instance wrote a moment ago is read from the leader:
|
|
258
|
+
// a replica may not have applied that write yet, and the memory
|
|
259
|
+
// layer would keep what it had instead (see LocalWriteLedger).
|
|
260
|
+
const consistent = this.localWrites.wroteRecently(address);
|
|
261
|
+
const manifest = await this.readManifest(pk, address, consistent);
|
|
262
|
+
if (!manifest || manifest.expiresAt <= nowSeconds)
|
|
263
|
+
return undefined;
|
|
264
|
+
try {
|
|
265
|
+
return await this.readEntry(pk, address, manifest, consistent, overtaken);
|
|
160
266
|
}
|
|
161
|
-
|
|
162
|
-
|
|
267
|
+
catch (error) {
|
|
268
|
+
// Only an entry this read can prove wrong is dropped. Anything
|
|
269
|
+
// else is the table answering badly and propagates, like the
|
|
270
|
+
// manifest read's errors, for getOrSet's fail-open to handle as
|
|
271
|
+
// the infrastructure failure it is.
|
|
272
|
+
if (!isEntryFault(error))
|
|
273
|
+
throw error;
|
|
163
274
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
275
|
+
// One read is not proof yet: a replica can hold a manifest before
|
|
276
|
+
// its chunks, and a newer write can replace the version and delete
|
|
277
|
+
// its chunks while they are read. One consistent read of both
|
|
278
|
+
// decides, and only what fails that is corrupt.
|
|
279
|
+
const current = await this.readManifest(pk, address, true);
|
|
280
|
+
if (!current || current.expiresAt <= this.nowSeconds())
|
|
281
|
+
return undefined;
|
|
282
|
+
return await this.readLeaderEntry(pk, address, current, overtaken);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The value a manifest the leader answered describes, its chunks read
|
|
287
|
+
* consistently, or undefined when it fails that read: then it is corrupt,
|
|
288
|
+
* and its manifest is dropped. `overtaken` comes from the read's watch
|
|
289
|
+
* (see LocalWriteLedger).
|
|
290
|
+
*/
|
|
291
|
+
async readLeaderEntry(pk, address, manifest, overtaken) {
|
|
292
|
+
try {
|
|
293
|
+
return await this.readEntry(pk, address, manifest, true, overtaken);
|
|
168
294
|
}
|
|
169
295
|
catch (error) {
|
|
170
|
-
|
|
171
|
-
// else is the table answering badly, which is the manifest read's
|
|
172
|
-
// own behaviour one line up: it propagates, and getOrSet's
|
|
173
|
-
// fail-open handles it as the infrastructure failure it is.
|
|
174
|
-
if (!(error instanceof LambderCacheIntegrityError || error instanceof LambderCompressionError))
|
|
296
|
+
if (!isEntryFault(error))
|
|
175
297
|
throw error;
|
|
176
298
|
await this.invalidateManifest(pk, address, manifest.version);
|
|
177
299
|
console.warn(`Ignoring corrupt DynamoDB cache entry in ${this.namespace}`, error);
|
|
178
300
|
return undefined;
|
|
179
301
|
}
|
|
180
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* The value a manifest describes, checked against it and kept in the
|
|
305
|
+
* memory layer unless a write of the key finished while it was read
|
|
306
|
+
* (`overtaken`, see LocalWriteLedger).
|
|
307
|
+
*/
|
|
308
|
+
async readEntry(pk, address, manifest, consistent, overtaken) {
|
|
309
|
+
const stored = manifest.inlineData ?? await this.readChunks(pk, address, manifest, consistent);
|
|
310
|
+
if (stored.length !== manifest.storedBytes) {
|
|
311
|
+
throw new LambderCacheIntegrityError("stored byte length does not match manifest");
|
|
312
|
+
}
|
|
313
|
+
if (await sha256(stored) !== manifest.checksum) {
|
|
314
|
+
throw new LambderCacheIntegrityError("stored checksum does not match manifest");
|
|
315
|
+
}
|
|
316
|
+
const json = await this.decode(stored, manifest.encoding, manifest.uncompressedBytes);
|
|
317
|
+
const parsed = JSON.parse(json);
|
|
318
|
+
if (!overtaken()) {
|
|
319
|
+
this.remember(address.memoryKey, stored, manifest.encoding, manifest.uncompressedBytes, manifest.expiresAt);
|
|
320
|
+
}
|
|
321
|
+
return parsed;
|
|
322
|
+
}
|
|
181
323
|
async has(key) {
|
|
182
|
-
const address =
|
|
324
|
+
const address = normalizeCacheKey(key);
|
|
183
325
|
const cached = this.memory?.get(address.memoryKey);
|
|
184
326
|
const nowSeconds = this.nowSeconds();
|
|
185
327
|
if (cached?.expiresAt && cached.expiresAt > nowSeconds)
|
|
186
328
|
return true;
|
|
187
329
|
if (cached)
|
|
188
330
|
this.memory?.delete(address.memoryKey);
|
|
189
|
-
|
|
331
|
+
// From the leader for a key this instance wrote a moment ago, as getByAddress reads it.
|
|
332
|
+
const manifest = await this.readManifest(await this.partitionKey(address.partition), address, this.localWrites.wroteRecently(address));
|
|
190
333
|
return !!manifest && manifest.expiresAt > nowSeconds;
|
|
191
334
|
}
|
|
192
335
|
async set(key, value, options = {}) {
|
|
193
|
-
|
|
336
|
+
const address = normalizeCacheKey(key);
|
|
337
|
+
const ttlSeconds = resolveCacheTtlSeconds(options.ttlSeconds, this.defaultTtlSeconds);
|
|
338
|
+
this.filler.supersedeFill(address.memoryKey);
|
|
339
|
+
await this.setByAddress(address, value, ttlSeconds);
|
|
194
340
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Stores the value and hands back what was stored, the parse of its
|
|
343
|
+
* JSON: what every later read answers too.
|
|
344
|
+
*
|
|
345
|
+
* With `leaseOwner` it is a fill publishing under its lease, and the
|
|
346
|
+
* manifest is written only while that lease is still on it. A set,
|
|
347
|
+
* delete or deletePartition since the lease was taken replaced or
|
|
348
|
+
* removed it, and so did a waiter that took it over once it lapsed; the
|
|
349
|
+
* loader may have read its source before any of those, so its value must
|
|
350
|
+
* not land over them. A refused fill deletes the chunks it wrote, which
|
|
351
|
+
* belong to no manifest, and hands its value back uncached. A write
|
|
352
|
+
* winning is the design and is not logged; a takeover is logged, since it
|
|
353
|
+
* means the loader ran past `leaseSeconds`, and while every fill does,
|
|
354
|
+
* each is refused by the next takeover and the entry never fills. The
|
|
355
|
+
* cure is a lease longer than the loader's worst case, which only the
|
|
356
|
+
* caller can give.
|
|
357
|
+
*/
|
|
358
|
+
async setByAddress(address, value, ttlSeconds, leaseOwner) {
|
|
359
|
+
const { json, utf8 } = serializeCacheValue(value, this.maxValueBytes);
|
|
360
|
+
const input = Buffer.from(utf8.buffer, utf8.byteOffset, utf8.byteLength);
|
|
204
361
|
const brotli = this.compression && input.length >= this.compression.minBytes ? this.compression : null;
|
|
205
362
|
const encoding = brotli ? "br" : "identity";
|
|
206
363
|
const stored = brotli ? await compressText(input, "br", brotli.quality) : input;
|
|
@@ -227,188 +384,352 @@ export class LambderDdbCache {
|
|
|
227
384
|
},
|
|
228
385
|
},
|
|
229
386
|
}));
|
|
230
|
-
await this.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
387
|
+
const publish = await this.localWrites.watch(address.memoryKey, async (overtaken) => {
|
|
388
|
+
let kept = false;
|
|
389
|
+
try {
|
|
390
|
+
await this.batchWrite(writes);
|
|
391
|
+
const { client, sdk } = await this.ready();
|
|
392
|
+
let replaced;
|
|
393
|
+
let refused = false;
|
|
394
|
+
try {
|
|
395
|
+
const response = await client.send(new sdk.PutItemCommand({
|
|
396
|
+
TableName: this.tableName,
|
|
397
|
+
ReturnValues: "ALL_OLD",
|
|
398
|
+
Item: {
|
|
399
|
+
pk: { S: pk },
|
|
400
|
+
sk: { S: this.itemSortKey(address, META_SORT_KEY) },
|
|
401
|
+
version: { S: version },
|
|
402
|
+
chunkCount: { N: String(chunks.length) },
|
|
403
|
+
storedBytes: { N: String(stored.length) },
|
|
404
|
+
uncompressedBytes: { N: String(input.length) },
|
|
405
|
+
checksum: { S: await sha256(stored) },
|
|
406
|
+
encoding: { S: encoding },
|
|
407
|
+
createdAt: { N: String(this.nowSeconds()) },
|
|
408
|
+
expiresAt: { N: String(expiresAt) },
|
|
409
|
+
...(inline ? { data: { B: stored } } : {}),
|
|
410
|
+
},
|
|
411
|
+
...(leaseOwner === undefined ? {} : {
|
|
412
|
+
ConditionExpression: "#leaseOwner = :leaseOwner",
|
|
413
|
+
ExpressionAttributeNames: { "#leaseOwner": "leaseOwner" },
|
|
414
|
+
ExpressionAttributeValues: { ":leaseOwner": { S: leaseOwner } },
|
|
415
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
416
|
+
}),
|
|
417
|
+
}));
|
|
418
|
+
replaced = response.Attributes;
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
if (leaseOwner === undefined || !isConditionalCheckFailure(error))
|
|
422
|
+
throw error;
|
|
423
|
+
const current = error.Item;
|
|
424
|
+
// Refused over this very manifest when the SDK retried an
|
|
425
|
+
// attempt that had already landed: published, not refused.
|
|
426
|
+
refused = current?.version?.S !== version;
|
|
427
|
+
if (refused && current?.leaseOwner?.S !== undefined) {
|
|
428
|
+
console.warn(`LambderDdbCache: a fill in ${this.namespace} ran past its lease and another container took the lease over, so its value was not stored; give getOrSet a leaseSeconds longer than its loader takes.`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Kept only when no other write of this key finished while
|
|
432
|
+
// this one was in flight (see LocalWriteLedger).
|
|
433
|
+
if (!refused && !overtaken()) {
|
|
434
|
+
this.remember(address.memoryKey, stored, encoding, input.length, expiresAt);
|
|
435
|
+
kept = true;
|
|
436
|
+
}
|
|
437
|
+
return { refused, replaced };
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
// Settled as it lands, whatever happened: its own value in
|
|
441
|
+
// memory, or no copy of the key at all.
|
|
442
|
+
if (!kept)
|
|
443
|
+
this.memory?.delete(address.memoryKey);
|
|
444
|
+
this.localWrites.recordKeyWrite(address.memoryKey);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
if (publish.refused) {
|
|
448
|
+
await this.deleteChunks(pk, address, version, chunks.length, "a refused fill's");
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
await this.deleteReplacedChunks(pk, address, publish.replaced, version);
|
|
452
|
+
}
|
|
453
|
+
return JSON.parse(json);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The chunks of the version a manifest write just replaced, which nothing
|
|
457
|
+
* reads any more and which would otherwise sit in the table until their
|
|
458
|
+
* TTL: a large value refreshed hourly leaves a copy an hour. `ownVersion`
|
|
459
|
+
* is the writer's own, which an SDK retry of a write that had already
|
|
460
|
+
* landed reports as the replaced one.
|
|
461
|
+
*/
|
|
462
|
+
async deleteReplacedChunks(pk, address, previous, ownVersion) {
|
|
463
|
+
const previousVersion = previous?.version?.S;
|
|
464
|
+
const chunkCount = Number(previous?.chunkCount?.N);
|
|
465
|
+
if (!previousVersion || previousVersion === ownVersion || !Number.isSafeInteger(chunkCount) || chunkCount <= 0)
|
|
466
|
+
return;
|
|
467
|
+
const count = Math.min(chunkCount, Math.ceil(this.maxValueBytes / this.chunkBytes));
|
|
468
|
+
await this.deleteChunks(pk, address, previousVersion, count, "a replaced value's");
|
|
249
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* One version's chunk items, deleted. The write they belonged to is
|
|
472
|
+
* settled whether or not this succeeds, and the chunks carry their own
|
|
473
|
+
* TTL, so a failure here is logged, not the caller's.
|
|
474
|
+
*/
|
|
475
|
+
async deleteChunks(pk, address, version, count, whose) {
|
|
476
|
+
try {
|
|
477
|
+
await this.batchWrite(Array.from({ length: count }, (_, index) => ({
|
|
478
|
+
DeleteRequest: { Key: { pk: { S: pk }, sk: { S: this.chunkSortKey(address, version, index) } } },
|
|
479
|
+
})));
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
console.warn(`Failed to delete ${whose} chunks from the DynamoDB cache in ${this.namespace}`, error);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Removes the entry's manifest item, whatever it holds (a value, or a
|
|
487
|
+
* fill's lease, whose publish is then refused), and its chunks. True when
|
|
488
|
+
* the manifest held a live value: a lease or an expired value the table's
|
|
489
|
+
* TTL has not removed yet is no entry.
|
|
490
|
+
*/
|
|
250
491
|
async delete(key) {
|
|
251
|
-
const address =
|
|
252
|
-
|
|
492
|
+
const address = normalizeCacheKey(key);
|
|
493
|
+
this.filler.supersedeFill(address.memoryKey);
|
|
253
494
|
this.memory?.delete(address.memoryKey);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
495
|
+
try {
|
|
496
|
+
const pk = await this.partitionKey(address.partition);
|
|
497
|
+
const { client, sdk } = await this.ready();
|
|
498
|
+
// By its key, so the leader removes what it holds now: a value or
|
|
499
|
+
// a lease another container wrote a moment ago, which a Query
|
|
500
|
+
// could miss on a replica that has not seen it yet.
|
|
501
|
+
const { Attributes: removed } = await client.send(new sdk.DeleteItemCommand({
|
|
502
|
+
TableName: this.tableName,
|
|
503
|
+
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
504
|
+
ReturnValues: "ALL_OLD",
|
|
505
|
+
}));
|
|
506
|
+
// Then the chunks of any version (the value's, and any a failed
|
|
507
|
+
// write left behind), read consistently for the same reason. The
|
|
508
|
+
// prefix is this entry's own chunk items, so a plain key's delete
|
|
509
|
+
// leaves the grouped entries sharing its partition alone.
|
|
510
|
+
await this.deleteItems(await this.queryItems(pk, {
|
|
511
|
+
prefix: this.itemSortKey(address, CHUNK_SORT_KEY_PREFIX),
|
|
512
|
+
projection: "#pk, #sk",
|
|
513
|
+
consistent: true,
|
|
514
|
+
}));
|
|
515
|
+
return holdsLiveValue(removed, this.nowSeconds());
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
// Again once the items are gone: a read in flight meanwhile may
|
|
519
|
+
// have kept what it found before they were (see LocalWriteLedger).
|
|
520
|
+
this.memory?.delete(address.memoryKey);
|
|
521
|
+
this.localWrites.recordKeyWrite(address.memoryKey);
|
|
522
|
+
}
|
|
262
523
|
}
|
|
263
524
|
/**
|
|
264
525
|
* Drop every entry stored under one `pk`, without knowing which sort keys
|
|
265
526
|
* exist: the invalidation a group of related entries is worth grouping
|
|
266
|
-
* for. Returns the number of
|
|
267
|
-
* OTHER Lambda containers still serve until their own TTL, as they do
|
|
527
|
+
* for. Returns the number of live values removed. In-memory copies held
|
|
528
|
+
* by OTHER Lambda containers still serve until their own TTL, as they do
|
|
268
529
|
* after a single-entry delete.
|
|
269
530
|
*/
|
|
270
531
|
async deletePartition(partition) {
|
|
271
|
-
const normalized =
|
|
272
|
-
|
|
532
|
+
const normalized = normalizeCachePartition(partition);
|
|
533
|
+
this.filler.supersedeFillsWithPrefix(cacheMemoryKeyOf(normalized, ""));
|
|
273
534
|
this.forgetPartition(normalized);
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
535
|
+
try {
|
|
536
|
+
const pk = await this.partitionKey(normalized);
|
|
537
|
+
// Read consistently: a replica may not have seen a value or a
|
|
538
|
+
// fill's lease the table accepted a moment ago, and whatever this
|
|
539
|
+
// misses outlives the call.
|
|
540
|
+
const items = await this.queryItems(pk, {
|
|
541
|
+
projection: "#pk, #sk, #version, #expiresAt",
|
|
542
|
+
extraNames: { "#version": "version", "#expiresAt": "expiresAt" },
|
|
543
|
+
consistent: true,
|
|
544
|
+
});
|
|
545
|
+
await this.deleteItems(items);
|
|
546
|
+
const nowSeconds = this.nowSeconds();
|
|
547
|
+
return items.filter((item) => this.isManifestSortKey(item.sk?.S) && holdsLiveValue(item, nowSeconds)).length;
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
// Again once the items are gone, as in delete.
|
|
551
|
+
this.forgetPartition(normalized);
|
|
552
|
+
this.localWrites.recordPartitionWrite(normalized);
|
|
553
|
+
}
|
|
277
554
|
}
|
|
278
555
|
/**
|
|
279
556
|
* The live (unexpired) sort keys stored under one `pk`, in table order.
|
|
280
557
|
* Plain-string entries have no sort key, so they never appear here.
|
|
281
|
-
*
|
|
282
|
-
*
|
|
558
|
+
* The partition (or prefix range) is read whatever the limit, and reading
|
|
559
|
+
* a partition whose values are chunked also reads those chunk items, so
|
|
560
|
+
* grouping very large values makes listing more expensive.
|
|
283
561
|
*/
|
|
284
562
|
async listSortKeys(partition, options = {}) {
|
|
285
|
-
const pk = await this.partitionKey(
|
|
286
|
-
const prefix = `${SORT_KEY_MARKER}${
|
|
563
|
+
const pk = await this.partitionKey(normalizeCachePartition(partition));
|
|
564
|
+
const prefix = `${SORT_KEY_MARKER}${encodeCacheSortKey(options.prefix ?? "")}`;
|
|
287
565
|
const limit = options.limit === undefined ? undefined : assertPositiveInteger(options.limit, "limit");
|
|
288
566
|
const nowSeconds = this.nowSeconds();
|
|
289
|
-
const items = await this.queryItems(pk, {
|
|
567
|
+
const items = await this.queryItems(pk, {
|
|
568
|
+
prefix,
|
|
569
|
+
projection: "#sk, #expiresAt, #version",
|
|
570
|
+
extraNames: { "#expiresAt": "expiresAt", "#version": "version" },
|
|
571
|
+
});
|
|
290
572
|
const sortKeys = [];
|
|
291
573
|
for (const item of items) {
|
|
292
574
|
const sk = item.sk?.S;
|
|
293
575
|
if (!sk || !this.isManifestSortKey(sk))
|
|
294
576
|
continue;
|
|
295
|
-
|
|
577
|
+
// A manifest item holding only a fill's lease, or an expired value, has nothing to list.
|
|
578
|
+
if (!holdsLiveValue(item, nowSeconds))
|
|
296
579
|
continue;
|
|
297
|
-
sortKeys.push(
|
|
580
|
+
sortKeys.push(decodeCacheSortKey(sk.slice(SORT_KEY_MARKER.length, -(META_SORT_KEY.length + 1))));
|
|
298
581
|
if (limit !== undefined && sortKeys.length >= limit)
|
|
299
582
|
break;
|
|
300
583
|
}
|
|
301
584
|
return sortKeys;
|
|
302
585
|
}
|
|
303
|
-
async getOrSet(key,
|
|
304
|
-
const address =
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
this.inFlight.delete(address.memoryKey);
|
|
310
|
-
});
|
|
311
|
-
this.inFlight.set(address.memoryKey, fill);
|
|
312
|
-
return fill;
|
|
313
|
-
}
|
|
314
|
-
/**
|
|
315
|
-
* Cache infrastructure is best-effort for getOrSet: read, lease, or write
|
|
316
|
-
* failures return the loader value. Loader failures still propagate and the
|
|
317
|
-
* loader is never repeated after it has completed successfully.
|
|
318
|
-
*/
|
|
319
|
-
async getOrSetFailOpen(address, factory, options) {
|
|
320
|
-
let factoryStarted = false;
|
|
321
|
-
let factoryCompleted = false;
|
|
322
|
-
let factoryValue;
|
|
323
|
-
const trackedFactory = async () => {
|
|
324
|
-
factoryStarted = true;
|
|
325
|
-
factoryValue = await factory();
|
|
326
|
-
factoryCompleted = true;
|
|
327
|
-
return factoryValue;
|
|
328
|
-
};
|
|
329
|
-
try {
|
|
586
|
+
async getOrSet(key, loader, options = {}) {
|
|
587
|
+
const address = normalizeCacheKey(key);
|
|
588
|
+
const settings = resolveGetOrSetOptions(options, this.defaultTtlSeconds);
|
|
589
|
+
// Cache infrastructure is best-effort here: a read, lease or write
|
|
590
|
+
// failure returns the loader's value (see LambderCacheFiller).
|
|
591
|
+
return this.filler.getOrSet(address.memoryKey, loader, async (load) => {
|
|
330
592
|
const existing = await this.getByAddress(address);
|
|
331
593
|
if (existing !== undefined)
|
|
332
594
|
return existing;
|
|
333
|
-
return await this.fill(address,
|
|
334
|
-
}
|
|
335
|
-
catch (error) {
|
|
336
|
-
if (factoryStarted && !factoryCompleted)
|
|
337
|
-
throw error;
|
|
338
|
-
console.error(`DynamoDB cache failed open in ${this.namespace} for ${address.memoryKey}`, error);
|
|
339
|
-
if (factoryCompleted)
|
|
340
|
-
return factoryValue;
|
|
341
|
-
return trackedFactory();
|
|
342
|
-
}
|
|
595
|
+
return await this.fill(address, load, settings);
|
|
596
|
+
});
|
|
343
597
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const waitForFillMs = assertPositiveInteger(options.waitForFillMs ?? 5_000, "waitForFillMs");
|
|
598
|
+
/** The fill once a read found nothing: one container loads under the lease while the others wait for its value. */
|
|
599
|
+
async fill(address, load, { ttlSeconds, leaseSeconds, waitForFillMs }) {
|
|
347
600
|
const pk = await this.partitionKey(address.partition);
|
|
348
601
|
const owner = await randomUUID();
|
|
349
|
-
|
|
602
|
+
// The load under the lease just taken, published only while the lease
|
|
603
|
+
// is still this fill's (see setByAddress). A publish that answered,
|
|
604
|
+
// landed or refused, leaves no lease of this fill's on the item; any
|
|
605
|
+
// other end (the loader threw or answered undefined, a write
|
|
606
|
+
// superseded the fill, the publish failed) releases it.
|
|
607
|
+
const fillUnderLease = async () => {
|
|
608
|
+
let published = false;
|
|
350
609
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
610
|
+
return await load(async (value) => {
|
|
611
|
+
const stored = await this.setByAddress(address, value, ttlSeconds, owner);
|
|
612
|
+
published = true;
|
|
613
|
+
return stored;
|
|
614
|
+
});
|
|
354
615
|
}
|
|
355
616
|
finally {
|
|
356
|
-
|
|
617
|
+
if (!published)
|
|
618
|
+
await this.releaseLease(pk, address, owner);
|
|
357
619
|
}
|
|
358
|
-
}
|
|
620
|
+
};
|
|
359
621
|
const deadline = Date.now() + waitForFillMs;
|
|
360
622
|
let delay = 50;
|
|
361
|
-
|
|
623
|
+
for (;;) {
|
|
624
|
+
const attempt = await this.takeLease(pk, address, owner, leaseSeconds);
|
|
625
|
+
if (attempt === "taken")
|
|
626
|
+
return await fillUnderLease();
|
|
627
|
+
if (attempt !== "held") {
|
|
628
|
+
// Refused over a live value: the read that found the entry
|
|
629
|
+
// missing reached a replica that had not seen it yet. The
|
|
630
|
+
// refusal carries the manifest as the leader holds it, so it
|
|
631
|
+
// serves the value, its chunks read consistently when it has
|
|
632
|
+
// any, rather than waiting out the lag.
|
|
633
|
+
const value = await this.localWrites.watch(address.memoryKey, (overtaken) => this.readLeaderEntry(pk, address, attempt.filled, overtaken));
|
|
634
|
+
if (value !== undefined)
|
|
635
|
+
return value;
|
|
636
|
+
}
|
|
637
|
+
if (Date.now() >= deadline)
|
|
638
|
+
break;
|
|
362
639
|
await sleep(delay + Math.floor(Math.random() * 25));
|
|
363
640
|
const value = await this.getByAddress(address);
|
|
364
641
|
if (value !== undefined)
|
|
365
642
|
return value;
|
|
366
|
-
if (await this.acquireLease(pk, address, owner, leaseSeconds)) {
|
|
367
|
-
try {
|
|
368
|
-
const loaded = await factory();
|
|
369
|
-
await this.setByAddress(address, loaded, { ttlSeconds: options.ttlSeconds });
|
|
370
|
-
return loaded;
|
|
371
|
-
}
|
|
372
|
-
finally {
|
|
373
|
-
await this.releaseLease(pk, address, owner);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
643
|
delay = Math.min(delay * 2, 500);
|
|
377
644
|
}
|
|
378
645
|
throw new Error(`Timed out waiting for DynamoDB cache fill in ${this.namespace}`);
|
|
379
646
|
}
|
|
380
|
-
|
|
647
|
+
/**
|
|
648
|
+
* Takes the fill lease on one entry by writing it onto the entry's
|
|
649
|
+
* manifest item, a manifest with no value that the fill's publish then
|
|
650
|
+
* replaces. Readers find no value on it, so to them it is a miss.
|
|
651
|
+
*
|
|
652
|
+
* The write is conditional on the item holding nothing live: none at
|
|
653
|
+
* all, or one whose expiresAt has passed, whether an expired value or a
|
|
654
|
+
* lapsed lease. So a lease never hides a live value, and a waiter taking
|
|
655
|
+
* over a lapsed lease replaces the first holder's, whose publish is then
|
|
656
|
+
* refused. An expired value the lease replaces goes at once, its chunks
|
|
657
|
+
* with it, and readers see a miss until the fill publishes, as they did
|
|
658
|
+
* from the moment it expired. DynamoDB's TTL removes a lease its holder
|
|
659
|
+
* abandoned.
|
|
660
|
+
*
|
|
661
|
+
* A refusal hands back the item that refused it: "held" for another
|
|
662
|
+
* fill's lease, and for a live value its manifest, which the caller
|
|
663
|
+
* serves the value from. A manifest this instance cannot read (written
|
|
664
|
+
* under another chunkBytes or maxValueBytes) is a miss to every read here
|
|
665
|
+
* and would otherwise block fills until its TTL, so the lease is taken
|
|
666
|
+
* over it, conditioned on it being the same manifest still.
|
|
667
|
+
*/
|
|
668
|
+
async takeLease(pk, address, owner, leaseSeconds, unreadableVersion) {
|
|
381
669
|
const now = this.nowSeconds();
|
|
670
|
+
let current;
|
|
382
671
|
try {
|
|
383
672
|
const { client, sdk } = await this.ready();
|
|
384
|
-
await client.send(new sdk.PutItemCommand({
|
|
673
|
+
const response = await client.send(new sdk.PutItemCommand({
|
|
385
674
|
TableName: this.tableName,
|
|
386
675
|
Item: {
|
|
387
676
|
pk: { S: pk },
|
|
388
|
-
sk: { S: this.itemSortKey(address,
|
|
389
|
-
|
|
390
|
-
|
|
677
|
+
sk: { S: this.itemSortKey(address, META_SORT_KEY) },
|
|
678
|
+
leaseOwner: { S: owner },
|
|
679
|
+
// The first second the lease no longer holds, as a
|
|
680
|
+
// value's expiresAt is the first second it no longer
|
|
681
|
+
// reads. Counted from the whole second the lease was
|
|
682
|
+
// taken in, so one taken late in a second still lasts
|
|
683
|
+
// its full length.
|
|
684
|
+
expiresAt: { N: String(now + leaseSeconds + 1) },
|
|
391
685
|
},
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
686
|
+
...(unreadableVersion === undefined ? {
|
|
687
|
+
// An item without expiresAt is none a cache wrote; a
|
|
688
|
+
// comparison with a missing attribute is false, so it
|
|
689
|
+
// is allowed by name rather than refused for ever.
|
|
690
|
+
ConditionExpression: "attribute_not_exists(#expiresAt) OR #expiresAt <= :now",
|
|
691
|
+
ExpressionAttributeNames: { "#expiresAt": "expiresAt" },
|
|
692
|
+
ExpressionAttributeValues: { ":now": { N: String(now) } },
|
|
693
|
+
} : {
|
|
694
|
+
ConditionExpression: "attribute_not_exists(#leaseOwner) AND #version = :version",
|
|
695
|
+
ExpressionAttributeNames: { "#leaseOwner": "leaseOwner", "#version": "version" },
|
|
696
|
+
ExpressionAttributeValues: { ":version": { S: unreadableVersion } },
|
|
697
|
+
}),
|
|
698
|
+
ReturnValues: "ALL_OLD",
|
|
699
|
+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
|
|
395
700
|
}));
|
|
396
|
-
|
|
701
|
+
await this.deleteReplacedChunks(pk, address, response.Attributes);
|
|
702
|
+
return "taken";
|
|
397
703
|
}
|
|
398
704
|
catch (error) {
|
|
399
|
-
if (isConditionalCheckFailure(error))
|
|
400
|
-
|
|
401
|
-
|
|
705
|
+
if (!isConditionalCheckFailure(error))
|
|
706
|
+
throw error;
|
|
707
|
+
current = error.Item;
|
|
402
708
|
}
|
|
403
|
-
|
|
709
|
+
const holder = current?.leaseOwner?.S;
|
|
710
|
+
// Refused over this very lease when the SDK retried an attempt that
|
|
711
|
+
// had already landed: taken, not refused.
|
|
712
|
+
if (holder === owner)
|
|
713
|
+
return "taken";
|
|
714
|
+
if (!current || holder !== undefined)
|
|
715
|
+
return "held";
|
|
716
|
+
const manifest = this.parseManifest(current);
|
|
717
|
+
if (manifest && manifest.expiresAt > now)
|
|
718
|
+
return { filled: manifest };
|
|
719
|
+
const version = current.version?.S;
|
|
720
|
+
if (unreadableVersion !== undefined || version === undefined)
|
|
721
|
+
return "held";
|
|
722
|
+
return await this.takeLease(pk, address, owner, leaseSeconds, version);
|
|
723
|
+
}
|
|
724
|
+
/** Clears this fill's lease off the manifest item, conditional on it still being this fill's, so it never touches what replaced it. */
|
|
404
725
|
async releaseLease(pk, address, owner) {
|
|
405
726
|
try {
|
|
406
727
|
const { client, sdk } = await this.ready();
|
|
407
728
|
await client.send(new sdk.DeleteItemCommand({
|
|
408
729
|
TableName: this.tableName,
|
|
409
|
-
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address,
|
|
410
|
-
ConditionExpression: "#
|
|
411
|
-
ExpressionAttributeNames: { "#
|
|
730
|
+
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
731
|
+
ConditionExpression: "#leaseOwner = :owner",
|
|
732
|
+
ExpressionAttributeNames: { "#leaseOwner": "leaseOwner" },
|
|
412
733
|
ExpressionAttributeValues: { ":owner": { S: owner } },
|
|
413
734
|
}));
|
|
414
735
|
}
|
|
@@ -418,16 +739,21 @@ export class LambderDdbCache {
|
|
|
418
739
|
}
|
|
419
740
|
}
|
|
420
741
|
}
|
|
421
|
-
async readManifest(pk, address) {
|
|
742
|
+
async readManifest(pk, address, consistent) {
|
|
422
743
|
const { client, sdk } = await this.ready();
|
|
423
744
|
const response = await client.send(new sdk.GetItemCommand({
|
|
424
745
|
TableName: this.tableName,
|
|
425
746
|
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
426
|
-
ConsistentRead:
|
|
747
|
+
ConsistentRead: consistent,
|
|
427
748
|
}));
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
749
|
+
return response.Item ? this.parseManifest(response.Item) : undefined;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* The value a manifest item describes, or undefined when it describes
|
|
753
|
+
* none this instance can read: a fill's lease with no value under it
|
|
754
|
+
* yet, or a manifest that does not add up. Expiry is the caller's check.
|
|
755
|
+
*/
|
|
756
|
+
parseManifest(item) {
|
|
431
757
|
const version = item.version?.S;
|
|
432
758
|
const encoding = item.encoding?.S;
|
|
433
759
|
const chunkCount = Number(item.chunkCount?.N);
|
|
@@ -470,12 +796,13 @@ export class LambderDdbCache {
|
|
|
470
796
|
inlineData,
|
|
471
797
|
};
|
|
472
798
|
}
|
|
473
|
-
async readChunks(pk, address, manifest) {
|
|
799
|
+
async readChunks(pk, address, manifest, consistent) {
|
|
474
800
|
const prefix = this.itemSortKey(address, `${CHUNK_SORT_KEY_PREFIX}${manifest.version}#`);
|
|
475
801
|
const items = await this.queryItems(pk, {
|
|
476
802
|
prefix,
|
|
477
803
|
projection: "#sk, #data",
|
|
478
804
|
extraNames: { "#data": "data" },
|
|
805
|
+
consistent,
|
|
479
806
|
});
|
|
480
807
|
const chunks = items
|
|
481
808
|
.filter((item) => item.sk?.S && item.data?.B)
|
|
@@ -509,7 +836,7 @@ export class LambderDdbCache {
|
|
|
509
836
|
},
|
|
510
837
|
ProjectionExpression: options.projection,
|
|
511
838
|
ExclusiveStartKey: cursor,
|
|
512
|
-
ConsistentRead: false,
|
|
839
|
+
ConsistentRead: options.consistent ?? false,
|
|
513
840
|
}));
|
|
514
841
|
items.push(...(response.Items ?? []));
|
|
515
842
|
cursor = response.LastEvaluatedKey;
|
|
@@ -564,52 +891,28 @@ export class LambderDdbCache {
|
|
|
564
891
|
const ttl = expiresAt * 1000 - this.now();
|
|
565
892
|
if (ttl <= 0)
|
|
566
893
|
return;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
throw new Error("Cache key is required");
|
|
576
|
-
const partition = this.normalizePartition(key.pk);
|
|
577
|
-
const sortKey = key.sk;
|
|
578
|
-
if (typeof sortKey !== "string" || !sortKey.trim())
|
|
579
|
-
throw new Error("Cache sort key is required");
|
|
580
|
-
const encodedBytes = Buffer.byteLength(encodeSortKey(sortKey), "utf8");
|
|
581
|
-
if (encodedBytes > MAX_SORT_KEY_BYTES) {
|
|
582
|
-
throw new Error(`Cache sort key must be at most ${MAX_SORT_KEY_BYTES} UTF-8 bytes once escaped (${encodedBytes})`);
|
|
894
|
+
// Kept for as long as the entry lives, and counted by its length, so
|
|
895
|
+
// it must own exactly those bytes. A small Buffer is often a view onto
|
|
896
|
+
// something larger (a slice of zlib's 16 KB output chunk, a copy in
|
|
897
|
+
// Node's shared 8 KB pool), and kept as it is it would pin all of it.
|
|
898
|
+
let owned = stored;
|
|
899
|
+
if (stored.byteLength !== stored.buffer.byteLength) {
|
|
900
|
+
owned = Buffer.allocUnsafeSlow(stored.byteLength);
|
|
901
|
+
stored.copy(owned);
|
|
583
902
|
}
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
normalizePartition(key) {
|
|
587
|
-
if (typeof key !== "string" || !key.trim())
|
|
588
|
-
throw new Error("Cache key is required");
|
|
589
|
-
if (Buffer.byteLength(key, "utf8") > 8 * 1024) {
|
|
590
|
-
throw new Error("Cache key must be at most 8192 UTF-8 bytes");
|
|
591
|
-
}
|
|
592
|
-
return key;
|
|
593
|
-
}
|
|
594
|
-
/** Length-prefixed so a partition ending in the separator cannot collide with a sort key. */
|
|
595
|
-
memoryKeyOf(partition, sortKey) {
|
|
596
|
-
return `${partition.length}:${partition}#${sortKey ?? ""}`;
|
|
903
|
+
this.memory.set(key, { stored: owned, encoding, uncompressedBytes, expiresAt }, { ttl });
|
|
597
904
|
}
|
|
598
905
|
async partitionKey(key) {
|
|
599
906
|
return `${this.keyPrefix}#${this.namespace}#${await sha256(key)}`;
|
|
600
907
|
}
|
|
601
908
|
/**
|
|
602
|
-
* One of an entry's item keys. A plain-string entry
|
|
603
|
-
* suffix
|
|
604
|
-
*
|
|
605
|
-
*
|
|
909
|
+
* One of an entry's item keys. A plain-string entry uses the bare
|
|
910
|
+
* suffix; a grouped one nests under its escaped sort key, whose trailing
|
|
911
|
+
* `#` is an unambiguous boundary because an escaped sort key never
|
|
912
|
+
* contains a bare `#`.
|
|
606
913
|
*/
|
|
607
914
|
itemSortKey(address, suffix) {
|
|
608
|
-
return address.sortKey === null ? suffix : `${SORT_KEY_MARKER}${
|
|
609
|
-
}
|
|
610
|
-
/** The prefix covering every item of a grouped entry; null for a plain-string entry, which owns the bare item keys instead. */
|
|
611
|
-
entryItemPrefix(address) {
|
|
612
|
-
return address.sortKey === null ? null : `${SORT_KEY_MARKER}${encodeSortKey(address.sortKey)}#`;
|
|
915
|
+
return address.sortKey === null ? suffix : `${SORT_KEY_MARKER}${encodeCacheSortKey(address.sortKey)}#${suffix}`;
|
|
613
916
|
}
|
|
614
917
|
isManifestSortKey(sk) {
|
|
615
918
|
return sk === META_SORT_KEY || (!!sk && sk.startsWith(SORT_KEY_MARKER) && sk.endsWith(`#${META_SORT_KEY}`));
|
|
@@ -621,7 +924,7 @@ export class LambderDdbCache {
|
|
|
621
924
|
forgetPartition(partition) {
|
|
622
925
|
if (!this.memory)
|
|
623
926
|
return;
|
|
624
|
-
const prefix =
|
|
927
|
+
const prefix = cacheMemoryKeyOf(partition, "");
|
|
625
928
|
for (const key of [...this.memory.keys()]) {
|
|
626
929
|
if (key.startsWith(prefix))
|
|
627
930
|
this.memory.delete(key);
|