@lunora/do 1.0.0-alpha.16 → 1.0.0-alpha.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/dist/index.d.mts CHANGED
@@ -5846,8 +5846,12 @@ declare abstract class ShardDO {
5846
5846
  * subscriber counts are folded live from each socket's attachment via
5847
5847
  * {@link summarizeFanoutTopics}; the running per-path cost counters are the
5848
5848
  * in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
5849
- * "since this instance woke" epoch. Read-only: touches no SQLite and mutates
5850
- * no socket state.
5849
+ * "since this instance woke" epoch. Touches no SQLite and mutates no socket
5850
+ * state; it does call `relay.relayCount()`, which advances the promotion
5851
+ * latch — safe here because that transition is a pure, monotonic function of
5852
+ * the live socket count (DOs are single-threaded), so a metrics poll only ever
5853
+ * drives the latch to the same state the routing path would compute for the
5854
+ * same count, never a divergent one.
5851
5855
  */
5852
5856
  private collectFanoutMetrics;
5853
5857
  /** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
package/dist/index.d.ts CHANGED
@@ -5846,8 +5846,12 @@ declare abstract class ShardDO {
5846
5846
  * subscriber counts are folded live from each socket's attachment via
5847
5847
  * {@link summarizeFanoutTopics}; the running per-path cost counters are the
5848
5848
  * in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
5849
- * "since this instance woke" epoch. Read-only: touches no SQLite and mutates
5850
- * no socket state.
5849
+ * "since this instance woke" epoch. Touches no SQLite and mutates no socket
5850
+ * state; it does call `relay.relayCount()`, which advances the promotion
5851
+ * latch — safe here because that transition is a pure, monotonic function of
5852
+ * the live socket count (DOs are single-threaded), so a metrics poll only ever
5853
+ * drives the latch to the same state the routing path would compute for the
5854
+ * same count, never a divergent one.
5851
5855
  */
5852
5856
  private collectFanoutMetrics;
5853
5857
  /** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
package/dist/index.mjs CHANGED
@@ -26,7 +26,7 @@ export { RLS_UNWRAP_SYMBOL, RlsRequiredError, guardWriter } from './packem_share
26
26
  export { buildFtsMatch, ftsTableName, scoreDocument, stringifySearchText, tokenizeSearch } from './packem_shared/buildFtsMatch-BLEMawrp.mjs';
27
27
  export { M as MIN_ADMIN_TOKEN_LENGTH, a as MIN_AUTH_SECRET_LENGTH, b as buildSecurityAudit } from './packem_shared/security-audit-CucgBice.mjs';
28
28
  export { SESSION_DO_TTL_DEFAULT, SessionDO } from './packem_shared/SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs';
29
- export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-CtTkRXH0.mjs';
29
+ export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-DDp_NomS.mjs';
30
30
  export { SHARD_REGISTRY_DO_NAME, ShardRegistryDO } from './packem_shared/SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs';
31
31
  export { MAX_SQL_ROWS, assertReadonly, runReadonlySql } from './packem_shared/MAX_SQL_ROWS-dDcFE1YZ.mjs';
32
32
  export { createSystemReader } from './packem_shared/createSystemReader-8CzSZP9V.mjs';
@@ -196,7 +196,22 @@ const parseRelayName = (name) => {
196
196
  };
197
197
 
198
198
  const shapeRoutingKey = (name, args) => stableStringify({ args: args ?? {}, name });
199
- const DEFAULT_PROMOTION_THRESHOLDS = { tUp: 8e3 };
199
+ const DEFAULT_PROMOTION_THRESHOLDS = { tDown: 4e3, tUp: 8e3 };
200
+ const nextPromotionState = (current, subscribers, thresholds = DEFAULT_PROMOTION_THRESHOLDS) => {
201
+ if (thresholds.tDown >= thresholds.tUp) {
202
+ throw new Error(`invalid promotion thresholds: tDown (${String(thresholds.tDown)}) must be < tUp (${String(thresholds.tUp)})`);
203
+ }
204
+ if (current === "owned") {
205
+ return subscribers >= thresholds.tUp ? "promoted" : "owned";
206
+ }
207
+ return subscribers < thresholds.tDown ? "owned" : "promoted";
208
+ };
209
+ const clampPromotionThresholds = (tUp, tDownRaw) => {
210
+ if (tDownRaw < tUp) {
211
+ return { tDown: tDownRaw, tUp };
212
+ }
213
+ return { tDown: Math.min(Math.max(1, Math.floor(tUp / 2)), tUp - 1), tUp };
214
+ };
200
215
 
201
216
  const projectColumns = (document_, columns) => {
202
217
  if (!columns) {
@@ -232,11 +247,12 @@ const diffGlobalMembership = (rows, previous, options) => {
232
247
  }
233
248
  return { next, rowsPatch };
234
249
  };
235
- const buildPokeFrames = (parts, meta) => {
250
+ const encodeRowsPatch = (rowsPatch) => rowsPatch.map((op) => op.value === void 0 ? op : { ...op, value: encodeWire(op.value) });
251
+ const buildPokeFrames = (parts, meta, options = {}) => {
236
252
  const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
237
253
  const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
238
254
  for (const part of parts) {
239
- const rowsPatch = part.rowsPatch.map((op) => op.value === void 0 ? op : { ...op, value: encodeWire(op.value) });
255
+ const rowsPatch = options.preEncoded ? part.rowsPatch : encodeRowsPatch(part.rowsPatch);
240
256
  frames.push(
241
257
  JSON.stringify({
242
258
  pokeId,
@@ -376,6 +392,8 @@ class OwnerRelay extends RelayLink {
376
392
  relayShapeRegistry = /* @__PURE__ */ new Map();
377
393
  /** NON-uniform (identity-scoped) relay shapes, one entry per relay socket, keyed `relayIndex:connectionId:subId`. Each is served live by a per-socket proxy poke. */
378
394
  relayShapeProxies = /* @__PURE__ */ new Map();
395
+ /** The owner's current promotion state (plan 075 Phase 4), carried across `relayCount()` calls so hysteresis has memory — a shard hovering near the threshold can't flap. */
396
+ promotionState = "owned";
379
397
  constructor(host, ownerKey) {
380
398
  super(host, { ownerKey });
381
399
  }
@@ -406,13 +424,22 @@ class OwnerRelay extends RelayLink {
406
424
  }
407
425
  /**
408
426
  * How many relays the runtime should spread new connections across for this shard
409
- * (plan 075 Phase 2). `0` keeps every connection on the owner. The owner promotes
410
- * once its live socket count crosses `LUNORA_RELAY_THRESHOLD`, fanning to a fixed
411
- * `LUNORA_RELAY_FAN` (capped by `LUNORA_MAX_RELAYS`, the cost ceiling).
427
+ * (plan 075 Phase 2, hysteresis added in Phase 4). `0` keeps every connection on
428
+ * the owner. The owner promotes once its live socket count reaches
429
+ * `LUNORA_RELAY_THRESHOLD` (`tUp`), fanning to a fixed `LUNORA_RELAY_FAN` (capped
430
+ * by `LUNORA_MAX_RELAYS`, the cost ceiling), and only collapses back to
431
+ * owner-served once subscribers drain below `LUNORA_RELAY_COLLAPSE_THRESHOLD`
432
+ * (`tDown`) — the band between the two holds the current state so a shard
433
+ * hovering near the threshold can't flap. {@link clampPromotionThresholds}
434
+ * guarantees a valid `tDown < tUp` band even under a misconfigured collapse
435
+ * threshold. Advances the promotion latch, so it is not a pure read.
412
436
  */
413
437
  relayCount() {
414
- const threshold = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
415
- if (this.host.getWebSockets().length < threshold) {
438
+ const subscribers = this.host.getWebSockets().length;
439
+ const tUp = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
440
+ const tDownRaw = envPositiveInt(this.host.env(), "LUNORA_RELAY_COLLAPSE_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tDown);
441
+ this.promotionState = nextPromotionState(this.promotionState, subscribers, clampPromotionThresholds(tUp, tDownRaw));
442
+ if (this.promotionState === "owned") {
416
443
  return 0;
417
444
  }
418
445
  const maxRelays = envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
@@ -497,7 +524,10 @@ class OwnerRelay extends RelayLink {
497
524
  epoch,
498
525
  fromCursor,
499
526
  name: entry.name,
500
- rowsPatch,
527
+ // Wire-encode before the poke crosses the owner→relay `JSON.stringify`
528
+ // hop (`requestRelayMessage`); the relay re-frames it with
529
+ // `preEncoded` so a `bytes`/`bigint` shape column isn't dropped/truncated.
530
+ rowsPatch: encodeRowsPatch(rowsPatch),
501
531
  type: "relay_shape_poke"
502
532
  };
503
533
  for (const index of relays) {
@@ -541,7 +571,9 @@ class OwnerRelay extends RelayLink {
541
571
  epoch,
542
572
  fromCursor,
543
573
  name: entry.name,
544
- rowsPatch,
574
+ // Wire-encode before the owner→relay `JSON.stringify` hop (see the
575
+ // cohort-multicast path); the relay re-frames it with `preEncoded`.
576
+ rowsPatch: encodeRowsPatch(rowsPatch),
545
577
  targetConnectionId: entry.connectionId,
546
578
  type: "relay_shape_poke"
547
579
  };
@@ -861,13 +893,19 @@ class RelayMember extends RelayLink {
861
893
  if (memo?.cursor !== poke.fromCursor || memo.epoch !== poke.epoch || shapeRoutingKey(sub.name, sub.args) !== routingKey) {
862
894
  continue;
863
895
  }
864
- const frames = buildPokeFrames([{ rowsPatch: poke.rowsPatch, shapeId: subId }], {
865
- baseCheckpoint: void 0,
866
- checkpoint: poke.checkpoint,
867
- epoch: poke.epoch,
868
- lastMutationId: void 0,
869
- pokeId: this.host.nextPokeId()
870
- });
896
+ const frames = buildPokeFrames(
897
+ [{ rowsPatch: poke.rowsPatch, shapeId: subId }],
898
+ {
899
+ baseCheckpoint: void 0,
900
+ checkpoint: poke.checkpoint,
901
+ epoch: poke.epoch,
902
+ lastMutationId: void 0,
903
+ pokeId: this.host.nextPokeId()
904
+ },
905
+ // `poke.rowsPatch` was wire-encoded by the owner before it crossed
906
+ // the hub — don't double-encode it here.
907
+ { preEncoded: true }
908
+ );
871
909
  for (const frame of frames) {
872
910
  trySendFrame(ws, frame);
873
911
  }
@@ -4651,8 +4689,12 @@ class ShardDO {
4651
4689
  * subscriber counts are folded live from each socket's attachment via
4652
4690
  * {@link summarizeFanoutTopics}; the running per-path cost counters are the
4653
4691
  * in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
4654
- * "since this instance woke" epoch. Read-only: touches no SQLite and mutates
4655
- * no socket state.
4692
+ * "since this instance woke" epoch. Touches no SQLite and mutates no socket
4693
+ * state; it does call `relay.relayCount()`, which advances the promotion
4694
+ * latch — safe here because that transition is a pure, monotonic function of
4695
+ * the live socket count (DOs are single-threaded), so a metrics poll only ever
4696
+ * drives the latch to the same state the routing path would compute for the
4697
+ * same count, never a divergent one.
4656
4698
  */
4657
4699
  collectFanoutMetrics() {
4658
4700
  const summary = summarizeFanoutTopics(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/do",
3
- "version": "1.0.0-alpha.16",
3
+ "version": "1.0.0-alpha.17",
4
4
  "description": "Lunora Durable Objects: ShardDO (SQLite, OCC, hibernated WebSocket subscriptions) and SessionDO",
5
5
  "keywords": [
6
6
  "cloudflare",