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.
Files changed (237) hide show
  1. package/CHANGELOG.md +1047 -3
  2. package/README.md +46 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +68 -62
  13. package/dist/api/LambderApiIdempotency.js +214 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +47 -38
  17. package/dist/api/LambderApiPipeline.js +122 -63
  18. package/dist/api/LambderApiRateLimits.d.ts +201 -54
  19. package/dist/api/LambderApiRateLimits.js +185 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/ContractTypePrinter.d.ts +85 -0
  27. package/dist/build/ContractTypePrinter.js +402 -0
  28. package/dist/build/freshProcessVerifier.d.ts +13 -0
  29. package/dist/build/freshProcessVerifier.js +19 -0
  30. package/dist/build/moduleLocation.d.ts +11 -0
  31. package/dist/build/moduleLocation.js +6 -0
  32. package/dist/build/writeApiContract.d.ts +78 -0
  33. package/dist/build/writeApiContract.js +302 -0
  34. package/dist/build/writeApiSignatures.d.ts +114 -0
  35. package/dist/build/writeApiSignatures.js +217 -0
  36. package/dist/build/writeFileAtomically.d.ts +8 -0
  37. package/dist/build/writeFileAtomically.js +22 -0
  38. package/dist/build.d.ts +14 -0
  39. package/dist/build.js +11 -0
  40. package/dist/client/LambderCaller.d.ts +13 -44
  41. package/dist/client/LambderCaller.js +77 -84
  42. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  43. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  44. package/dist/client/LambderUploadRunner.d.ts +96 -0
  45. package/dist/client/LambderUploadRunner.js +234 -0
  46. package/dist/client/lambderFetchTransport.d.ts +4 -1
  47. package/dist/client/lambderFetchTransport.js +52 -28
  48. package/dist/client.d.ts +9 -3
  49. package/dist/client.js +6 -1
  50. package/dist/core/Lambder.d.ts +143 -79
  51. package/dist/core/Lambder.js +350 -231
  52. package/dist/core/LambderContext.d.ts +82 -15
  53. package/dist/core/LambderContext.js +107 -20
  54. package/dist/core/LambderCors.d.ts +21 -3
  55. package/dist/core/LambderCors.js +35 -16
  56. package/dist/core/LambderCrashHandling.d.ts +40 -0
  57. package/dist/core/LambderCrashHandling.js +97 -0
  58. package/dist/core/LambderCreateOptions.d.ts +151 -75
  59. package/dist/core/LambderCreateOptions.js +16 -23
  60. package/dist/core/LambderFiles.d.ts +21 -7
  61. package/dist/core/LambderFiles.js +62 -34
  62. package/dist/core/LambderIndexHtml.js +12 -11
  63. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  64. package/dist/core/LambderPolicyBuilders.js +17 -5
  65. package/dist/core/LambderPublicFiles.d.ts +11 -5
  66. package/dist/core/LambderPublicFiles.js +32 -4
  67. package/dist/core/LambderRequestPath.d.ts +43 -0
  68. package/dist/core/LambderRequestPath.js +63 -0
  69. package/dist/core/LambderResponse.d.ts +26 -5
  70. package/dist/core/LambderResponse.js +157 -70
  71. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  72. package/dist/core/LambderResponseBuilder.js +64 -3
  73. package/dist/core/LambderRouting.d.ts +2 -3
  74. package/dist/core/LambderRouting.js +22 -7
  75. package/dist/core/LambderTemplatingEngine.js +211 -32
  76. package/dist/index.d.ts +25 -8
  77. package/dist/index.js +13 -4
  78. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  79. package/dist/invoke/LambderInvokeCaller.js +76 -66
  80. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  81. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  82. package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
  83. package/dist/invoke/LambderLambdaEvent.js +40 -22
  84. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  85. package/dist/invoke/lambderHandlerTransport.js +15 -18
  86. package/dist/mock/LambderMockApp.d.ts +67 -83
  87. package/dist/mock/LambderMockApp.js +167 -153
  88. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  89. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  90. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  91. package/dist/mock/LambderMockCallRecorder.js +19 -28
  92. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  93. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  94. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  95. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  96. package/dist/mock/LambderMockFailureInjector.js +3 -6
  97. package/dist/mock/LambderMockTypes.d.ts +78 -108
  98. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  99. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  100. package/dist/mock/lambderMockMswHandler.d.ts +43 -33
  101. package/dist/mock/lambderMockMswHandler.js +50 -39
  102. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  103. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  104. package/dist/mock.d.ts +4 -1
  105. package/dist/mock.js +6 -3
  106. package/dist/session/LambderSessionController.d.ts +108 -89
  107. package/dist/session/LambderSessionController.js +187 -168
  108. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  109. package/dist/session/LambderSessionCrypto.js +26 -12
  110. package/dist/session/LambderSessionManager.d.ts +124 -46
  111. package/dist/session/LambderSessionManager.js +262 -137
  112. package/dist/shared/LambderHtml.d.ts +42 -3
  113. package/dist/shared/LambderHtml.js +127 -7
  114. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  115. package/dist/shared/LambderHtmlPositions.js +652 -0
  116. package/dist/shared/LambderI18n.d.ts +10 -11
  117. package/dist/shared/LambderI18n.js +33 -21
  118. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  119. package/dist/shared/contracts/LambderCache.js +11 -0
  120. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  121. package/dist/shared/contracts/LambderFileSource.js +5 -8
  122. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  123. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  124. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  125. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  126. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  127. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  128. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  129. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  130. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  131. package/dist/shared/transport/LambderApiTransport.js +7 -7
  132. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  133. package/dist/shared/transport/LambderCookieJar.js +54 -66
  134. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  135. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  136. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  137. package/dist/shared/util/LambderCallAbort.js +5 -5
  138. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  139. package/dist/shared/util/LambderClientIp.js +96 -13
  140. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  141. package/dist/shared/util/LambderContentDisposition.js +13 -0
  142. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  143. package/dist/shared/util/LambderExpiringMap.js +41 -57
  144. package/dist/shared/util/LambderNodeModules.js +6 -7
  145. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  146. package/dist/shared/util/LambderOptionChecks.js +4 -4
  147. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  148. package/dist/shared/util/LambderResponseBrand.js +5 -5
  149. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  150. package/dist/shared/util/LambderTextDigest.js +11 -5
  151. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  152. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  153. package/dist/shared/util/boundKeyField.d.ts +20 -0
  154. package/dist/shared/util/boundKeyField.js +34 -0
  155. package/dist/shared/util/canonicalJson.d.ts +11 -0
  156. package/dist/shared/util/canonicalJson.js +28 -0
  157. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  158. package/dist/shared/util/joinKeyFields.js +22 -0
  159. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  160. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  161. package/dist/shared/wire/LambderApiContract.d.ts +98 -53
  162. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  163. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  164. package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
  165. package/dist/shared/wire/LambderApiRefusal.js +42 -7
  166. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  167. package/dist/shared/wire/LambderApiSignature.js +16 -19
  168. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  169. package/dist/shared/wire/LambderCallOptions.js +9 -11
  170. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  171. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  172. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  173. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  174. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  175. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  176. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  177. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  178. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  179. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  180. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  181. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  182. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  183. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  184. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  185. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  186. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  187. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  188. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  189. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  190. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  191. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  192. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  193. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  194. package/dist/stores/LambderCacheFiller.js +119 -0
  195. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  196. package/dist/stores/LambderCacheKeys.js +54 -0
  197. package/dist/stores/LambderCacheValues.d.ts +45 -0
  198. package/dist/stores/LambderCacheValues.js +74 -0
  199. package/dist/stores/LambderDdbCache.d.ts +121 -56
  200. package/dist/stores/LambderDdbCache.js +528 -225
  201. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  202. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  203. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  204. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  205. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  206. package/dist/stores/LambderDdbSdk.js +80 -38
  207. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  208. package/dist/stores/LambderDdbSessionStore.js +119 -47
  209. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  210. package/dist/stores/LambderHttpFileSource.js +15 -13
  211. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  212. package/dist/stores/LambderMemoryCache.js +113 -0
  213. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  214. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  215. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  216. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  217. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  218. package/dist/stores/LambderMemorySessionStore.js +38 -19
  219. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  220. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  221. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  222. package/dist/stores/LambderS3FileSource.js +12 -7
  223. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  224. package/dist/stores/LambderS3UploadBucket.js +144 -0
  225. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  226. package/dist/stores/LambderSdkInstallHint.js +14 -0
  227. package/dist/testing/LambderTestApp.d.ts +23 -25
  228. package/dist/testing/LambderTestApp.js +22 -24
  229. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  230. package/dist/testing/LambderTestVisitor.js +15 -15
  231. package/dist/testing.d.ts +3 -0
  232. package/dist/testing.js +2 -0
  233. package/package.json +26 -3
  234. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  235. package/dist/api/LambderApiPolicyEngine.js +0 -85
  236. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  237. 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
