@zerotal/cache 1.4.0 → 1.5.0

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 CHANGED
@@ -8,6 +8,18 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Fixed
14
+
15
+ - **Stampede protection survives a compute slower than 30 seconds.** `remember()`'s
16
+ recompute lock had a fixed 30s TTL and no way to extend it, so a factory that ran longer
17
+ silently dropped its lock and let a second node in — and an expensive query is the only
18
+ kind anyone bothers caching, so the protection failed precisely where it was needed. The
19
+ lock now refreshes for as long as the factory runs. A lock lost anyway falls back to
20
+ re-checking the cache and recomputing, which is what a timed-out waiter already did:
21
+ caching is best-effort here, not a correctness boundary.
22
+
11
23
  ## [1.0.3] — 2026-08-07
12
24
 
13
25
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/cache",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,7 +30,7 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.4.0"
33
+ "@zerotal/core": "1.5.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.8.0"
@@ -3,10 +3,16 @@ import { CacheSerializationError, CacheDeserializationError } from "./errors.ts"
3
3
  import { FrameworkEvents, sha256Hex } from "@zerotal/core";
4
4
  import { CacheQueried } from "./events.ts";
5
5
  import type { LockManager } from "@zerotal/core/lock";
6
- import { LockNotAcquiredError } from "@zerotal/core/lock";
6
+ import { LockNotAcquiredError, LockLostError } from "@zerotal/core/lock";
7
7
 
8
8
  // Cross-process recompute lock for remember(): how long the lock is held while the
9
9
  // factory runs, and how long a waiter blocks for the holder before recomputing anyway.
10
+ //
11
+ // The TTL is now a heartbeat interval rather than a deadline — the lock is
12
+ // refreshed for as long as the factory runs, so a compute slower than 30s no
13
+ // longer silently drops its lock and lets a second node in. That was the exact
14
+ // case where stampede protection stopped protecting: an expensive query, which
15
+ // is the only kind anyone bothers caching.
10
16
  const STAMPEDE_LOCK_TTL = 30; // seconds
11
17
  const STAMPEDE_LOCK_WAIT = 30; // seconds
12
18
 
@@ -137,9 +143,15 @@ export class CacheManager {
137
143
  return await this._lock.block(lockKey, STAMPEDE_LOCK_TTL, recheckThenCompute, {
138
144
  timeout: STAMPEDE_LOCK_WAIT,
139
145
  retryDelay: 50,
146
+ refresh: true,
140
147
  });
141
148
  } catch (err) {
142
149
  if (err instanceof LockNotAcquiredError) return recheckThenCompute();
150
+ // A lost lock is not a reason to fail the read. Caching is best-effort by
151
+ // design here — a waiter that times out already recomputes — so losing the
152
+ // lock mid-compute means at worst two nodes did the work, which is the
153
+ // thing we were optimising, not a correctness boundary.
154
+ if (err instanceof LockLostError) return recheckThenCompute();
143
155
  throw err;
144
156
  }
145
157
  }