@rocicorp/zero 1.9.1-canary.1 → 1.9.1-canary.2

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 (69) hide show
  1. package/out/analyze-query/src/analyze-cli.js +2 -2
  2. package/out/analyze-query/src/analyze-cli.js.map +1 -1
  3. package/out/shared/src/promise-race.d.ts +14 -0
  4. package/out/shared/src/promise-race.d.ts.map +1 -1
  5. package/out/shared/src/promise-race.js +32 -1
  6. package/out/shared/src/promise-race.js.map +1 -1
  7. package/out/zero/package.js +1 -1
  8. package/out/zero/package.js.map +1 -1
  9. package/out/zero-cache/src/config/zero-config.d.ts +5 -1
  10. package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
  11. package/out/zero-cache/src/config/zero-config.js +48 -12
  12. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  13. package/out/zero-cache/src/db/transaction-pool.d.ts +0 -13
  14. package/out/zero-cache/src/db/transaction-pool.d.ts.map +1 -1
  15. package/out/zero-cache/src/db/transaction-pool.js +3 -69
  16. package/out/zero-cache/src/db/transaction-pool.js.map +1 -1
  17. package/out/zero-cache/src/server/change-streamer.d.ts.map +1 -1
  18. package/out/zero-cache/src/server/change-streamer.js +3 -2
  19. package/out/zero-cache/src/server/change-streamer.js.map +1 -1
  20. package/out/zero-cache/src/server/replicator.d.ts.map +1 -1
  21. package/out/zero-cache/src/server/replicator.js +1 -1
  22. package/out/zero-cache/src/server/replicator.js.map +1 -1
  23. package/out/zero-cache/src/services/change-source/custom/change-source.js +2 -2
  24. package/out/zero-cache/src/services/change-source/custom/change-source.js.map +1 -1
  25. package/out/zero-cache/src/services/change-streamer/broadcast.d.ts +67 -77
  26. package/out/zero-cache/src/services/change-streamer/broadcast.d.ts.map +1 -1
  27. package/out/zero-cache/src/services/change-streamer/broadcast.js +84 -92
  28. package/out/zero-cache/src/services/change-streamer/broadcast.js.map +1 -1
  29. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +3 -3
  30. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
  31. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts +2 -1
  32. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts.map +1 -1
  33. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +7 -4
  34. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
  35. package/out/zero-cache/src/services/change-streamer/error-type-enum.d.ts +8 -0
  36. package/out/zero-cache/src/services/change-streamer/error-type-enum.d.ts.map +1 -1
  37. package/out/zero-cache/src/services/change-streamer/forwarder.d.ts +13 -1
  38. package/out/zero-cache/src/services/change-streamer/forwarder.d.ts.map +1 -1
  39. package/out/zero-cache/src/services/change-streamer/forwarder.js +42 -12
  40. package/out/zero-cache/src/services/change-streamer/forwarder.js.map +1 -1
  41. package/out/zero-cache/src/services/change-streamer/storer.d.ts +34 -0
  42. package/out/zero-cache/src/services/change-streamer/storer.d.ts.map +1 -1
  43. package/out/zero-cache/src/services/change-streamer/storer.js +149 -40
  44. package/out/zero-cache/src/services/change-streamer/storer.js.map +1 -1
  45. package/out/zero-cache/src/services/change-streamer/subscriber.d.ts +22 -6
  46. package/out/zero-cache/src/services/change-streamer/subscriber.d.ts.map +1 -1
  47. package/out/zero-cache/src/services/change-streamer/subscriber.js +39 -2
  48. package/out/zero-cache/src/services/change-streamer/subscriber.js.map +1 -1
  49. package/out/zero-cache/src/services/replicator/incremental-sync.d.ts +1 -1
  50. package/out/zero-cache/src/services/replicator/incremental-sync.d.ts.map +1 -1
  51. package/out/zero-cache/src/services/replicator/incremental-sync.js +6 -1
  52. package/out/zero-cache/src/services/replicator/incremental-sync.js.map +1 -1
  53. package/out/zero-cache/src/services/replicator/replicator.d.ts +1 -1
  54. package/out/zero-cache/src/services/replicator/replicator.d.ts.map +1 -1
  55. package/out/zero-cache/src/services/replicator/replicator.js +2 -2
  56. package/out/zero-cache/src/services/replicator/replicator.js.map +1 -1
  57. package/out/zero-cache/src/types/pg.d.ts.map +1 -1
  58. package/out/zero-cache/src/types/pg.js.map +1 -1
  59. package/out/zero-cache/src/types/streams.d.ts +4 -9
  60. package/out/zero-cache/src/types/streams.d.ts.map +1 -1
  61. package/out/zero-cache/src/types/streams.js.map +1 -1
  62. package/out/zero-cache/src/types/subscription.d.ts +2 -11
  63. package/out/zero-cache/src/types/subscription.d.ts.map +1 -1
  64. package/out/zero-cache/src/types/subscription.js +4 -25
  65. package/out/zero-cache/src/types/subscription.js.map +1 -1
  66. package/out/zero-cache/src/workers/connection.js +2 -2
  67. package/out/zero-cache/src/workers/connection.js.map +1 -1
  68. package/out/zero-client/src/client/version.js +1 -1
  69. package/package.json +1 -1
@@ -3,12 +3,24 @@ import type { WatermarkedChange } from './change-streamer-service.ts';
3
3
  import type { Status } from './change-streamer.ts';
4
4
  import type { Subscriber } from './subscriber.ts';
5
5
  export type ProgressMonitorOptions = {
6
- flowControlConsensusPaddingSeconds: number;
6
+ flowControlConsensusTimeoutProportion: number;
7
+ flowControlSlowSubscriberGracePeriodMs?: number | undefined;
8
+ setTimeoutFn?: typeof setTimeout | undefined;
9
+ clearTimeoutFn?: typeof clearTimeout | undefined;
7
10
  };
