cross-tab-worker-databus 0.21.16 → 0.21.17
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 +18 -1
- package/dist/centrifuge.js +1 -1
- package/dist/{chunk-GQ6NYWQL.js → chunk-EZYCSJRS.js} +49 -2
- package/dist/chunk-EZYCSJRS.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +48 -1
- package/dist/cjs/centrifuge.cjs.map +2 -2
- package/dist/cjs/index.cjs +48 -1
- package/dist/cjs/index.cjs.map +2 -2
- package/dist/core/cluster.d.ts +28 -0
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/docs/architecture.md +2 -1
- package/docs/release-checklist.md +1 -1
- package/docs/roadmap.md +28 -1
- package/docs/zh/architecture.md +2 -1
- package/docs/zh/release-checklist.md +1 -0
- package/docs/zh/roadmap.md +25 -1
- package/package.json +1 -1
- package/dist/chunk-GQ6NYWQL.js.map +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [0.21.17] - 2026-09-24
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **A durable route confirmed to a Worker that no longer holds the assignment no longer orphans its Topic permanently.** `pause()` clears `assignedTopics` unconditionally but rewrites only the routes it can hand off, and a handoff whose final storage flush is cut short leaves the queued rest to be discarded when the teardown ends — so the record on disk still names the departing owner, confirmed, with the generation it had before. When that worker id registers again (a BFCache restore, or a runtime rebuilt with an explicit `workerId`), the route names a live Worker that holds nothing, and both reconcile passes miss the combination by construction: `reconcileSubscriptions()` walks `subscribedTopics` and stands down for a live owner, `reconcileAssignedTopics()` walks `assignedTopics`, and the phantom is in neither. The result is a topic nobody's transport subscribes — inbound messages stop arriving in **every** tab while outbound publishing keeps working, and `isAssigned()` still answers `true` for the phantom owner through its durable-route fallback, so nothing reports it.
|
|
6
|
+
- The named Worker now repairs it, and only it can: reclaim through the same self-assignment `subscribe()` performs when it still has a local handler for the topic (the record and its generation stay byte-identical, so peers see no churn on a route that already named it), or release the record and let a remaining subscriber elect an owner it can act on. Two gates make this safe to run every heartbeat. `confirmedAt` must be present, because that is what distinguishes a settled record from an election or handoff in flight — `confirmRoute()` has exactly three call sites and each sits in the same task as the `assignedTopics.set` before it, so a confirmed route with no assignment can only mean the assignment was taken away afterwards; and the assignment map is consulted first, which is what leaves the *legitimate* case alone: a Worker elected on a peer's behalf owns a route for a topic it never subscribed locally, and a sweep keyed on `subscribedTopics` would delete that record on every pass.
|
|
7
|
+
- The release **flushes the deletion before broadcasting `REGISTRY`**. A peer reconciling on the nudge reads storage through its own batching writer, so without the flush it reads the route that is still queued for deletion in the releasing Worker, stands down again, and the repair waits a full heartbeat. This is the rule `pause()` and `handoffAssignedTopics()` already follow, and the pin is a synchronous post-pass read that fails when the flush is removed.
|
|
8
|
+
- Corrected the premise of one pre-existing test, which hand-wrote a takeover route by copying the real one and changing `workerId`/`tabId`/`generation` — leaving the *previous* owner's `confirmedAt` on a record that names a different worker. No Worker writes that state, and it is exactly what the new sweep releases, so the test's assertion would have gone green by way of a different mechanism than the one it names. It now stages an unconfirmed takeover, which is what a real one looks like from a peer's seat.
|
|
9
|
+
|
|
10
|
+
### Tests
|
|
11
|
+
|
|
12
|
+
- `MemoryStorage` gained `failNextWrites(n)`, which makes the next `n` `setItem` calls throw the error a quota-full `localStorage` throws. Only writes fail — `removeItem` cannot — which is what makes the knob usable for its one purpose: the state above is produced by a lost **write**, and a test that asserted "storage failed" through a lost delete would be asserting a mechanism the browser does not have.
|
|
13
|
+
- Three pins, one per leg, and each dies separately: reclaiming a stranded self-route (`generation` and owner asserted unchanged alongside the restored assignment), releasing one for a peer to re-elect, and leaving a route alone while its holder still has the assignment. Mutating the new code leg by leg, with the whole suite run each time: dropping the confirmation gate fails **6**, dropping the addressee gate fails **28**, dropping the assignment gate fails **7** (including the sticky-route pin above), dropping the reclaim fails exactly **1**, dropping the route delete exactly **1**, dropping the nudge exactly **1**, dropping the flush exactly **1** — the last three are disjoint, so none of the three legs is decoration.
|
|
14
|
+
- The `storage-fail` interleaving that found this is **not** shipped in the coordination sweep, and the reason is measured rather than assumed. Adding it as a band in the seed's roll (2.5% of steps) makes the sweep fail on the pre-fix code with five violating seeds — `414`, `467`, `1557`, `2074`, `2969`, every one opening with a `storage-fail` and ending in `1 subscriber(s) need one owner that holds the transport, got owners=[] transportSubscriptions=[]` — and pass with the fix (`0` violations over the `4,701` seeds that run before the fuse). But the first kill sits at seed **414**, four times above the sweep's 100-seed floor, so on a starved runner the band would not catch a regression of this class, while the unit pins catch it deterministically. It stays a finder: the recipe is in `docs/progress.md`, and the `failNextWrites` knob it needs is now committed.
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
- `docs/architecture.md` and its Chinese mirror gained a "Stranded-self-route repair" invariant bullet next to the "Assignment drift repair" one it is the inverse of, and the heartbeat/reconcile summary now lists the pass in the order it runs (after the orphan prune, before the subscription pass, so a released route is re-elected inside the same cycle rather than one heartbeat later).
|
|
2
19
|
|
|
3
20
|
## [0.21.16] - 2026-09-24
|
|
4
21
|
|
package/dist/centrifuge.js
CHANGED
|
@@ -1284,6 +1284,7 @@ var WorkerClusterRuntime = class {
|
|
|
1284
1284
|
reconcile() {
|
|
1285
1285
|
if (!this.started) return;
|
|
1286
1286
|
const workers = this.reconcileWorkers();
|
|
1287
|
+
this.reconcileStrandedSelfRoutes();
|
|
1287
1288
|
const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
|
|
1288
1289
|
this.reconcileSubscriptions(workers, activeWorkers);
|
|
1289
1290
|
this.reconcileAssignedTopics();
|
|
@@ -1348,6 +1349,52 @@ var WorkerClusterRuntime = class {
|
|
|
1348
1349
|
}
|
|
1349
1350
|
}
|
|
1350
1351
|
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Repair a confirmed route that names this Worker while its assignment is gone.
|
|
1354
|
+
*
|
|
1355
|
+
* Every write that settles ownership stamps the new owner's assignment in the
|
|
1356
|
+
* same task as the confirmation (`confirmRoute()` has exactly three call sites,
|
|
1357
|
+
* each directly after an `assignedTopics.set`), so a route carrying
|
|
1358
|
+
* `confirmedAt` while this map has no entry can only mean the assignment was
|
|
1359
|
+
* taken *after* the route settled. That is what `pause()` does: it clears the
|
|
1360
|
+
* map unconditionally but rewrites only the routes it can hand off, so a
|
|
1361
|
+
* handoff whose final flush is cut short by a storage failure — and the
|
|
1362
|
+
* `discardPending()` that ends the teardown throws the rest away — leaves a
|
|
1363
|
+
* durable route naming this worker. If the same worker id registers again (a
|
|
1364
|
+
* BFCache restore, or a runtime rebuilt with an explicit `workerId`), every
|
|
1365
|
+
* peer reads that route as a live, confirmed owner and stands down, while no
|
|
1366
|
+
* worker holds a transport subscription for the topic any more. Both reconcile
|
|
1367
|
+
* passes miss it by construction: one walks `subscribedTopics`, the other
|
|
1368
|
+
* `assignedTopics`, and the phantom is in neither.
|
|
1369
|
+
*
|
|
1370
|
+
* Acting here is this worker's alone — a peer that elected it is still waiting
|
|
1371
|
+
* for its own CONTROL/SUBSCRIBE to be accepted, and the single-writer rule
|
|
1372
|
+
* recorded in `reconcileSubscriptions()` forbids anyone else rewriting the
|
|
1373
|
+
* record. Reclaim when the local subscription is still live, which restores the
|
|
1374
|
+
* assignment and the transport subscription without touching the route, so the
|
|
1375
|
+
* owner and its generation stay exactly as peers recorded them; release
|
|
1376
|
+
* otherwise, since an owner that has no handler for the topic and never will is
|
|
1377
|
+
* a record nothing can act on.
|
|
1378
|
+
*/
|
|
1379
|
+
reconcileStrandedSelfRoutes() {
|
|
1380
|
+
if (!this.storage) return;
|
|
1381
|
+
let released = false;
|
|
1382
|
+
for (const { key, value: route } of readAllByPrefix(this.storage, this.routePrefix)) {
|
|
1383
|
+
if (route.confirmedAt === void 0 || route.workerId !== this.workerId) continue;
|
|
1384
|
+
if (this.assignedTopics.has(route.topicKey)) continue;
|
|
1385
|
+
const topic = this.knownTopics.get(route.topicKey);
|
|
1386
|
+
if (topic !== void 0 && this.subscribedTopics.has(topic)) {
|
|
1387
|
+
this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, route.topicKey);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
this.removeStorage(key);
|
|
1391
|
+
released = true;
|
|
1392
|
+
}
|
|
1393
|
+
if (released) {
|
|
1394
|
+
this.flushStorage();
|
|
1395
|
+
this.notifyRegistry();
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1351
1398
|
/** Drop assignments where the route no longer points to this worker. */
|
|
1352
1399
|
reconcileAssignedTopics() {
|
|
1353
1400
|
for (const [topicKey, topic] of [...this.assignedTopics]) {
|
|
@@ -2410,7 +2457,7 @@ var DedupManager = class {
|
|
|
2410
2457
|
};
|
|
2411
2458
|
|
|
2412
2459
|
// src/core/version.ts
|
|
2413
|
-
var SDK_VERSION = true ? "0.21.
|
|
2460
|
+
var SDK_VERSION = true ? "0.21.17" : "";
|
|
2414
2461
|
|
|
2415
2462
|
// src/core/data-bus.ts
|
|
2416
2463
|
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
@@ -3641,4 +3688,4 @@ export {
|
|
|
3641
3688
|
parseDataBusPublication,
|
|
3642
3689
|
selectWorkerBackend
|
|
3643
3690
|
};
|
|
3644
|
-
//# sourceMappingURL=chunk-
|
|
3691
|
+
//# sourceMappingURL=chunk-EZYCSJRS.js.map
|