- const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
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 orphans its chunks
33
- * until their TTL and sends every later reader to the origin, which is the
34
- * load the cache exists to absorb.
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
- * `#` separates the store's own item-key segments, so a caller's `#` is
44
- * escaped rather than refused: `~` becomes `~0` and `#` becomes `~1`. An
45
- * encoded sort key therefore never contains a bare `#`, which keeps
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 encodeSortKey = (value) => value.replace(/~/g, "~0").replace(/#/g, "~1");
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. Brotli helpers
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
- * split into versioned binary chunks. A manifest is written only after every
81
- * chunk succeeds, so readers see either the previous complete version or the
82
- * new complete version. DynamoDB TTL is cleanup only; every read also checks
83
- * expiresAt because TTL deletion can lag.
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. Plain-string keys keep the exact item layout
93
- * they have always had (`meta`, `lock`, `chunk#...`), and grouped entries
94
- * live beside them under `sk#<encoded sort key>#...`, so both forms can
95
- * share a partition and a live table needs no migration.
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
- inFlight = new Map();
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
- sizeCalculation: (entry) => entry.stored.length,
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(this.normalizeKey(key));
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
- const pk = await this.partitionKey(address.partition);
153
- const manifest = await this.readManifest(pk, address);
154
- if (!manifest || manifest.expiresAt <= nowSeconds)
155
- return undefined;
156
- try {
157
- const stored = manifest.inlineData ?? await this.readChunks(pk, address, manifest);
158
- if (stored.length !== manifest.storedBytes) {
159
- throw new LambderCacheIntegrityError("stored byte length does not match manifest");
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
- if (await sha256(stored) !== manifest.checksum) {
162
- throw new LambderCacheIntegrityError("stored checksum does not match manifest");
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
- const json = await this.decode(stored, manifest.encoding, manifest.uncompressedBytes);
165
- const parsed = JSON.parse(json);
166
- this.remember(address.memoryKey, stored, manifest.encoding, manifest.uncompressedBytes, manifest.expiresAt);
167
- return parsed;
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
- // Only an entry this read can prove wrong is dropped. Everything
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 = this.normalizeKey(key);
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
- const manifest = await this.readManifest(await this.partitionKey(address.partition), address);
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
- return await this.setByAddress(this.normalizeKey(key), value, options);
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
- async setByAddress(address, value, options) {
196
- const ttlSeconds = assertPositiveInteger(options.ttlSeconds ?? this.defaultTtlSeconds, "ttlSeconds");
197
- const json = JSON.stringify(value);
198
- if (json === undefined)
199
- throw new Error("Cache value must be JSON-serializable");
200
- const input = Buffer.from(json, "utf8");
201
- if (input.length > this.maxValueBytes) {
202
- throw new Error(`Cache value exceeds maxValueBytes (${input.length} > ${this.maxValueBytes})`);
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.batchWrite(writes);
231
- const { client, sdk } = await this.ready();
232
- await client.send(new sdk.PutItemCommand({
233
- TableName: this.tableName,
234
- Item: {
235
- pk: { S: pk },
236
- sk: { S: this.itemSortKey(address, META_SORT_KEY) },
237
- version: { S: version },
238
- chunkCount: { N: String(chunks.length) },
239
- storedBytes: { N: String(stored.length) },
240
- uncompressedBytes: { N: String(input.length) },
241
- checksum: { S: await sha256(stored) },
242
- encoding: { S: encoding },
243
- createdAt: { N: String(this.nowSeconds()) },
244
- expiresAt: { N: String(expiresAt) },
245
- ...(inline ? { data: { B: stored } } : {}),
246
- },
247
- }));
248
- this.remember(address.memoryKey, stored, encoding, input.length, expiresAt);
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 = this.normalizeKey(key);
252
- const pk = await this.partitionKey(address.partition);
492
+ const address = normalizeCacheKey(key);
493
+ this.filler.supersedeFill(address.memoryKey);
253
494
  this.memory?.delete(address.memoryKey);
254
- // A grouped entry owns one contiguous item range; a plain-string one
255
- // owns the bare item keys, so it must leave any grouped entries
256
- // sharing its partition alone.
257
- const prefix = this.entryItemPrefix(address);
258
- const items = await this.queryItems(pk, { prefix, projection: "#pk, #sk" });
259
- const owned = prefix ? items : items.filter((item) => !item.sk?.S?.startsWith(SORT_KEY_MARKER));
260
- await this.deleteItems(owned);
261
- return owned.length > 0;
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 entries removed. In-memory copies held by
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 = this.normalizePartition(partition);
272
- const pk = await this.partitionKey(normalized);
532
+ const normalized = normalizeCachePartition(partition);
533
+ this.filler.supersedeFillsWithPrefix(cacheMemoryKeyOf(normalized, ""));
273
534
  this.forgetPartition(normalized);
274
- const items = await this.queryItems(pk, { projection: "#pk, #sk" });
275
- await this.deleteItems(items);
276
- return items.filter((item) => this.isManifestSortKey(item.sk?.S)).length;
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
- * Reading a partition whose values are chunked also reads those chunk
282
- * items, so grouping very large values makes listing more expensive.
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(this.normalizePartition(partition));
286
- const prefix = `${SORT_KEY_MARKER}${encodeSortKey(options.prefix ?? "")}`;
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, { prefix, projection: "#sk, #expiresAt", extraNames: { "#expiresAt": "expiresAt" } });
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
- if (Number(item.expiresAt?.N) <= nowSeconds)
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(decodeSortKey(sk.slice(SORT_KEY_MARKER.length, -(META_SORT_KEY.length + 1))));
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, factory, options = {}) {
304
- const address = this.normalizeKey(key);
305
- const current = this.inFlight.get(address.memoryKey);
306
- if (current)
307
- return current;
308
- const fill = this.getOrSetFailOpen(address, factory, options).finally(() => {
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, trackedFactory, options);
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
- async fill(address, factory, options) {
345
- const leaseSeconds = assertPositiveInteger(options.leaseSeconds ?? 15, "leaseSeconds");
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
- if (await this.acquireLease(pk, address, owner, leaseSeconds)) {
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
- const value = await factory();
352
- await this.setByAddress(address, value, { ttlSeconds: options.ttlSeconds });
353
- return value;
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
- await this.releaseLease(pk, address, owner);
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
- while (Date.now() < deadline) {
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
- async acquireLease(pk, address, owner, leaseSeconds) {
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, LOCK_SORT_KEY) },
389
- owner: { S: owner },
390
- expiresAt: { N: String(now + leaseSeconds) },
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
- ConditionExpression: "attribute_not_exists(#pk) OR #expiresAt < :now",
393
- ExpressionAttributeNames: { "#pk": "pk", "#expiresAt": "expiresAt" },
394
- ExpressionAttributeValues: { ":now": { N: String(now) } },
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
- return true;
701
+ await this.deleteReplacedChunks(pk, address, response.Attributes);
702
+ return "taken";
397
703
  }
398
704
  catch (error) {
399
- if (isConditionalCheckFailure(error))
400
- return false;
401
- throw error;
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, LOCK_SORT_KEY) } },
410
- ConditionExpression: "#owner = :owner",
411
- ExpressionAttributeNames: { "#owner": "owner" },
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: false,
747
+ ConsistentRead: consistent,
427
748
  }));
428
- const item = response.Item;
429
- if (!item)
430
- return undefined;
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
- this.memory.set(key, { stored, encoding, uncompressedBytes, expiresAt }, { ttl });
568
- }
569
- normalizeKey(key) {
570
- if (typeof key === "string") {
571
- const partition = this.normalizePartition(key);
572
- return { partition, sortKey: null, memoryKey: this.memoryKeyOf(partition, null) };
573
- }
574
- if (!key || typeof key !== "object")
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
- return { partition, sortKey, memoryKey: this.memoryKeyOf(partition, sortKey) };
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 keeps the bare
603
- * suffix it has always used; a grouped one nests under its escaped sort
604
- * key, whose trailing `#` is an unambiguous boundary because an escaped
605
- * sort key never contains a bare `#`.
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}${encodeSortKey(address.sortKey)}#${suffix}`;
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 = this.memoryKeyOf(partition, "");
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);