8
11
  export declare class Forwarder {
9
12
  #private;
10
13
  constructor(lc: LogContext, opts?: ProgressMonitorOptions);
11
14
  startProgressMonitor(): void;
15
+ /**
16
+ * Runs one progress-monitor tick: samples each active subscriber's change
17
+ * rate, logs slow broadcasts, and disconnects subscribers that have been
18
+ * continuously lagging for longer than the configured grace period.
19
+ *
20
+ * Invoked on an interval by {@link startProgressMonitor()}; exposed (rather
21
+ * than private) so tests can drive a single tick with an explicit `now`.
22
+ */
23
+ checkSubscriberProgress(now: number): void;
12
24
  stopProgressMonitor(): void;
13
25
  /**
14
26
  * `add()` is called in lock step with `Storer.catchup()` so that the
@@ -1 +1 @@
1
- {"version":3,"file":"forwarder.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/forwarder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAQjD,OAAO,KAAK,EAAY,iBAAiB,EAAC,MAAM,8BAA8B,CAAC;AAC/E,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,sBAAsB,CAAC;AACjD,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAEhD,MAAM,MAAM,sBAAsB,GAAG;IACnC,kCAAkC,EAAE,MAAM,CAAC;CAC5C,CAAC;AAEF,qBAAa,SAAS;;gBAqBlB,EAAE,EAAE,UAAU,EACd,IAAI,GAAE,sBAAgE;IAkDxE,oBAAoB;IAsBpB,mBAAmB;IAInB;;;;OAIG;IACH,GAAG,CAAC,GAAG,EAAE,UAAU;IAQnB,MAAM,CAAC,GAAG,EAAE,UAAU;IAMtB;;;;;;;;OAQG;IACH,OAAO,CAAC,KAAK,EAAE,iBAAiB;IAKhC,UAAU,CAAC,MAAM,EAAE,MAAM;IAMzB;;;OAGG;IACG,sBAAsB,CAAC,KAAK,EAAE,iBAAiB;IAiErD,OAAO,IAAI,GAAG,CAAC,MAAM,CAAC;CAQvB"}
1
+ {"version":3,"file":"forwarder.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/forwarder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAQjD,OAAO,KAAK,EAAY,iBAAiB,EAAC,MAAM,8BAA8B,CAAC;AAC/E,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,sBAAsB,CAAC;AAEjD,OAAO,KAAK,EAAC,UAAU,EAAkB,MAAM,iBAAiB,CAAC;AAEjE,MAAM,MAAM,sBAAsB,GAAG;IACnC,qCAAqC,EAAE,MAAM,CAAC;IAC9C,sCAAsC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAE5D,YAAY,CAAC,EAAE,OAAO,UAAU,GAAG,SAAS,CAAC;IAC7C,cAAc,CAAC,EAAE,OAAO,YAAY,GAAG,SAAS,CAAC;CAClD,CAAC;AAEF,qBAAa,SAAS;;gBAyBlB,EAAE,EAAE,UAAU,EACd,IAAI,GAAE,sBAEL;IA+DH,oBAAoB;IAQpB;;;;;;;OAOG;IACH,uBAAuB,CAAC,GAAG,EAAE,MAAM;IAyCnC,mBAAmB;IAInB;;;;OAIG;IACH,GAAG,CAAC,GAAG,EAAE,UAAU;IAQnB,MAAM,CAAC,GAAG,EAAE,UAAU;IAMtB;;;;;;;;OAQG;IACH,OAAO,CAAC,KAAK,EAAE,iBAAiB;IAKhC,UAAU,CAAC,MAAM,EAAE,MAAM;IAMzB;;;OAGG;IACG,sBAAsB,CAAC,KAAK,EAAE,iBAAiB;IAsErD,OAAO,IAAI,GAAG,CAAC,MAAM,CAAC;CAQvB"}
@@ -4,17 +4,27 @@ import { Broadcast } from "./broadcast.js";
4
4
  //#region ../zero-cache/src/services/change-streamer/forwarder.ts
5
5
  var Forwarder = class {
6
6
  #lc;
7
- #progressMonitorOptions;
7
+ #setTimeout;
8
+ #clearTimeout;
8
9
  #active = /* @__PURE__ */ new Set();
9
10
  #queued = /* @__PURE__ */ new Set();
11
+ #earlyReleaseOptions;
12
+ #flowControlSlowSubscriberGracePeriodMs;
10
13
  #flowControlWaits = getOrCreateCounter("replication", "flow_control.waits", "Flow-control checkpoints completed, labeled by release mode.");
11
14
  #flowControlWaitTime = getOrCreateLatencyHistogram("replication", "flow_control.wait_duration", "Time replication waits at flow-control checkpoints.");
12
15
  #inTransaction = false;
13
16
  #currentBroadcast;
14
17
  #progressMonitor;
15
- constructor(lc, opts = { flowControlConsensusPaddingSeconds: 1 }) {
16
- this.#lc = lc.withContext("component", "progress-monitor");
17
- this.#progressMonitorOptions = opts;
18
+ constructor(lc, opts = { flowControlConsensusTimeoutProportion: 2 }) {
19
+ this.#lc = lc.withContext("component", "subscriber-progress-monitor");
20
+ this.#setTimeout = opts.setTimeoutFn ?? setTimeout;
21
+ this.#clearTimeout = opts.clearTimeoutFn ?? clearTimeout;
22
+ this.#earlyReleaseOptions = opts.flowControlConsensusTimeoutProportion < 0 ? void 0 : {
23
+ consensusTimeoutProportion: opts.flowControlConsensusTimeoutProportion,
24
+ setTimeoutFn: this.#setTimeout,
25
+ clearTimeoutFn: this.#clearTimeout
26
+ };
27
+ this.#flowControlSlowSubscriberGracePeriodMs = opts.flowControlSlowSubscriberGracePeriodMs;
18
28
  getOrCreateGauge("replication", "flow_control.active_subscribers", "Active change-stream subscribers receiving live changes.").addCallback((result) => result.observe(this.#active.size));
19
29
  getOrCreateGauge("replication", "flow_control.queued_subscribers", "Change-stream subscribers waiting for the current transaction to finish before activation.").addCallback((result) => result.observe(this.#queued.size));
20
30
  getOrCreateGauge("replication", "flow_control.pending_messages", "Downstream change-stream messages not yet acked by subscribers.").addCallback((result) => result.observe(this.#subscriberStats().pendingMessages));
@@ -30,14 +40,34 @@ var Forwarder = class {
30
40
  }
31
41
  startProgressMonitor() {
32
42
  clearInterval(this.#progressMonitor);
33
- this.#progressMonitor = setInterval(this.#trackProgress, 1e3);
43
+ this.#progressMonitor = setInterval(() => this.checkSubscriberProgress(performance.now()), 1e3);
44
+ }
45
+ /**
46
+ * Runs one progress-monitor tick: samples each active subscriber's change
47
+ * rate, logs slow broadcasts, and disconnects subscribers that have been
48
+ * continuously lagging for longer than the configured grace period.
49
+ *
50
+ * Invoked on an interval by {@link startProgressMonitor()}; exposed (rather
51
+ * than private) so tests can drive a single tick with an explicit `now`.
52
+ */
53
+ checkSubscriberProgress(now) {
54
+ let timedOut;
55
+ let slowestOnTime;
56
+ for (const sub of this.#active) {
57
+ const stats = sub.sampleProcessRate(now).getStats();
58
+ if (stats.missedLastTimeout) (timedOut ??= /* @__PURE__ */ new Map()).set(sub, stats);
59
+ else if (stats.processRate <= (slowestOnTime?.processRate ?? Infinity)) slowestOnTime = stats;
60
+ }
61
+ this.#currentBroadcast?.logSlowRequests(now);
62
+ if (this.#flowControlSlowSubscriberGracePeriodMs && timedOut?.size && slowestOnTime) for (const [sub, stats] of timedOut.entries()) {
63
+ const rate = stats.processRate < slowestOnTime.processRate ? "lagging" : "catching-up";
64
+ const laggingDuration = sub.reportChangeRate(now, rate);
65
+ if (laggingDuration >= this.#flowControlSlowSubscriberGracePeriodMs) {
66
+ this.#lc.warn?.(`subscriber ${sub.id} has lagged for ${laggingDuration}ms. disconnecting`, { stats });
67
+ sub.close(3, `lagging for over ${this.#flowControlSlowSubscriberGracePeriodMs}ms (${laggingDuration}ms)`);
68
+ }
69
+ }
34
70
  }
35
- #trackProgress = () => {
36
- const now = performance.now();
37
- for (const sub of this.#active) sub.sampleProcessRate(now);
38
- const { flowControlConsensusPaddingSeconds } = this.#progressMonitorOptions;
39
- if (flowControlConsensusPaddingSeconds >= 0) this.#currentBroadcast?.checkProgress(this.#lc, flowControlConsensusPaddingSeconds * 1e3, now);
40
- };
41
71
  stopProgressMonitor() {
42
72
  clearInterval(this.#progressMonitor);
43
73
  }
@@ -77,7 +107,7 @@ var Forwarder = class {
77
107
  */
78
108
  async forwardWithFlowControl(entry) {
79
109
  const start = performance.now();
80
- const broadcast = new Broadcast(this.#active.values(), entry);
110
+ const broadcast = new Broadcast(this.#lc, this.#active.values(), entry, this.#earlyReleaseOptions);
81
111
  this.#updateActiveSubscribers(entry[1]);
82
112
  this.#currentBroadcast = broadcast;
83
113
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"forwarder.js","names":["#lc","#progressMonitorOptions","#active","#queued","#flowControlWaits","#flowControlWaitTime","#subscriberStats","#progressMonitor","#trackProgress","#currentBroadcast","#inTransaction","#updateActiveSubscribers"],"sources":["../../../../../../zero-cache/src/services/change-streamer/forwarder.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {joinIterables, wrapIterable} from '../../../../shared/src/iterables.ts';\nimport {\n getOrCreateCounter,\n getOrCreateGauge,\n getOrCreateLatencyHistogram,\n} from '../../observability/metrics.ts';\nimport {Broadcast} from './broadcast.ts';\nimport type {ChangeTag, WatermarkedChange} from './change-streamer-service.ts';\nimport type {Status} from './change-streamer.ts';\nimport type {Subscriber} from './subscriber.ts';\n\nexport type ProgressMonitorOptions = {\n flowControlConsensusPaddingSeconds: number;\n};\n\nexport class Forwarder {\n readonly #lc: LogContext;\n readonly #progressMonitorOptions: ProgressMonitorOptions;\n readonly #active = new Set<Subscriber>();\n readonly #queued = new Set<Subscriber>();\n readonly #flowControlWaits = getOrCreateCounter(\n 'replication',\n 'flow_control.waits',\n 'Flow-control checkpoints completed, labeled by release mode.',\n );\n readonly #flowControlWaitTime = getOrCreateLatencyHistogram(\n 'replication',\n 'flow_control.wait_duration',\n 'Time replication waits at flow-control checkpoints.',\n );\n #inTransaction = false;\n\n #currentBroadcast: Broadcast | undefined;\n #progressMonitor: NodeJS.Timeout | undefined;\n\n constructor(\n lc: LogContext,\n opts: ProgressMonitorOptions = {flowControlConsensusPaddingSeconds: 1},\n ) {\n this.#lc = lc.withContext('component', 'progress-monitor');\n this.#progressMonitorOptions = opts;\n\n getOrCreateGauge(\n 'replication',\n 'flow_control.active_subscribers',\n 'Active change-stream subscribers receiving live changes.',\n ).addCallback(result => result.observe(this.#active.size));\n\n getOrCreateGauge(\n 'replication',\n 'flow_control.queued_subscribers',\n 'Change-stream subscribers waiting for the current transaction to finish before activation.',\n ).addCallback(result => result.observe(this.#queued.size));\n\n getOrCreateGauge(\n 'replication',\n 'flow_control.pending_messages',\n 'Downstream change-stream messages not yet acked by subscribers.',\n ).addCallback(result =>\n result.observe(this.#subscriberStats().pendingMessages),\n );\n\n getOrCreateGauge(\n 'replication',\n 'flow_control.backlog_messages',\n 'Live change-stream messages buffered while subscribers catch up.',\n ).addCallback(result =>\n result.observe(this.#subscriberStats().backlogMessages),\n );\n\n getOrCreateGauge('replication', 'flow_control.backlog_bytes', {\n description:\n 'Live change-stream bytes buffered while subscribers catch up.',\n unit: 'By',\n }).addCallback(result =>\n result.observe(this.#subscriberStats().backlogBytes),\n );\n\n getOrCreateGauge('replication', 'flow_control.max_backlog_bytes', {\n description:\n 'Maximum live change-stream bytes buffered by a single subscriber.',\n unit: 'By',\n }).addCallback(result =>\n result.observe(this.#subscriberStats().maxBacklogBytes),\n );\n }\n\n startProgressMonitor() {\n clearInterval(this.#progressMonitor);\n this.#progressMonitor = setInterval(this.#trackProgress, 1000);\n }\n\n readonly #trackProgress = () => {\n const now = performance.now();\n for (const sub of this.#active) {\n sub.sampleProcessRate(now);\n }\n\n const {flowControlConsensusPaddingSeconds} = this.#progressMonitorOptions;\n // A negative number disables early flow control release.\n if (flowControlConsensusPaddingSeconds >= 0) {\n this.#currentBroadcast?.checkProgress(\n this.#lc,\n flowControlConsensusPaddingSeconds * 1000,\n now,\n );\n }\n };\n\n stopProgressMonitor() {\n clearInterval(this.#progressMonitor);\n }\n\n /**\n * `add()` is called in lock step with `Storer.catchup()` so that the\n * two components have an equivalent interpretation of whether a Transaction is\n * currently being streamed.\n */\n add(sub: Subscriber) {\n if (this.#inTransaction) {\n this.#queued.add(sub);\n } else {\n this.#active.add(sub);\n }\n }\n\n remove(sub: Subscriber) {\n this.#active.delete(sub);\n this.#queued.delete(sub);\n sub.close();\n }\n\n /**\n * `forward()` is called in lockstep with `Storer.store()` so that the\n * two components have an equivalent interpretation of whether a Transaction is\n * currently being streamed.\n *\n * This version of forward is fire-and-forget, with no flow control. The\n * change-streamer should call and await {@link forwardWithFlowControl()}\n * occasionally to avoid memory blowup.\n */\n forward(entry: WatermarkedChange) {\n Broadcast.withoutTracking(this.#active.values(), entry);\n this.#updateActiveSubscribers(entry[1]);\n }\n\n sendStatus(status: Status) {\n for (const sub of this.#active.values()) {\n sub.sendStatus(status);\n }\n }\n\n /**\n * The flow-control-aware equivalent of {@link forward()}, returning a\n * Promise that resolves when replication should continue.\n */\n async forwardWithFlowControl(entry: WatermarkedChange) {\n const start = performance.now();\n const broadcast = new Broadcast(this.#active.values(), entry);\n this.#updateActiveSubscribers(entry[1]);\n\n // set for progress tracking\n this.#currentBroadcast = broadcast;\n\n try {\n await broadcast.done;\n const attributes = {'release.mode': broadcast.releaseMode};\n this.#flowControlWaits.add(1, attributes);\n this.#flowControlWaitTime.recordMs(performance.now() - start, attributes);\n } finally {\n // Technically #currentBroadcast may have changed, so only\n // unset if it if is still the same.\n if (this.#currentBroadcast === broadcast) {\n this.#currentBroadcast = undefined;\n }\n }\n }\n\n #subscriberStats() {\n let pendingMessages = 0;\n let backlogMessages = 0;\n let backlogBytes = 0;\n let maxBacklogBytes = 0;\n\n for (const sub of joinIterables(this.#active, this.#queued)) {\n const stats = sub.getStats();\n pendingMessages += stats.pending;\n backlogMessages += stats.backlog;\n backlogBytes += stats.backlogBytes;\n maxBacklogBytes = Math.max(maxBacklogBytes, stats.backlogBytes);\n }\n\n return {pendingMessages, backlogMessages, backlogBytes, maxBacklogBytes};\n }\n\n #updateActiveSubscribers(tag: ChangeTag) {\n switch (tag) {\n case 'begin':\n // While in a Transaction, all added subscribers are \"queued\" so that no\n // messages are forwarded to them. This state corresponds to being queued\n // for catchup in the Storer, which will retrieve historic changes\n // and call catchup() once the current transaction is committed.\n this.#inTransaction = true;\n break;\n case 'commit':\n case 'rollback':\n // Upon commit or rollback, all queued subscribers are transferred to\n // the active set. This means that they can receive messages starting\n // from the next transaction.\n //\n // Note that if catchup is still in progress (in the Storer), these messages\n // will be buffered in the backlog until catchup completes.\n this.#inTransaction = false;\n for (const sub of this.#queued.values()) {\n this.#active.add(sub);\n }\n this.#queued.clear();\n break;\n }\n }\n\n getAcks(): Set<string> {\n return new Set(\n joinIterables(\n wrapIterable(this.#active).map(s => s.acked),\n wrapIterable(this.#queued).map(s => s.acked),\n ),\n );\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"forwarder.js","names":["#lc","#setTimeout","#clearTimeout","#active","#queued","#earlyReleaseOptions","#flowControlSlowSubscriberGracePeriodMs","#flowControlWaits","#flowControlWaitTime","#subscriberStats","#progressMonitor","#currentBroadcast","#inTransaction","#updateActiveSubscribers"],"sources":["../../../../../../zero-cache/src/services/change-streamer/forwarder.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {joinIterables, wrapIterable} from '../../../../shared/src/iterables.ts';\nimport {\n getOrCreateCounter,\n getOrCreateGauge,\n getOrCreateLatencyHistogram,\n} from '../../observability/metrics.ts';\nimport {Broadcast, type EarlyReleaseOptions} from './broadcast.ts';\nimport type {ChangeTag, WatermarkedChange} from './change-streamer-service.ts';\nimport type {Status} from './change-streamer.ts';\nimport {StreamTooFarBehind} from './error-type-enum.ts';\nimport type {Subscriber, SubscriberStats} from './subscriber.ts';\n\nexport type ProgressMonitorOptions = {\n flowControlConsensusTimeoutProportion: number;\n flowControlSlowSubscriberGracePeriodMs?: number | undefined;\n // Injectable timers, for deterministic testing.\n setTimeoutFn?: typeof setTimeout | undefined;\n clearTimeoutFn?: typeof clearTimeout | undefined;\n};\n\nexport class Forwarder {\n readonly #lc: LogContext;\n readonly #setTimeout: typeof setTimeout;\n readonly #clearTimeout: typeof clearTimeout;\n readonly #active = new Set<Subscriber>();\n readonly #queued = new Set<Subscriber>();\n readonly #earlyReleaseOptions: EarlyReleaseOptions | undefined;\n readonly #flowControlSlowSubscriberGracePeriodMs: number | undefined;\n\n readonly #flowControlWaits = getOrCreateCounter(\n 'replication',\n 'flow_control.waits',\n 'Flow-control checkpoints completed, labeled by release mode.',\n );\n readonly #flowControlWaitTime = getOrCreateLatencyHistogram(\n 'replication',\n 'flow_control.wait_duration',\n 'Time replication waits at flow-control checkpoints.',\n );\n #inTransaction = false;\n\n #currentBroadcast: Broadcast | undefined;\n #progressMonitor: NodeJS.Timeout | undefined;\n\n constructor(\n lc: LogContext,\n opts: ProgressMonitorOptions = {\n flowControlConsensusTimeoutProportion: 2.0,\n },\n ) {\n this.#lc = lc.withContext('component', 'subscriber-progress-monitor');\n this.#setTimeout = opts.setTimeoutFn ?? setTimeout;\n this.#clearTimeout = opts.clearTimeoutFn ?? clearTimeout;\n\n this.#earlyReleaseOptions =\n opts.flowControlConsensusTimeoutProportion < 0\n ? undefined\n : {\n consensusTimeoutProportion:\n opts.flowControlConsensusTimeoutProportion,\n setTimeoutFn: this.#setTimeout,\n clearTimeoutFn: this.#clearTimeout,\n };\n this.#flowControlSlowSubscriberGracePeriodMs =\n opts.flowControlSlowSubscriberGracePeriodMs;\n\n getOrCreateGauge(\n 'replication',\n 'flow_control.active_subscribers',\n 'Active change-stream subscribers receiving live changes.',\n ).addCallback(result => result.observe(this.#active.size));\n\n getOrCreateGauge(\n 'replication',\n 'flow_control.queued_subscribers',\n 'Change-stream subscribers waiting for the current transaction to finish before activation.',\n ).addCallback(result => result.observe(this.#queued.size));\n\n getOrCreateGauge(\n 'replication',\n 'flow_control.pending_messages',\n 'Downstream change-stream messages not yet acked by subscribers.',\n ).addCallback(result =>\n result.observe(this.#subscriberStats().pendingMessages),\n );\n\n getOrCreateGauge(\n 'replication',\n 'flow_control.backlog_messages',\n 'Live change-stream messages buffered while subscribers catch up.',\n ).addCallback(result =>\n result.observe(this.#subscriberStats().backlogMessages),\n );\n\n getOrCreateGauge('replication', 'flow_control.backlog_bytes', {\n description:\n 'Live change-stream bytes buffered while subscribers catch up.',\n unit: 'By',\n }).addCallback(result =>\n result.observe(this.#subscriberStats().backlogBytes),\n );\n\n getOrCreateGauge('replication', 'flow_control.max_backlog_bytes', {\n description:\n 'Maximum live change-stream bytes buffered by a single subscriber.',\n unit: 'By',\n }).addCallback(result =>\n result.observe(this.#subscriberStats().maxBacklogBytes),\n );\n }\n\n startProgressMonitor() {\n clearInterval(this.#progressMonitor);\n this.#progressMonitor = setInterval(\n () => this.checkSubscriberProgress(performance.now()),\n 1000,\n );\n }\n\n /**\n * Runs one progress-monitor tick: samples each active subscriber's change\n * rate, logs slow broadcasts, and disconnects subscribers that have been\n * continuously lagging for longer than the configured grace period.\n *\n * Invoked on an interval by {@link startProgressMonitor()}; exposed (rather\n * than private) so tests can drive a single tick with an explicit `now`.\n */\n checkSubscriberProgress(now: number) {\n let timedOut: Map<Subscriber, SubscriberStats> | undefined;\n let slowestOnTime: SubscriberStats | undefined;\n\n for (const sub of this.#active) {\n const stats = sub.sampleProcessRate(now).getStats();\n if (stats.missedLastTimeout) {\n (timedOut ??= new Map()).set(sub, stats);\n } else if (\n stats.processRate <= (slowestOnTime?.processRate ?? Infinity)\n ) {\n slowestOnTime = stats;\n }\n }\n this.#currentBroadcast?.logSlowRequests(now);\n\n if (\n this.#flowControlSlowSubscriberGracePeriodMs &&\n timedOut?.size &&\n slowestOnTime\n ) {\n for (const [sub, stats] of timedOut.entries()) {\n const rate =\n stats.processRate < slowestOnTime.processRate\n ? 'lagging'\n : 'catching-up';\n const laggingDuration = sub.reportChangeRate(now, rate);\n if (laggingDuration >= this.#flowControlSlowSubscriberGracePeriodMs) {\n this.#lc.warn?.(\n `subscriber ${sub.id} has lagged for ${laggingDuration}ms. disconnecting`,\n {stats},\n );\n sub.close(\n StreamTooFarBehind,\n `lagging for over ${this.#flowControlSlowSubscriberGracePeriodMs}ms (${laggingDuration}ms)`,\n );\n }\n }\n }\n }\n\n stopProgressMonitor() {\n clearInterval(this.#progressMonitor);\n }\n\n /**\n * `add()` is called in lock step with `Storer.catchup()` so that the\n * two components have an equivalent interpretation of whether a Transaction is\n * currently being streamed.\n */\n add(sub: Subscriber) {\n if (this.#inTransaction) {\n this.#queued.add(sub);\n } else {\n this.#active.add(sub);\n }\n }\n\n remove(sub: Subscriber) {\n this.#active.delete(sub);\n this.#queued.delete(sub);\n sub.close();\n }\n\n /**\n * `forward()` is called in lockstep with `Storer.store()` so that the\n * two components have an equivalent interpretation of whether a Transaction is\n * currently being streamed.\n *\n * This version of forward is fire-and-forget, with no flow control. The\n * change-streamer should call and await {@link forwardWithFlowControl()}\n * occasionally to avoid memory blowup.\n */\n forward(entry: WatermarkedChange) {\n Broadcast.withoutTracking(this.#active.values(), entry);\n this.#updateActiveSubscribers(entry[1]);\n }\n\n sendStatus(status: Status) {\n for (const sub of this.#active.values()) {\n sub.sendStatus(status);\n }\n }\n\n /**\n * The flow-control-aware equivalent of {@link forward()}, returning a\n * Promise that resolves when replication should continue.\n */\n async forwardWithFlowControl(entry: WatermarkedChange) {\n const start = performance.now();\n const broadcast = new Broadcast(\n this.#lc,\n this.#active.values(),\n entry,\n this.#earlyReleaseOptions,\n );\n this.#updateActiveSubscribers(entry[1]);\n\n // set for progress tracking\n this.#currentBroadcast = broadcast;\n\n try {\n await broadcast.done;\n const attributes = {'release.mode': broadcast.releaseMode};\n this.#flowControlWaits.add(1, attributes);\n this.#flowControlWaitTime.recordMs(performance.now() - start, attributes);\n } finally {\n // Technically #currentBroadcast may have changed, so only\n // unset if it if is still the same.\n if (this.#currentBroadcast === broadcast) {\n this.#currentBroadcast = undefined;\n }\n }\n }\n\n #subscriberStats() {\n let pendingMessages = 0;\n let backlogMessages = 0;\n let backlogBytes = 0;\n let maxBacklogBytes = 0;\n\n for (const sub of joinIterables(this.#active, this.#queued)) {\n const stats = sub.getStats();\n pendingMessages += stats.pending;\n backlogMessages += stats.backlog;\n backlogBytes += stats.backlogBytes;\n maxBacklogBytes = Math.max(maxBacklogBytes, stats.backlogBytes);\n }\n\n return {pendingMessages, backlogMessages, backlogBytes, maxBacklogBytes};\n }\n\n #updateActiveSubscribers(tag: ChangeTag) {\n switch (tag) {\n case 'begin':\n // While in a Transaction, all added subscribers are \"queued\" so that no\n // messages are forwarded to them. This state corresponds to being queued\n // for catchup in the Storer, which will retrieve historic changes\n // and call catchup() once the current transaction is committed.\n this.#inTransaction = true;\n break;\n case 'commit':\n case 'rollback':\n // Upon commit or rollback, all queued subscribers are transferred to\n // the active set. This means that they can receive messages starting\n // from the next transaction.\n //\n // Note that if catchup is still in progress (in the Storer), these messages\n // will be buffered in the backlog until catchup completes.\n this.#inTransaction = false;\n for (const sub of this.#queued.values()) {\n this.#active.add(sub);\n }\n this.#queued.clear();\n break;\n }\n }\n\n getAcks(): Set<string> {\n return new Set(\n joinIterables(\n wrapIterable(this.#active).map(s => s.acked),\n wrapIterable(this.#queued).map(s => s.acked),\n ),\n );\n }\n}\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import type { LogContext } from '@rocicorp/logger';
2
+ import { type JSONObject } from '../../../../shared/src/bigint-json.ts';
2
3
  import { TransactionPool } from '../../db/transaction-pool.ts';
3
4
  import { type PostgresDB } from '../../types/pg.ts';
4
5
  import { type ShardID } from '../../types/shards.ts';
@@ -100,4 +101,37 @@ export declare class PurgeLocker {
100
101
  constructor(lc: LogContext, shard: ShardID, db: PostgresDB);
101
102
  acquire(): Promise<PurgeLock | null>;
102
103
  }
104
+ type TaskKey = JSONObject;
105
+ type TaskDoneFn = () => void;
106
+ /**
107
+ * Periodic monitor for detecting the pathological condition in which a db
108
+ * query hangs forever because of a connection problem, such as a half-closed
109
+ * socket.
110
+ *
111
+ * Use this for db calls that recur or accumulate progress within a single
112
+ * logical operation on the main storer loop (e.g. the queue-processing loop,
113
+ * or a multi-batch catchup read), where "no progress for N ms" is the
114
+ * meaningful signal. For a one-off db call -- including ones made before
115
+ * {@link Storer.run} starts this monitor's polling -- use a plain timeout
116
+ * (see `Storer.#withTimeout`) instead.
117
+ *
118
+ * The failure handler should stop the server. The ProgressMonitor will
119
+ * assume this and stop itself after calling it.
120
+ *
121
+ * Internal to the Storer, but exported for testing.
122
+ */
123
+ export declare class ProgressMonitor {
124
+ #private;
125
+ constructor(lc: LogContext, failureThresholdMs: number, onFailure: (err: Error) => void);
126
+ start(): void;
127
+ checkTaskProgress(now: Date): void;
128
+ /**
129
+ * Starts a task to be tracked.
130
+ *
131
+ * @return a callback to invoke when the task is done.
132
+ */
133
+ trackTask(task: TaskKey, now?: Date): TaskDoneFn;
134
+ stop(): void;
135
+ }
136
+ export {};
103
137
  //# sourceMappingURL=storer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"storer.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/storer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"storer.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/storer.ts"],"names":[],"mappings":"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"}
@@ -66,6 +66,7 @@ var Storer = class {
66
66
  #statementTimeoutMs;
67
67
  #changeLogBatchSize;
68
68
  #drainTimeoutMs;
69
+ #progressMonitor;
69
70
  #approximateQueuedBytes = 0;
70
71
  #running = false;
71
72
  constructor(lc, shard, taskID, discoveryAddress, discoveryProtocol, db, replicaVersion, onConsumed, onFatal, { backPressureLimitHeapProportion, statementTimeoutMs, changeLogBatchSize, drainTimeoutMs = 3e4 }) {
@@ -81,6 +82,7 @@ var Storer = class {
81
82
  this.#statementTimeoutMs = statementTimeoutMs;
82
83
  this.#changeLogBatchSize = Math.max(1, changeLogBatchSize);
83
84
  this.#drainTimeoutMs = drainTimeoutMs;
85
+ this.#progressMonitor = new ProgressMonitor(lc, statementTimeoutMs, onFatal);
84
86
  const heapStats = getHeapStatistics();
85
87
  this.#backPressureThresholdBytes = (heapStats.heap_size_limit - heapStats.used_heap_size) * backPressureLimitHeapProportion;
86
88
  this.#lc.info?.(`Using up to ${(this.#backPressureThresholdBytes / 1024 ** 2).toFixed(2)} MB of --max-old-space-size (~${(heapStats.heap_size_limit / 1024 ** 2).toFixed(2)} MB) to absorb upstream spikes`, { heapStats });
@@ -88,6 +90,28 @@ var Storer = class {
88
90
  #cdc(table) {
89
91
  return this.#db(`${cdcSchema(this.#shard)}.${table}`);
90
92
  }
93
+ /**
94
+ * Bounds a one-off db call (i.e. not part of the main storer loop or the
95
+ * background catchup read, which are tracked by the ProgressMonitor
96
+ * instead) with a plain timeout. This covers calls like
97
+ * {@link assumeOwnership} and {@link getStartStreamInitializationParameters}
98
+ * that are made by the caller *before* {@link run()} -- and thus before the
99
+ * ProgressMonitor's polling starts -- as well as ones made well after,
100
+ * where a continuously-polling watchdog would be overkill for a single
101
+ * bounded db round trip.
102
+ *
103
+ * Does not cancel `promise` on timeout (there is no cancellation mechanism
104
+ * for a postgres.js query); it just stops waiting for it, and swallows a
105
+ * later rejection so it doesn't surface as an unhandled rejection.
106
+ */
107
+ async #withTimeout(name, promise) {
108
+ const result = await orTimeout(promise, this.#statementTimeoutMs);
109
+ if (result === "timed-out") {
110
+ promise.catch(() => {});
111
+ throw new AbortError(`${name} did not complete within ${this.#statementTimeoutMs}ms`);
112
+ }
113
+ return result;
114
+ }
91
115
  async assumeOwnership(purgeLock) {
92
116
  const db = this.#db;
93
117
  const owner = this.#taskID;
@@ -96,16 +120,16 @@ var Storer = class {
96
120
  const addressWithProtocol = ownerProtocol === "ws" ? ownerAddress : `${ownerProtocol}://${ownerAddress}`;
97
121
  this.#lc.info?.(`assuming ownership at ${addressWithProtocol}`);
98
122
  const start = performance.now();
99
- await db`UPDATE ${this.#cdc("replicationState")} SET ${db({
123
+ await this.#withTimeout("assume-ownership", db`UPDATE ${this.#cdc("replicationState")} SET ${db({
100
124
  owner,
101
125
  ownerAddress: addressWithProtocol
102
- })}`;
126
+ })}`);
103
127
  const elapsed = (performance.now() - start).toFixed(2);
104
128
  this.#lc.info?.(`assumed ownership at ${addressWithProtocol} (${elapsed} ms)`);
105
129
  if (purgeLock) purgeLock.release();
106
130
  }
107
131
  async getStartStreamInitializationParameters() {
108
- const [[{ lastWatermark }], result] = await runTx(this.#db, (sql) => [sql`
132
+ const [[{ lastWatermark }], result] = await this.#withTimeout("get-stream-params", runTx(this.#db, (sql) => [sql`
109
133
  SELECT "lastWatermark" FROM ${this.#cdc("replicationState")}`, sql`
110
134
  SELECT
111
135
  json_build_object(
@@ -119,15 +143,15 @@ var Storer = class {
119
143
  LEFT JOIN ${this.#cdc("tableMetadata")} as t
120
144
  ON (b."schema" = t."schema" AND b."table" = t."table")
121
145
  GROUP BY b."schema", b."table", t."metadata"
122
- `], { mode: READONLY });
146
+ `], { mode: READONLY }));
123
147
  return {
124
148
  lastWatermark,
125
149
  backfillRequests: parse(result, backfillRequestsSchema)
126
150
  };
127
151
  }
128
152
  async getMinWatermarkForCatchup() {
129
- const [{ minWatermark }] = await this.#db`
130
- SELECT min(watermark) as "minWatermark" FROM ${this.#cdc("changeLog")}`;
153
+ const [{ minWatermark }] = await this.#withTimeout("get-min-watermark", this.#db`
154
+ SELECT min(watermark) as "minWatermark" FROM ${this.#cdc("changeLog")}`);
131
155
  return minWatermark;
132
156
  }
133
157
  purgeRecordsBefore(watermark) {
@@ -186,30 +210,22 @@ var Storer = class {
186
210
  }]);
187
211
  }
188
212
  #readyForMore = null;
189
- #backpressureTimeout;
190
213
  readyForMore() {
191
214
  if (!this.#running) return;
192
215
  if (this.#readyForMore === null && this.#approximateQueuedBytes > this.#backPressureThresholdBytes) {
193
216
  this.#lc.warn?.(`applying back pressure with ${this.#queue.size()} queued changes (~${(this.#approximateQueuedBytes / 1024 ** 2).toFixed(2)} MB)\n\nTo inspect changeLog backlog in your change DB:\n SELECT\n (change->'relation'->>'schema') || '.' || (change->'relation'->>'name') AS table_name,\n change->>'tag' AS operation,\n COUNT(*) AS count\n FROM "<app_id>/cdc"."changeLog"\n GROUP BY 1, 2\n ORDER BY 3 DESC\n LIMIT 20;`);
194
217
  this.#readyForMore = resolver();
195
- this.#resetBackpressureTimeout(true);
196
218
  }
197
219
  return this.#readyForMore?.promise;
198
220
  }
199
- #resetBackpressureTimeout(reschedule) {
200
- clearTimeout(this.#backpressureTimeout);
201
- this.#backpressureTimeout = reschedule ? setTimeout(() => {
202
- this.#readyForMore?.reject(new AbortError(`No statement processed within the last ${this.#statementTimeoutMs}ms. Considering the change-log to be wedged.`));
203
- this.#readyForMore = null;
204
- }, this.#statementTimeoutMs) : void 0;
205
- }
206
221
  #maybeReleaseBackPressure() {
207
- if (this.#readyForMore !== null) if (this.#approximateQueuedBytes < this.#backPressureThresholdBytes * .8) {
208
- this.#lc.info?.(`releasing back pressure with ${this.#queue.size()} queued changes (~${(this.#approximateQueuedBytes / 1024 ** 2).toFixed(2)} MB)`);
209
- this.#readyForMore.resolve();
210
- this.#readyForMore = null;
211
- this.#resetBackpressureTimeout(false);
212
- } else this.#resetBackpressureTimeout(true);
222
+ if (this.#readyForMore !== null) {
223
+ if (this.#approximateQueuedBytes < this.#backPressureThresholdBytes * .8) {
224
+ this.#lc.info?.(`releasing back pressure with ${this.#queue.size()} queued changes (~${(this.#approximateQueuedBytes / 1024 ** 2).toFixed(2)} MB)`);
225
+ this.#readyForMore.resolve();
226
+ this.#readyForMore = null;
227
+ }
228
+ }
213
229
  }
214
230
  /**
215
231
  * Flushes any buffered {@link PendingTransaction.batch} rows to the changeLog.
@@ -248,6 +264,7 @@ var Storer = class {
248
264
  this.#running = true;
249
265
  this.#stopped = stopped;
250
266
  this.#lc.info?.("starting storer");
267
+ this.#progressMonitor.start();
251
268
  let err;
252
269
  try {
253
270
  await this.#processQueue();
@@ -259,7 +276,6 @@ var Storer = class {
259
276
  this.#readyForMore.resolve();
260
277
  this.#readyForMore = null;
261
278
  }
262
- this.#resetBackpressureTimeout(false);
263
279
  this.#cancelQueueEntries(this.#queue.drain().filter((entry) => entry !== void 0), err);
264
280
  this.#running = false;
265
281
  signalStopped();
@@ -285,9 +301,20 @@ var Storer = class {
285
301
  async #processQueue() {
286
302
  let tx = null;
287
303
  let msg;
304
+ let lastTaskDone;
305
+ const nextMessage = async () => {
306
+ lastTaskDone?.();
307
+ lastTaskDone = void 0;
308
+ const entry = await this.#queue.dequeue();
309
+ if (entry !== "stop") lastTaskDone = this.#progressMonitor.trackTask({
310
+ task: "queue-entry",
311
+ type: entry[0]
312
+ });
313
+ return entry;
314
+ };
288
315
  const catchupQueue = [];
289
316
  try {
290
- while ((msg = await this.#queue.dequeue()) !== "stop") {
317
+ while ((msg = await nextMessage()) !== "stop") {
291
318
  const [msgType] = msg;
292
319
  switch (msgType) {
293
320
  case "ready": {
@@ -320,10 +347,7 @@ var Storer = class {
320
347
  const { promise, resolve, reject } = resolver();
321
348
  promise.catch(() => {});
322
349
  tx = {
323
- pool: new TransactionPool(this.#lc.withContext("watermark", watermark), {
324
- mode: READ_COMMITTED,
325
- statementResponseTimeout: this.#statementTimeoutMs
326
- }),
350
+ pool: new TransactionPool(this.#lc.withContext("watermark", watermark), { mode: READ_COMMITTED }),
327
351
  preCommitWatermark: watermark,
328
352
  pos: 0,
329
353
  startingReplicationState: promise,
@@ -394,6 +418,10 @@ var Storer = class {
394
418
  const reader = new TransactionPool(this.#lc.withContext("pool", "catchup"), { mode: READONLY });
395
419
  reader.run(this.#db);
396
420
  let lastWatermark;
421
+ const catchupSnapshotted = this.#progressMonitor.trackTask({
422
+ task: "capture-catchup-snapshot",
423
+ subscribers: subs.map(({ subscriber: s }) => s.id)
424
+ });
397
425
  try {
398
426
  [{lastWatermark}] = await reader.processReadTask((sql) => sql`
399
427
  SELECT "lastWatermark" FROM ${this.#cdc("replicationState")}
@@ -401,10 +429,18 @@ var Storer = class {
401
429
  } catch (e) {
402
430
  subs.map(({ subscriber }) => subscriber.fail(e));
403
431
  throw e;
432
+ } finally {
433
+ catchupSnapshotted();
404
434
  }
405
435
  Promise.all(subs.map((sub) => this.#catchup(sub, lastWatermark, reader))).finally(() => reader.setDone());
406
436
  }
407
437
  async #catchup({ subscriber: sub, mode }, lastWatermark, reader) {
438
+ let entriesReceived = 0;
439
+ let catchupProgressed = this.#progressMonitor.trackTask({
440
+ task: "catchup",
441
+ subscriber: sub.id,
442
+ entriesReceived
443
+ });
408
444
  try {
409
445
  await reader.processReadTask(async (tx) => {
410
446
  const start = Date.now();
@@ -416,21 +452,32 @@ var Storer = class {
416
452
  WHERE watermark >= ${sub.watermark}
417
453
  AND watermark <= ${lastWatermark}
418
454
  ORDER BY watermark, pos`.cursor(2e3)) {
419
- const start = performance.now();
420
- await lastBatchConsumed;
421
- const elapsed = performance.now() - start;
422
- if (lastBatchConsumed) this.#lc[elapsed > 100 ? "info" : "debug"]?.(`waited ${elapsed.toFixed(3)} ms for ${sub.id} to consume last batch of catchup entries`);
423
- for (const entry of entries) if (entry.watermark === sub.watermark) watermarkFound = true;
424
- else if (watermarkFound) {
425
- lastBatchConsumed = sub.catchup(toDownstream(entry));
426
- count++;
427
- } else if (mode === "backup") throw new AutoResetSignal(`backup replica at watermark ${sub.watermark} is behind change db: ${entry.watermark})`);
428
- else {
429
- this.#lc.warn?.(`rejecting subscriber at watermark ${sub.watermark} (earliest watermark: ${entry.watermark})`);
430
- sub.close(2, `earliest supported watermark is ${entry.watermark} (requested ${sub.watermark})`);
431
- return;
455
+ catchupProgressed();
456
+ entriesReceived += entries.length;
457
+ try {
458
+ const start = performance.now();
459
+ await lastBatchConsumed;
460
+ const elapsed = performance.now() - start;
461
+ if (lastBatchConsumed) this.#lc[elapsed > 100 ? "info" : "debug"]?.(`waited ${elapsed.toFixed(3)} ms for ${sub.id} to consume last batch of catchup entries`);
462
+ for (const entry of entries) if (entry.watermark === sub.watermark) watermarkFound = true;
463
+ else if (watermarkFound) {
464
+ lastBatchConsumed = sub.catchup(toDownstream(entry));
465
+ count++;
466
+ } else if (mode === "backup") throw new AutoResetSignal(`backup replica at watermark ${sub.watermark} is behind change db: ${entry.watermark})`);
467
+ else {
468
+ this.#lc.warn?.(`rejecting subscriber at watermark ${sub.watermark} (earliest watermark: ${entry.watermark})`);
469
+ sub.close(2, `earliest supported watermark is ${entry.watermark} (requested ${sub.watermark})`);
470
+ return;
471
+ }
472
+ } finally {
473
+ catchupProgressed = this.#progressMonitor.trackTask({
474
+ task: "catchup",
475
+ subscriber: sub.id,
476
+ entriesReceived
477
+ });
432
478
  }
433
479
  }
480
+ catchupProgressed();
434
481
  if (watermarkFound) {
435
482
  await lastBatchConsumed;
436
483
  this.#lc.info?.(`caught up ${sub.id} with ${count} changes (${Date.now() - start} ms)`);
@@ -444,6 +491,8 @@ var Storer = class {
444
491
  this.#onFatal(err);
445
492
  }
446
493
  sub.fail(err);
494
+ } finally {
495
+ catchupProgressed();
447
496
  }
448
497
  }
449
498
  /**
@@ -554,6 +603,7 @@ var Storer = class {
554
603
  */
555
604
  async stop() {
556
605
  if (this.#running) {
606
+ this.#progressMonitor.stop();
557
607
  this.#lc.info?.(`draining ${this.#queue.size()} changeLog entries`);
558
608
  this.#queue.enqueue("stop");
559
609
  }
@@ -653,6 +703,65 @@ var PurgeLocker = class {
653
703
  return new PurgeLock(this.#lc, tx, replicaVersion, watermark);
654
704
  }
655
705
  };
706
+ /**
707
+ * Periodic monitor for detecting the pathological condition in which a db
708
+ * query hangs forever because of a connection problem, such as a half-closed
709
+ * socket.
710
+ *
711
+ * Use this for db calls that recur or accumulate progress within a single
712
+ * logical operation on the main storer loop (e.g. the queue-processing loop,
713
+ * or a multi-batch catchup read), where "no progress for N ms" is the
714
+ * meaningful signal. For a one-off db call -- including ones made before
715
+ * {@link Storer.run} starts this monitor's polling -- use a plain timeout
716
+ * (see `Storer.#withTimeout`) instead.
717
+ *
718
+ * The failure handler should stop the server. The ProgressMonitor will
719
+ * assume this and stop itself after calling it.
720
+ *
721
+ * Internal to the Storer, but exported for testing.
722
+ */
723
+ var ProgressMonitor = class {
724
+ #lc;
725
+ #failureThresholdMs;
726
+ #onFailure;
727
+ #taskUpdates = /* @__PURE__ */ new Map();
728
+ #timer;
729
+ constructor(lc, failureThresholdMs, onFailure) {
730
+ this.#lc = lc.withContext("component", "storer-progress-monitor");
731
+ this.#failureThresholdMs = failureThresholdMs;
732
+ this.#onFailure = onFailure;
733
+ }
734
+ start() {
735
+ clearInterval(this.#timer);
736
+ this.#timer = setInterval(() => this.checkTaskProgress(/* @__PURE__ */ new Date()), this.#failureThresholdMs).unref();
737
+ }
738
+ checkTaskProgress(now) {
739
+ for (const [task, lastUpdate] of this.#taskUpdates.entries()) if (now.getTime() - lastUpdate.getTime() >= this.#failureThresholdMs) {
740
+ this.#lc.error?.(`Last task update was more than ${this.#failureThresholdMs}ms ago`, {
741
+ task,
742
+ lastUpdate
743
+ });
744
+ this.#onFailure(/* @__PURE__ */ new Error(`Task failed to progress for over ${this.#failureThresholdMs}ms: ${BigIntJSON.stringify(task)}`));
745
+ this.stop();
746
+ return;
747
+ }
748
+ }
749
+ /**
750
+ * Starts a task to be tracked.
751
+ *
752
+ * @return a callback to invoke when the task is done.
753
+ */
754
+ trackTask(task, now = /* @__PURE__ */ new Date()) {
755
+ this.#taskUpdates.set(task, now);
756
+ return () => {
757
+ this.#taskUpdates.delete(task);
758
+ };
759
+ }
760
+ stop() {
761
+ clearInterval(this.#timer);
762
+ this.#taskUpdates.clear();
763
+ }
764
+ };
656
765
  //#endregion
657
766
  export { PurgeLocker, Storer };
658
767