@hile/micro 2.0.8 → 2.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.
- package/README.md +44 -14
- package/dist/application.d.ts +66 -3
- package/dist/application.js +676 -114
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +146 -36
- package/dist/server.d.ts +1 -0
- package/dist/server.js +13 -2
- package/package.json +5 -5
package/dist/application.js
CHANGED
|
@@ -35,39 +35,122 @@ function withTimeout(promise, ms, label) {
|
|
|
35
35
|
clearTimeout(timer);
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
|
+
function createPubSubPayloadSnapshot(topic, payload) {
|
|
39
|
+
const serialized = JSON.stringify({ topic, payload });
|
|
40
|
+
const parsed = JSON.parse(serialized);
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(parsed, 'payload')) {
|
|
42
|
+
return parsed.payload;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function stablePayloadSignature(value) {
|
|
47
|
+
if (value === undefined)
|
|
48
|
+
return 'undefined';
|
|
49
|
+
if (value === null)
|
|
50
|
+
return 'null';
|
|
51
|
+
const type = typeof value;
|
|
52
|
+
if (type === 'string')
|
|
53
|
+
return `string:${JSON.stringify(value)}`;
|
|
54
|
+
if (type === 'number')
|
|
55
|
+
return `number:${String(value)}`;
|
|
56
|
+
if (type === 'boolean')
|
|
57
|
+
return `boolean:${String(value)}`;
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return `array:[${value.map(stablePayloadSignature).join(',')}]`;
|
|
60
|
+
}
|
|
61
|
+
if (type === 'object') {
|
|
62
|
+
const keys = Object.keys(value).sort();
|
|
63
|
+
return `object:{${keys.map(key => `${JSON.stringify(key)}:${stablePayloadSignature(value[key])}`).join(',')}}`;
|
|
64
|
+
}
|
|
65
|
+
return `${type}:${String(value)}`;
|
|
66
|
+
}
|
|
67
|
+
const DEFAULT_CIRCUIT_BREAKER = {
|
|
68
|
+
failureThreshold: 3,
|
|
69
|
+
failureWindowMs: 60_000,
|
|
70
|
+
successThreshold: 2,
|
|
71
|
+
cooldownMs: 10_000,
|
|
72
|
+
maxCooldownMs: 120_000,
|
|
73
|
+
halfOpenMaxProbes: 1,
|
|
74
|
+
shouldRecordFailure: () => true,
|
|
75
|
+
shouldRetry: () => true,
|
|
76
|
+
};
|
|
77
|
+
function positiveIntegerOr(value, fallback) {
|
|
78
|
+
if (!Number.isFinite(value) || value === undefined || value < 1)
|
|
79
|
+
return fallback;
|
|
80
|
+
return Math.trunc(value);
|
|
81
|
+
}
|
|
82
|
+
function resolveCircuitBreakerOptions(options) {
|
|
83
|
+
const cooldownMs = positiveIntegerOr(options?.cooldownMs, DEFAULT_CIRCUIT_BREAKER.cooldownMs);
|
|
84
|
+
const configuredMaxCooldownMs = positiveIntegerOr(options?.maxCooldownMs, DEFAULT_CIRCUIT_BREAKER.maxCooldownMs);
|
|
85
|
+
return {
|
|
86
|
+
failureThreshold: positiveIntegerOr(options?.failureThreshold, DEFAULT_CIRCUIT_BREAKER.failureThreshold),
|
|
87
|
+
failureWindowMs: positiveIntegerOr(options?.failureWindowMs, DEFAULT_CIRCUIT_BREAKER.failureWindowMs),
|
|
88
|
+
successThreshold: positiveIntegerOr(options?.successThreshold, DEFAULT_CIRCUIT_BREAKER.successThreshold),
|
|
89
|
+
cooldownMs,
|
|
90
|
+
maxCooldownMs: Math.max(cooldownMs, configuredMaxCooldownMs),
|
|
91
|
+
halfOpenMaxProbes: positiveIntegerOr(options?.halfOpenMaxProbes, DEFAULT_CIRCUIT_BREAKER.halfOpenMaxProbes),
|
|
92
|
+
shouldRecordFailure: options?.shouldRecordFailure ?? DEFAULT_CIRCUIT_BREAKER.shouldRecordFailure,
|
|
93
|
+
shouldRetry: options?.shouldRetry ?? DEFAULT_CIRCUIT_BREAKER.shouldRetry,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
38
96
|
export class Application extends Server {
|
|
39
97
|
registry;
|
|
40
98
|
reconnectTimeout;
|
|
41
99
|
registryReconnectPromise;
|
|
100
|
+
registryReconnectGeneration = 0;
|
|
42
101
|
/** 为 true 时不再向 Registry 重连(listen 返回的 teardown 已触发) */
|
|
43
102
|
stopped = false;
|
|
103
|
+
listenGeneration = 0;
|
|
44
104
|
_registry_address;
|
|
45
105
|
_registryLookupTimeoutMs;
|
|
46
106
|
_requestTimeoutMs;
|
|
107
|
+
_circuitBreaker;
|
|
47
108
|
namespaces = new Map();
|
|
48
|
-
static CB_COOLDOWN_MS = 30_000;
|
|
49
109
|
circuitBreakers = new Map();
|
|
50
|
-
fallbacks = new Set();
|
|
51
110
|
topics = new Map();
|
|
111
|
+
publishedTopics = new Map();
|
|
112
|
+
publishedTopicRevisions = new Map();
|
|
113
|
+
publishedTopicDirty = new Set();
|
|
114
|
+
publishedTopicVersions = new Map();
|
|
115
|
+
publishedTopicSignatures = new Map();
|
|
116
|
+
topicSyncs = new Map();
|
|
117
|
+
topicUpdateVersions = new Map();
|
|
118
|
+
publishIntentVersion = 0;
|
|
52
119
|
constructor(props) {
|
|
53
|
-
const { namespace, registry, registryLookupTimeoutMs = 10_000, requestTimeoutMs = 30_000, ...microAndLoader } = props;
|
|
120
|
+
const { namespace, registry, registryLookupTimeoutMs = 10_000, requestTimeoutMs = 30_000, circuitBreaker, ...microAndLoader } = props;
|
|
54
121
|
super(namespace, microAndLoader);
|
|
55
122
|
assertValidRegistrySocket('registry address', registry.host, registry.port);
|
|
56
123
|
this._registry_address = registry;
|
|
57
124
|
this._registryLookupTimeoutMs = registryLookupTimeoutMs;
|
|
58
125
|
this._requestTimeoutMs = requestTimeoutMs;
|
|
59
|
-
this.
|
|
126
|
+
this._circuitBreaker = resolveCircuitBreakerOptions(circuitBreaker);
|
|
127
|
+
this.register('/-/health', async () => ({
|
|
60
128
|
status: 'ok',
|
|
61
129
|
registry: !!this.registry,
|
|
62
130
|
uptime: process.uptime(),
|
|
63
131
|
namespaces: [...this.namespaces.keys()],
|
|
64
|
-
})));
|
|
65
|
-
this.fallbacks.add(this.register('/-/topic/update', async ({ data }) => {
|
|
66
|
-
this.events.emit('topic:' + data.topic, data.payload);
|
|
67
|
-
return Date.now();
|
|
68
132
|
}));
|
|
133
|
+
this.register('/-/topic/update', async ({ data }) => {
|
|
134
|
+
this.dispatchTopicUpdate(data.topic, data.payload);
|
|
135
|
+
return Date.now();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
dispatchTopicUpdate(topic, payload) {
|
|
139
|
+
this.topicUpdateVersions.set(topic, (this.topicUpdateVersions.get(topic) ?? 0) + 1);
|
|
140
|
+
for (const listener of this.events.listeners('topic:' + topic)) {
|
|
141
|
+
try {
|
|
142
|
+
const listenerPayload = createPubSubPayloadSnapshot(topic, payload);
|
|
143
|
+
void Promise.resolve(listener(listenerPayload)).catch(err => {
|
|
144
|
+
this.logger.error(err);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
this.logger.error(err);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
69
151
|
}
|
|
70
152
|
async listen(port = 0) {
|
|
153
|
+
const generation = ++this.listenGeneration;
|
|
71
154
|
this.stopped = false;
|
|
72
155
|
const callback = await super.listen(port);
|
|
73
156
|
try {
|
|
@@ -86,10 +169,9 @@ export class Application extends Server {
|
|
|
86
169
|
// 这里也不清理 declare 和 undeclare 由业务方自己清理
|
|
87
170
|
return async () => {
|
|
88
171
|
this.stopped = true;
|
|
89
|
-
|
|
90
|
-
|
|
172
|
+
if (this.listenGeneration === generation) {
|
|
173
|
+
this.listenGeneration++;
|
|
91
174
|
}
|
|
92
|
-
this.fallbacks.clear();
|
|
93
175
|
if (this.reconnectTimeout) {
|
|
94
176
|
clearTimeout(this.reconnectTimeout);
|
|
95
177
|
this.reconnectTimeout = undefined;
|
|
@@ -99,29 +181,180 @@ export class Application extends Server {
|
|
|
99
181
|
await callback();
|
|
100
182
|
};
|
|
101
183
|
}
|
|
102
|
-
scheduleRegistryRetry() {
|
|
103
|
-
if (this.stopped)
|
|
184
|
+
scheduleRegistryRetry(generation = this.listenGeneration) {
|
|
185
|
+
if (this.stopped || this.listenGeneration !== generation)
|
|
104
186
|
return;
|
|
105
187
|
if (this.reconnectTimeout)
|
|
106
188
|
clearTimeout(this.reconnectTimeout);
|
|
107
189
|
this.reconnectTimeout = setTimeout(() => {
|
|
108
190
|
this.reconnectTimeout = undefined;
|
|
191
|
+
if (this.stopped || this.listenGeneration !== generation)
|
|
192
|
+
return;
|
|
109
193
|
this.logger.debug('[reconnecting] %s:%d', this._registry_address.host, this._registry_address.port);
|
|
110
194
|
void this.reconnectToRegistry().catch(() => {
|
|
111
|
-
if (this.stopped)
|
|
195
|
+
if (this.stopped || this.listenGeneration !== generation)
|
|
112
196
|
return;
|
|
113
|
-
this.scheduleRegistryRetry();
|
|
197
|
+
this.scheduleRegistryRetry(generation);
|
|
114
198
|
});
|
|
115
199
|
}, 3000);
|
|
116
200
|
}
|
|
201
|
+
canUsePubSub() {
|
|
202
|
+
return !!this.port && !this.stopped;
|
|
203
|
+
}
|
|
204
|
+
assertCanUsePubSub() {
|
|
205
|
+
if (!this.canUsePubSub()) {
|
|
206
|
+
throw new Error('Registry not found');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
ensureRegistryReconnectScheduled() {
|
|
210
|
+
if (!this.canUsePubSub())
|
|
211
|
+
return;
|
|
212
|
+
const generation = this.listenGeneration;
|
|
213
|
+
void this.reconnectToRegistry().catch(() => {
|
|
214
|
+
if (this.stopped || this.listenGeneration !== generation)
|
|
215
|
+
return;
|
|
216
|
+
this.scheduleRegistryRetry(generation);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
handleRegistrySyncFailure(registry, err) {
|
|
220
|
+
if (this.stopped)
|
|
221
|
+
return;
|
|
222
|
+
const generation = this.listenGeneration;
|
|
223
|
+
this.logger.error(err);
|
|
224
|
+
if (this.registry === registry) {
|
|
225
|
+
this.registry = undefined;
|
|
226
|
+
registry.dispose();
|
|
227
|
+
}
|
|
228
|
+
if (!this.stopped && this.listenGeneration === generation) {
|
|
229
|
+
this.scheduleRegistryRetry(generation);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
registryRequestOptions() {
|
|
233
|
+
if (!Number.isFinite(this._registryLookupTimeoutMs) || this._registryLookupTimeoutMs <= 0) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
return { timeout: this._registryLookupTimeoutMs };
|
|
237
|
+
}
|
|
238
|
+
recordPublishedTopic(topic, payload) {
|
|
239
|
+
const signature = stablePayloadSignature(payload);
|
|
240
|
+
const previousSignature = this.publishedTopicSignatures.get(topic);
|
|
241
|
+
const version = this.publishedTopics.has(topic) && previousSignature === signature
|
|
242
|
+
? this.publishedTopicVersions.get(topic) ?? ++this.publishIntentVersion
|
|
243
|
+
: ++this.publishIntentVersion;
|
|
244
|
+
this.publishedTopics.set(topic, payload);
|
|
245
|
+
this.publishedTopicDirty.add(topic);
|
|
246
|
+
this.publishedTopicVersions.set(topic, version);
|
|
247
|
+
this.publishedTopicSignatures.set(topic, signature);
|
|
248
|
+
return version;
|
|
249
|
+
}
|
|
250
|
+
enqueueTopicSync(topic, operation, options = {}) {
|
|
251
|
+
const previous = this.topicSyncs.get(topic) ?? Promise.resolve();
|
|
252
|
+
const run = previous.catch(() => undefined).then(async () => {
|
|
253
|
+
if (!this.canUsePubSub())
|
|
254
|
+
return;
|
|
255
|
+
const registry = this.registry;
|
|
256
|
+
if (!registry) {
|
|
257
|
+
this.ensureRegistryReconnectScheduled();
|
|
258
|
+
if (options.propagateError)
|
|
259
|
+
throw new Error('Registry not found');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
await operation(registry);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
this.handleRegistrySyncFailure(registry, err);
|
|
267
|
+
if (options.propagateError)
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
const tracked = run.finally(() => {
|
|
272
|
+
if (this.topicSyncs.get(topic) === tracked) {
|
|
273
|
+
this.topicSyncs.delete(topic);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
this.topicSyncs.set(topic, tracked);
|
|
277
|
+
return tracked;
|
|
278
|
+
}
|
|
279
|
+
syncPublishedTopic(topic, options = {}) {
|
|
280
|
+
const hasPayload = Object.prototype.hasOwnProperty.call(options, 'payload');
|
|
281
|
+
return this.enqueueTopicSync(topic, async (registry) => {
|
|
282
|
+
if (!this.publishedTopics.has(topic))
|
|
283
|
+
return;
|
|
284
|
+
const version = options.version ?? this.publishedTopicVersions.get(topic);
|
|
285
|
+
const request = {
|
|
286
|
+
topic,
|
|
287
|
+
payload: hasPayload ? options.payload : this.publishedTopics.get(topic),
|
|
288
|
+
};
|
|
289
|
+
const preserveRevision = options.preserveRevision
|
|
290
|
+
?? (!this.publishedTopicDirty.has(topic) && this.publishedTopicRevisions.has(topic));
|
|
291
|
+
if (preserveRevision && this.publishedTopicRevisions.has(topic)) {
|
|
292
|
+
request.revision = this.publishedTopicRevisions.get(topic);
|
|
293
|
+
}
|
|
294
|
+
const revision = await registry.request('/-/declare', request, this.registryRequestOptions());
|
|
295
|
+
if (this.publishedTopics.has(topic) &&
|
|
296
|
+
this.publishedTopicVersions.get(topic) === version &&
|
|
297
|
+
Number.isFinite(revision)) {
|
|
298
|
+
this.publishedTopicRevisions.set(topic, revision);
|
|
299
|
+
this.publishedTopicDirty.delete(topic);
|
|
300
|
+
}
|
|
301
|
+
}, options);
|
|
302
|
+
}
|
|
303
|
+
syncUnpublishedTopic(topic, options = {}) {
|
|
304
|
+
return this.enqueueTopicSync(topic, async (registry) => {
|
|
305
|
+
await registry.request('/-/undeclare', { topic }, this.registryRequestOptions());
|
|
306
|
+
}, options);
|
|
307
|
+
}
|
|
308
|
+
syncUnsubscribedTopic(topic, options = {}) {
|
|
309
|
+
return this.enqueueTopicSync(topic, async (registry) => {
|
|
310
|
+
await registry.request('/-/unsubscribe', { topic }, this.registryRequestOptions());
|
|
311
|
+
}, options);
|
|
312
|
+
}
|
|
313
|
+
syncRestoredSubscription(topic, callback, options = {}) {
|
|
314
|
+
return this.enqueueTopicSync(topic, async (registry) => {
|
|
315
|
+
if (!this.topics.get(topic)?.has(callback))
|
|
316
|
+
return;
|
|
317
|
+
await this.restoreSubscription(topic, callback, registry, true);
|
|
318
|
+
}, options);
|
|
319
|
+
}
|
|
320
|
+
async restoreSubscription(topic, callback, registry, isReconnect, requireLocal = true) {
|
|
321
|
+
const replayBaseVersion = this.topicUpdateVersions.get(topic) ?? 0;
|
|
322
|
+
const snapshot = await registry.request('/-/subscribe', { topic }, this.registryRequestOptions());
|
|
323
|
+
if (requireLocal && !this.topics.get(topic)?.has(callback))
|
|
324
|
+
return;
|
|
325
|
+
if (!snapshot.hasData)
|
|
326
|
+
return;
|
|
327
|
+
if ((this.topicUpdateVersions.get(topic) ?? 0) !== replayBaseVersion)
|
|
328
|
+
return;
|
|
329
|
+
try {
|
|
330
|
+
await Promise.resolve(callback(snapshot.payload));
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
if (!isReconnect)
|
|
334
|
+
throw err;
|
|
335
|
+
this.logger.error(err);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async rollbackLocalSubscription(topic, callback) {
|
|
339
|
+
const callbacks = this.topics.get(topic);
|
|
340
|
+
callbacks?.delete(callback);
|
|
341
|
+
this.events.off('topic:' + topic, callback);
|
|
342
|
+
if (callbacks?.size === 0) {
|
|
343
|
+
this.topics.delete(topic);
|
|
344
|
+
await this.syncUnsubscribedTopic(topic);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
117
347
|
async reconnectToRegistry() {
|
|
118
348
|
if (this.stopped)
|
|
119
349
|
return;
|
|
120
|
-
|
|
350
|
+
const generation = this.listenGeneration;
|
|
351
|
+
if (this.registryReconnectPromise && this.registryReconnectGeneration === generation) {
|
|
121
352
|
return this.registryReconnectPromise;
|
|
122
|
-
|
|
353
|
+
}
|
|
354
|
+
this.registryReconnectGeneration = generation;
|
|
355
|
+
const run = (async () => {
|
|
123
356
|
const registry = await this.connect(this._registry_address.host, this._registry_address.port);
|
|
124
|
-
if (this.stopped) {
|
|
357
|
+
if (this.stopped || this.listenGeneration !== generation) {
|
|
125
358
|
registry.dispose();
|
|
126
359
|
return;
|
|
127
360
|
}
|
|
@@ -129,58 +362,304 @@ export class Application extends Server {
|
|
|
129
362
|
if (this.registry !== registry)
|
|
130
363
|
return;
|
|
131
364
|
this.registry = undefined;
|
|
132
|
-
if (this.stopped)
|
|
365
|
+
if (this.stopped || this.listenGeneration !== generation)
|
|
133
366
|
return;
|
|
134
367
|
void this.reconnectToRegistry().catch(() => {
|
|
135
|
-
if (this.stopped)
|
|
368
|
+
if (this.stopped || this.listenGeneration !== generation)
|
|
136
369
|
return;
|
|
137
|
-
this.scheduleRegistryRetry();
|
|
370
|
+
this.scheduleRegistryRetry(generation);
|
|
138
371
|
});
|
|
139
372
|
});
|
|
140
373
|
this.registry = registry;
|
|
374
|
+
// 重新声明所有仍处于发布状态的 topic
|
|
375
|
+
for (const topic of [...this.publishedTopics.keys()]) {
|
|
376
|
+
if (this.stopped || this.listenGeneration !== generation) {
|
|
377
|
+
registry.dispose();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!this.publishedTopics.has(topic))
|
|
381
|
+
continue;
|
|
382
|
+
await this.syncPublishedTopic(topic, { propagateError: true });
|
|
383
|
+
}
|
|
141
384
|
// 重新订阅所有 topic
|
|
142
|
-
for (const [topic,
|
|
143
|
-
|
|
385
|
+
for (const [topic, callbacks] of [...this.topics]) {
|
|
386
|
+
for (const callback of [...callbacks]) {
|
|
387
|
+
if (this.stopped || this.listenGeneration !== generation) {
|
|
388
|
+
registry.dispose();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!this.topics.get(topic)?.has(callback))
|
|
392
|
+
continue;
|
|
393
|
+
await this.syncRestoredSubscription(topic, callback, { propagateError: true });
|
|
394
|
+
}
|
|
144
395
|
}
|
|
145
396
|
this.logger.debug('[reconnected] %s:%d', this._registry_address.host, this._registry_address.port);
|
|
146
|
-
})()
|
|
147
|
-
|
|
397
|
+
})();
|
|
398
|
+
const tracked = run.finally(() => {
|
|
399
|
+
if (this.registryReconnectPromise === tracked) {
|
|
400
|
+
this.registryReconnectPromise = undefined;
|
|
401
|
+
this.registryReconnectGeneration = 0;
|
|
402
|
+
}
|
|
148
403
|
});
|
|
149
|
-
|
|
404
|
+
this.registryReconnectPromise = tracked;
|
|
405
|
+
return tracked;
|
|
406
|
+
}
|
|
407
|
+
peerKey(host, port) {
|
|
408
|
+
return `${host}:${port}`;
|
|
150
409
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
this.circuitBreakers.delete(ns);
|
|
410
|
+
getPeerStates(ns, create = false) {
|
|
411
|
+
let states = this.circuitBreakers.get(ns);
|
|
412
|
+
if (!states && create) {
|
|
413
|
+
states = new Map();
|
|
414
|
+
this.circuitBreakers.set(ns, states);
|
|
157
415
|
}
|
|
416
|
+
return states;
|
|
158
417
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
418
|
+
getOrCreatePeerState(ns, host, port) {
|
|
419
|
+
const states = this.getPeerStates(ns, true);
|
|
420
|
+
const key = this.peerKey(host, port);
|
|
421
|
+
let state = states.get(key);
|
|
422
|
+
if (!state) {
|
|
423
|
+
state = {
|
|
424
|
+
status: 'closed',
|
|
425
|
+
failures: 0,
|
|
426
|
+
successes: 0,
|
|
427
|
+
openedAt: 0,
|
|
428
|
+
nextAttemptAt: 0,
|
|
429
|
+
cooldownMs: this._circuitBreaker.cooldownMs,
|
|
430
|
+
lastFailureAt: 0,
|
|
431
|
+
halfOpenInFlight: 0,
|
|
432
|
+
};
|
|
433
|
+
states.set(key, state);
|
|
164
434
|
}
|
|
165
|
-
|
|
435
|
+
return state;
|
|
166
436
|
}
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
170
|
-
return
|
|
437
|
+
deletePeerState(ns, key) {
|
|
438
|
+
const states = this.circuitBreakers.get(ns);
|
|
439
|
+
if (!states)
|
|
440
|
+
return;
|
|
441
|
+
states.delete(key);
|
|
442
|
+
if (states.size === 0)
|
|
443
|
+
this.circuitBreakers.delete(ns);
|
|
444
|
+
}
|
|
445
|
+
openCircuit(state, cooldownMs) {
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
state.status = 'open';
|
|
448
|
+
state.successes = 0;
|
|
449
|
+
state.openedAt = now;
|
|
450
|
+
state.lastFailureAt = now;
|
|
451
|
+
state.cooldownMs = Math.min(cooldownMs, this._circuitBreaker.maxCooldownMs);
|
|
452
|
+
state.nextAttemptAt = now + state.cooldownMs;
|
|
453
|
+
state.halfOpenInFlight = 0;
|
|
454
|
+
}
|
|
455
|
+
acquireCircuitProbe(ns, host, port) {
|
|
456
|
+
const key = this.peerKey(host, port);
|
|
457
|
+
const state = this.circuitBreakers.get(ns)?.get(key);
|
|
458
|
+
if (state?.status === 'open') {
|
|
459
|
+
return {
|
|
460
|
+
acquired: false,
|
|
461
|
+
wasHalfOpen: false,
|
|
462
|
+
cooldownMs: state.cooldownMs,
|
|
463
|
+
release: () => { },
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (state?.status !== 'half-open') {
|
|
467
|
+
return {
|
|
468
|
+
acquired: true,
|
|
469
|
+
wasHalfOpen: false,
|
|
470
|
+
cooldownMs: this._circuitBreaker.cooldownMs,
|
|
471
|
+
release: () => { },
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (state.halfOpenInFlight >= this._circuitBreaker.halfOpenMaxProbes) {
|
|
475
|
+
return {
|
|
476
|
+
acquired: false,
|
|
477
|
+
wasHalfOpen: true,
|
|
478
|
+
cooldownMs: state.cooldownMs,
|
|
479
|
+
release: () => { },
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
state.halfOpenInFlight++;
|
|
483
|
+
let released = false;
|
|
484
|
+
return {
|
|
485
|
+
acquired: true,
|
|
486
|
+
wasHalfOpen: true,
|
|
487
|
+
cooldownMs: state.cooldownMs,
|
|
488
|
+
release: () => {
|
|
489
|
+
if (released)
|
|
490
|
+
return;
|
|
491
|
+
released = true;
|
|
492
|
+
const current = this.circuitBreakers.get(ns)?.get(key);
|
|
493
|
+
if (current?.status === 'half-open' && current.halfOpenInFlight > 0) {
|
|
494
|
+
current.halfOpenInFlight--;
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
shouldRecordCircuitFailure(err) {
|
|
500
|
+
try {
|
|
501
|
+
return this._circuitBreaker.shouldRecordFailure(err);
|
|
502
|
+
}
|
|
503
|
+
catch (hookErr) {
|
|
504
|
+
this.logger.error(hookErr);
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
shouldRetryCircuitFailure(err) {
|
|
509
|
+
try {
|
|
510
|
+
return this._circuitBreaker.shouldRetry(err);
|
|
511
|
+
}
|
|
512
|
+
catch (hookErr) {
|
|
513
|
+
this.logger.error(hookErr);
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
recordSuccess(ns, host, port, probe) {
|
|
518
|
+
const key = this.peerKey(host, port);
|
|
519
|
+
const state = this.circuitBreakers.get(ns)?.get(key);
|
|
520
|
+
if (!state)
|
|
521
|
+
return;
|
|
522
|
+
if (probe.wasHalfOpen) {
|
|
523
|
+
if (state.status !== 'half-open')
|
|
524
|
+
return;
|
|
525
|
+
state.failures = 0;
|
|
526
|
+
state.successes++;
|
|
527
|
+
if (state.successes < this._circuitBreaker.successThreshold)
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
else if (state.status !== 'closed') {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
this.deletePeerState(ns, key);
|
|
534
|
+
}
|
|
535
|
+
recordFailure(ns, host, port, err, probe) {
|
|
536
|
+
if (!this.shouldRecordCircuitFailure(err))
|
|
537
|
+
return;
|
|
171
538
|
const now = Date.now();
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
539
|
+
const key = this.peerKey(host, port);
|
|
540
|
+
if (probe.wasHalfOpen) {
|
|
541
|
+
const state = this.circuitBreakers.get(ns)?.get(key);
|
|
542
|
+
if (state?.status !== 'half-open')
|
|
543
|
+
return;
|
|
544
|
+
state.failures = this._circuitBreaker.failureThreshold;
|
|
545
|
+
state.successes = 0;
|
|
546
|
+
this.openCircuit(state, Math.max(state.cooldownMs, probe.cooldownMs) * 2);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const state = this.getOrCreatePeerState(ns, host, port);
|
|
550
|
+
if (state.status !== 'closed')
|
|
551
|
+
return;
|
|
552
|
+
state.successes = 0;
|
|
553
|
+
if (state.lastFailureAt > 0 && now - state.lastFailureAt >= this._circuitBreaker.failureWindowMs) {
|
|
554
|
+
state.failures = 0;
|
|
555
|
+
}
|
|
556
|
+
state.lastFailureAt = now;
|
|
557
|
+
state.failures++;
|
|
558
|
+
if (state.failures >= this._circuitBreaker.failureThreshold) {
|
|
559
|
+
this.openCircuit(state, this._circuitBreaker.cooldownMs);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
getActiveCircuitExcludes(ns) {
|
|
563
|
+
const states = this.circuitBreakers.get(ns);
|
|
564
|
+
if (!states)
|
|
565
|
+
return { keys: [], hasProbeLimitedPeer: false };
|
|
566
|
+
const now = Date.now();
|
|
567
|
+
const keys = [];
|
|
568
|
+
let hasProbeLimitedPeer = false;
|
|
569
|
+
for (const [key, state] of states) {
|
|
570
|
+
if (state.status === 'closed' &&
|
|
571
|
+
state.lastFailureAt > 0 &&
|
|
572
|
+
now - state.lastFailureAt >= this._circuitBreaker.failureWindowMs) {
|
|
573
|
+
states.delete(key);
|
|
574
|
+
continue;
|
|
176
575
|
}
|
|
177
|
-
|
|
178
|
-
|
|
576
|
+
if (state.status === 'open') {
|
|
577
|
+
if (now >= state.nextAttemptAt) {
|
|
578
|
+
state.status = 'half-open';
|
|
579
|
+
state.successes = 0;
|
|
580
|
+
state.halfOpenInFlight = 0;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
keys.push(key);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (state.status === 'half-open' && state.halfOpenInFlight >= this._circuitBreaker.halfOpenMaxProbes) {
|
|
587
|
+
keys.push(key);
|
|
588
|
+
hasProbeLimitedPeer = true;
|
|
179
589
|
}
|
|
180
590
|
}
|
|
181
|
-
if (
|
|
591
|
+
if (states.size === 0)
|
|
182
592
|
this.circuitBreakers.delete(ns);
|
|
183
|
-
return
|
|
593
|
+
return { keys, hasProbeLimitedPeer };
|
|
594
|
+
}
|
|
595
|
+
getActiveExcludes(ns) {
|
|
596
|
+
return this.getActiveCircuitExcludes(ns).keys;
|
|
597
|
+
}
|
|
598
|
+
trackCircuitStream(ns, host, port, probe, readable) {
|
|
599
|
+
let settled = false;
|
|
600
|
+
const settle = (status, err) => {
|
|
601
|
+
if (settled)
|
|
602
|
+
return;
|
|
603
|
+
settled = true;
|
|
604
|
+
if (status === 'failure') {
|
|
605
|
+
this.recordFailure(ns, host, port, err, probe);
|
|
606
|
+
}
|
|
607
|
+
else if (status === 'success') {
|
|
608
|
+
this.recordSuccess(ns, host, port, probe);
|
|
609
|
+
}
|
|
610
|
+
probe.release();
|
|
611
|
+
};
|
|
612
|
+
readable.once('error', err => settle('failure', err));
|
|
613
|
+
readable.once('end', () => settle('success'));
|
|
614
|
+
readable.once('close', () => settle('neutral'));
|
|
615
|
+
return readable;
|
|
616
|
+
}
|
|
617
|
+
async selectCircuitClient(namespace, allowExcludedCachedFallback = true) {
|
|
618
|
+
const { keys: exclude, hasProbeLimitedPeer } = this.getActiveCircuitExcludes(namespace);
|
|
619
|
+
try {
|
|
620
|
+
return await this.resolveClient(namespace, exclude, {
|
|
621
|
+
allowExcludedCachedFallback: allowExcludedCachedFallback && !hasProbeLimitedPeer,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
if (!allowExcludedCachedFallback || hasProbeLimitedPeer)
|
|
626
|
+
throw err;
|
|
627
|
+
this.circuitBreakers.delete(namespace);
|
|
628
|
+
return this.get(namespace);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async selectCircuitProbe(namespace, allowCircuitReset) {
|
|
632
|
+
let client = await this.selectCircuitClient(namespace, allowCircuitReset);
|
|
633
|
+
let probe = this.acquireCircuitProbe(namespace, client.host, client.port);
|
|
634
|
+
if (probe.acquired)
|
|
635
|
+
return { client, probe };
|
|
636
|
+
let blockedByHalfOpenProbe = probe.wasHalfOpen;
|
|
637
|
+
let resetClient = probe.wasHalfOpen ? undefined : client;
|
|
638
|
+
try {
|
|
639
|
+
client = await this.selectCircuitClient(namespace, false);
|
|
640
|
+
probe = this.acquireCircuitProbe(namespace, client.host, client.port);
|
|
641
|
+
if (probe.acquired)
|
|
642
|
+
return { client, probe };
|
|
643
|
+
blockedByHalfOpenProbe ||= probe.wasHalfOpen;
|
|
644
|
+
if (!probe.wasHalfOpen)
|
|
645
|
+
resetClient = client;
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
if (!allowCircuitReset)
|
|
649
|
+
throw err;
|
|
650
|
+
}
|
|
651
|
+
if (!allowCircuitReset || blockedByHalfOpenProbe) {
|
|
652
|
+
throw new Error(`Circuit breaker probe unavailable for ${namespace}`);
|
|
653
|
+
}
|
|
654
|
+
this.circuitBreakers.delete(namespace);
|
|
655
|
+
// Preserve a cached client selected before the reset. A registry failure can
|
|
656
|
+
// clear namespace cache while the peer connection itself is still usable.
|
|
657
|
+
client = resetClient ?? await this.get(namespace);
|
|
658
|
+
probe = this.acquireCircuitProbe(namespace, client.host, client.port);
|
|
659
|
+
if (!probe.acquired) {
|
|
660
|
+
throw new Error(`Circuit breaker probe unavailable for ${namespace}`);
|
|
661
|
+
}
|
|
662
|
+
return { client, probe };
|
|
184
663
|
}
|
|
185
664
|
async findFromRegistry(namespace, exclude) {
|
|
186
665
|
if (!this.registry)
|
|
@@ -189,6 +668,9 @@ export class Application extends Server {
|
|
|
189
668
|
return await withTimeout(promise, this._registryLookupTimeoutMs, 'Registry /-/find');
|
|
190
669
|
}
|
|
191
670
|
get(namespace, exclude) {
|
|
671
|
+
return this.resolveClient(namespace, exclude);
|
|
672
|
+
}
|
|
673
|
+
resolveClient(namespace, exclude, options = {}) {
|
|
192
674
|
if (!this.namespaces.has(namespace)) {
|
|
193
675
|
this.namespaces.set(namespace, {
|
|
194
676
|
host: '',
|
|
@@ -238,7 +720,9 @@ export class Application extends Server {
|
|
|
238
720
|
}).catch(e => {
|
|
239
721
|
// Registry unavailable but previously cached client still valid -> degrade
|
|
240
722
|
const cachedKey = `${cachedHost}:${cachedPort}`;
|
|
241
|
-
|
|
723
|
+
const cachedExcluded = exclude?.includes(cachedKey);
|
|
724
|
+
const allowCachedFallback = options.allowExcludedCachedFallback ?? true;
|
|
725
|
+
if (cachedHost && this.clients.has(cachedKey) && (allowCachedFallback || !cachedExcluded)) {
|
|
242
726
|
const client = this.clients.get(cachedKey);
|
|
243
727
|
// Restore cache so subsequent calls hit the fast path
|
|
244
728
|
stack.host = cachedHost;
|
|
@@ -259,96 +743,174 @@ export class Application extends Server {
|
|
|
259
743
|
}
|
|
260
744
|
async call(namespace, url, data, options) {
|
|
261
745
|
const { timeout = this._requestTimeoutMs, retries = 1, signal } = options || {};
|
|
262
|
-
|
|
263
|
-
let
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
return
|
|
746
|
+
let remainingRetries = retries;
|
|
747
|
+
let retrySourceError;
|
|
748
|
+
let hasRetrySourceError = false;
|
|
749
|
+
for (;;) {
|
|
750
|
+
let selected;
|
|
751
|
+
try {
|
|
752
|
+
selected = await this.selectCircuitProbe(namespace, !hasRetrySourceError);
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
if (hasRetrySourceError)
|
|
756
|
+
throw retrySourceError;
|
|
757
|
+
throw err;
|
|
758
|
+
}
|
|
759
|
+
const { client, probe } = selected;
|
|
760
|
+
try {
|
|
761
|
+
const result = await client.request(url, data, {
|
|
762
|
+
timeout: timeout ?? this._requestTimeoutMs,
|
|
763
|
+
signal,
|
|
764
|
+
});
|
|
765
|
+
this.recordSuccess(namespace, client.host, client.port, probe);
|
|
766
|
+
return result;
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
this.recordFailure(namespace, client.host, client.port, err, probe);
|
|
770
|
+
if (remainingRetries > 0 && this.shouldRetryCircuitFailure(err)) {
|
|
771
|
+
retrySourceError = err;
|
|
772
|
+
hasRetrySourceError = true;
|
|
773
|
+
remainingRetries--;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
throw err;
|
|
777
|
+
}
|
|
778
|
+
finally {
|
|
779
|
+
probe.release();
|
|
283
780
|
}
|
|
284
|
-
throw err;
|
|
285
781
|
}
|
|
286
782
|
}
|
|
287
783
|
async stream(namespace, url, data, options) {
|
|
288
784
|
const { signal, retries = 1 } = options || {};
|
|
289
|
-
|
|
290
|
-
let
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
785
|
+
let remainingRetries = retries;
|
|
786
|
+
let retrySourceError;
|
|
787
|
+
let hasRetrySourceError = false;
|
|
788
|
+
for (;;) {
|
|
789
|
+
let selected;
|
|
790
|
+
try {
|
|
791
|
+
selected = await this.selectCircuitProbe(namespace, !hasRetrySourceError);
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
if (hasRetrySourceError)
|
|
795
|
+
throw retrySourceError;
|
|
796
|
+
throw err;
|
|
797
|
+
}
|
|
798
|
+
const { client, probe } = selected;
|
|
799
|
+
try {
|
|
800
|
+
const readable = client.stream(url, data, { signal });
|
|
801
|
+
return this.trackCircuitStream(namespace, client.host, client.port, probe, readable);
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
this.recordFailure(namespace, client.host, client.port, err, probe);
|
|
805
|
+
probe.release();
|
|
806
|
+
if (remainingRetries > 0 && this.shouldRetryCircuitFailure(err)) {
|
|
807
|
+
retrySourceError = err;
|
|
808
|
+
hasRetrySourceError = true;
|
|
809
|
+
remainingRetries--;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
throw err;
|
|
307
813
|
}
|
|
308
|
-
throw err;
|
|
309
814
|
}
|
|
310
815
|
}
|
|
311
816
|
async publish(topic, data) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
817
|
+
this.assertCanUsePubSub();
|
|
818
|
+
const snapshot = createPubSubPayloadSnapshot(topic, data);
|
|
819
|
+
const version = this.recordPublishedTopic(topic, snapshot);
|
|
820
|
+
await this.syncPublishedTopic(topic, { payload: snapshot, preserveRevision: false, version });
|
|
821
|
+
let refVersion = version;
|
|
315
822
|
const ref = {
|
|
316
823
|
update: async (payload) => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
824
|
+
this.assertCanUsePubSub();
|
|
825
|
+
if (this.publishedTopicVersions.get(topic) !== refVersion)
|
|
826
|
+
return ref;
|
|
827
|
+
const snapshot = createPubSubPayloadSnapshot(topic, payload);
|
|
828
|
+
const version = this.recordPublishedTopic(topic, snapshot);
|
|
829
|
+
refVersion = version;
|
|
830
|
+
await this.syncPublishedTopic(topic, { payload: snapshot, preserveRevision: false, version });
|
|
320
831
|
return ref;
|
|
321
832
|
},
|
|
322
833
|
unpublish: async () => {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
834
|
+
this.assertCanUsePubSub();
|
|
835
|
+
if (this.publishedTopicVersions.get(topic) !== refVersion)
|
|
836
|
+
return ref;
|
|
837
|
+
this.publishedTopics.delete(topic);
|
|
838
|
+
this.publishedTopicRevisions.delete(topic);
|
|
839
|
+
this.publishedTopicDirty.delete(topic);
|
|
840
|
+
this.publishedTopicVersions.delete(topic);
|
|
841
|
+
this.publishedTopicSignatures.delete(topic);
|
|
842
|
+
await this.syncUnpublishedTopic(topic);
|
|
326
843
|
return ref;
|
|
327
844
|
}
|
|
328
845
|
};
|
|
329
846
|
return ref;
|
|
330
847
|
}
|
|
331
|
-
/**
|
|
848
|
+
/**
|
|
849
|
+
* 对同一 topic 可多次 subscribe,各自独立回调。
|
|
850
|
+
* 传入同一个 callback 引用第二次调用时幂等返回 unsubscribe,不重复注册。
|
|
851
|
+
*/
|
|
332
852
|
async subscribe(topic, callback, isReconnect = false) {
|
|
333
|
-
|
|
334
|
-
throw new Error('Registry not found');
|
|
853
|
+
this.assertCanUsePubSub();
|
|
335
854
|
const fallback = async () => {
|
|
336
|
-
if (!this.registry)
|
|
337
|
-
throw new Error('Registry not found');
|
|
338
|
-
await this.registry.request('/-/unsubscribe', { topic });
|
|
339
855
|
if (this.topics.has(topic)) {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
this.
|
|
856
|
+
const callbacks = this.topics.get(topic);
|
|
857
|
+
callbacks.delete(callback);
|
|
858
|
+
this.events.off('topic:' + topic, callback);
|
|
859
|
+
if (callbacks.size === 0) {
|
|
860
|
+
this.topics.delete(topic);
|
|
861
|
+
await this.syncUnsubscribedTopic(topic);
|
|
862
|
+
}
|
|
343
863
|
}
|
|
344
864
|
};
|
|
345
|
-
|
|
865
|
+
let localRegistered = false;
|
|
866
|
+
if (isReconnect) {
|
|
867
|
+
if (!this.registry) {
|
|
868
|
+
this.ensureRegistryReconnectScheduled();
|
|
869
|
+
return fallback;
|
|
870
|
+
}
|
|
871
|
+
await this.restoreSubscription(topic, callback, this.registry, true, false);
|
|
346
872
|
return fallback;
|
|
347
|
-
|
|
873
|
+
}
|
|
348
874
|
if (!isReconnect) {
|
|
875
|
+
// 预分配 Set 必须在 await 之前;空 Set 残留无害,会在 unsubscribe / shutdown 时清理
|
|
876
|
+
if (!this.topics.has(topic)) {
|
|
877
|
+
this.topics.set(topic, new Set());
|
|
878
|
+
}
|
|
879
|
+
// 同一个 callback 引用已注册,幂等返回 unsubscribe,不重复发起订阅
|
|
880
|
+
if (this.topics.get(topic).has(callback))
|
|
881
|
+
return fallback;
|
|
882
|
+
this.topics.get(topic).add(callback);
|
|
349
883
|
this.events.on('topic:' + topic, callback);
|
|
350
|
-
|
|
351
|
-
|
|
884
|
+
localRegistered = true;
|
|
885
|
+
}
|
|
886
|
+
const registry = this.registry;
|
|
887
|
+
if (!registry) {
|
|
888
|
+
this.ensureRegistryReconnectScheduled();
|
|
889
|
+
return fallback;
|
|
890
|
+
}
|
|
891
|
+
const replayBaseVersion = this.topicUpdateVersions.get(topic) ?? 0;
|
|
892
|
+
let snapshot;
|
|
893
|
+
let synced = false;
|
|
894
|
+
await this.enqueueTopicSync(topic, async (registry) => {
|
|
895
|
+
if (!this.topics.get(topic)?.has(callback))
|
|
896
|
+
return;
|
|
897
|
+
snapshot = await registry.request('/-/subscribe', { topic }, this.registryRequestOptions());
|
|
898
|
+
synced = true;
|
|
899
|
+
});
|
|
900
|
+
if (!synced || !snapshot) {
|
|
901
|
+
return fallback;
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
if (this.topics.get(topic)?.has(callback) &&
|
|
905
|
+
snapshot.hasData &&
|
|
906
|
+
(this.topicUpdateVersions.get(topic) ?? 0) === replayBaseVersion) {
|
|
907
|
+
await Promise.resolve(callback(snapshot.payload));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
catch (err) {
|
|
911
|
+
if (localRegistered)
|
|
912
|
+
await this.rollbackLocalSubscription(topic, callback);
|
|
913
|
+
throw err;
|
|
352
914
|
}
|
|
353
915
|
return fallback;
|
|
354
916
|
}
|