@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.
@@ -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.fallbacks.add(this.register('/-/health', async () => ({
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
- for (const fallback of this.fallbacks) {
90
- fallback();
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
- if (this.registryReconnectPromise)
350
+ const generation = this.listenGeneration;
351
+ if (this.registryReconnectPromise && this.registryReconnectGeneration === generation) {
121
352
  return this.registryReconnectPromise;
122
- this.registryReconnectPromise = (async () => {
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, callback] of this.topics) {
143
- await this.subscribe(topic, callback, true);
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
- })().finally(() => {
147
- this.registryReconnectPromise = undefined;
397
+ })();
398
+ const tracked = run.finally(() => {
399
+ if (this.registryReconnectPromise === tracked) {
400
+ this.registryReconnectPromise = undefined;
401
+ this.registryReconnectGeneration = 0;
402
+ }
148
403
  });
149
- return this.registryReconnectPromise;
404
+ this.registryReconnectPromise = tracked;
405
+ return tracked;
406
+ }
407
+ peerKey(host, port) {
408
+ return `${host}:${port}`;
150
409
  }
151
- recordSuccess(ns, host, port) {
152
- const excludes = this.circuitBreakers.get(ns);
153
- if (excludes) {
154
- excludes.delete(`${host}:${port}`);
155
- if (excludes.size === 0)
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
- recordFailure(ns, host, port) {
160
- let excludes = this.circuitBreakers.get(ns);
161
- if (!excludes) {
162
- excludes = new Map();
163
- this.circuitBreakers.set(ns, excludes);
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
- excludes.set(`${host}:${port}`, Date.now());
435
+ return state;
166
436
  }
167
- getActiveExcludes(ns) {
168
- const excludes = this.circuitBreakers.get(ns);
169
- if (!excludes)
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 active = [];
173
- for (const [key, openedAt] of excludes) {
174
- if (now - openedAt >= Application.CB_COOLDOWN_MS) {
175
- excludes.delete(key);
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
- else {
178
- active.push(key);
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 (excludes.size === 0)
591
+ if (states.size === 0)
182
592
  this.circuitBreakers.delete(ns);
183
- return active;
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
- if (cachedHost && this.clients.has(cachedKey)) {
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
- const exclude = this.getActiveExcludes(namespace);
263
- let client;
264
- try {
265
- client = await this.get(namespace, exclude);
266
- }
267
- catch {
268
- this.circuitBreakers.delete(namespace);
269
- client = await this.get(namespace);
270
- }
271
- try {
272
- const result = await client.request(url, data, {
273
- timeout: timeout ?? this._requestTimeoutMs,
274
- signal,
275
- });
276
- this.recordSuccess(namespace, client.host, client.port);
277
- return result;
278
- }
279
- catch (err) {
280
- this.recordFailure(namespace, client.host, client.port);
281
- if (retries > 0) {
282
- return this.call(namespace, url, data, { timeout, retries: retries - 1, signal });
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
- const exclude = this.getActiveExcludes(namespace);
290
- let client;
291
- try {
292
- client = await this.get(namespace, exclude);
293
- }
294
- catch {
295
- this.circuitBreakers.delete(namespace);
296
- client = await this.get(namespace);
297
- }
298
- try {
299
- const readable = client.stream(url, data, { signal });
300
- this.recordSuccess(namespace, client.host, client.port);
301
- return readable;
302
- }
303
- catch (err) {
304
- this.recordFailure(namespace, client.host, client.port);
305
- if (retries > 0) {
306
- return this.stream(namespace, url, data, { signal, retries: retries - 1 });
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
- if (!this.registry)
313
- throw new Error('Registry not found');
314
- await this.registry.request('/-/declare', { topic, payload: data });
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
- if (!this.registry)
318
- throw new Error('Registry not found');
319
- await this.registry.request('/-/topic/update', { topic, payload });
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
- if (!this.registry)
324
- throw new Error('Registry not found');
325
- await this.registry.request('/-/undeclare', { topic });
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
- /** 对同一 topic 重复 subscribe 是幂等的:第二次调用只返回 unsubscribe 函数,不会注册第二个 callback */
848
+ /**
849
+ * 对同一 topic 可多次 subscribe,各自独立回调。
850
+ * 传入同一个 callback 引用第二次调用时幂等返回 unsubscribe,不重复注册。
851
+ */
332
852
  async subscribe(topic, callback, isReconnect = false) {
333
- if (!this.registry)
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 _callback = this.topics.get(topic);
341
- this.events.off('topic:' + topic, _callback);
342
- this.topics.delete(topic);
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
- if (this.topics.has(topic) && !isReconnect)
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
- const payload = await this.registry.request('/-/subscribe', { topic });
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
- this.topics.set(topic, callback);
351
- callback(payload);
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
  }