@onlineapps/conn-orch-registry 1.1.4
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/LICENSE +22 -0
- package/README.md +151 -0
- package/docs/REGISTRY_CLIENT_GUIDE.md +240 -0
- package/examples/basicUsage.js +85 -0
- package/examples/event-consumer-example.js +108 -0
- package/package.json +66 -0
- package/src/config.js +52 -0
- package/src/events.js +28 -0
- package/src/index.js +37 -0
- package/src/queueManager.js +88 -0
- package/src/registryClient.js +397 -0
- package/src/registryEventConsumer.js +422 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registryEventConsumer.js
|
|
3
|
+
*
|
|
4
|
+
* Extension for ServiceRegistryClient to consume registry change events.
|
|
5
|
+
* Implements opt-in subscription to registry.changes exchange.
|
|
6
|
+
* Maintains local service index and enables lazy loading of specs.
|
|
7
|
+
*
|
|
8
|
+
* @module @onlineapps/connector-registry-client/src/registryEventConsumer
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const EventEmitter = require('events');
|
|
12
|
+
|
|
13
|
+
// Default StorageConnector - can be overridden for testing
|
|
14
|
+
let DefaultStorageConnector;
|
|
15
|
+
try {
|
|
16
|
+
DefaultStorageConnector = require('@onlineapps/connector-storage');
|
|
17
|
+
} catch (e) {
|
|
18
|
+
// StorageConnector not available, must be injected
|
|
19
|
+
DefaultStorageConnector = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class RegistryEventConsumer extends EventEmitter {
|
|
23
|
+
/**
|
|
24
|
+
* @param {Object} opts
|
|
25
|
+
* @param {Object} opts.queueManager - QueueManager instance from ServiceRegistryClient
|
|
26
|
+
* @param {string} opts.serviceName - Name of the consuming service
|
|
27
|
+
* @param {Object} [opts.redis] - Optional Redis client for index persistence
|
|
28
|
+
* @param {Object} [opts.storageConfig] - Configuration for StorageConnector
|
|
29
|
+
* @param {Object} [opts.StorageConnector] - Injectable StorageConnector class for testing
|
|
30
|
+
* @param {boolean} [opts.cacheEnabled=true] - Enable local spec caching
|
|
31
|
+
* @param {number} [opts.maxCacheSize=50] - Maximum cached specs
|
|
32
|
+
*/
|
|
33
|
+
constructor({ queueManager, serviceName, redis = null, storageConfig = {}, StorageConnector = null, cacheEnabled = true, maxCacheSize = 50 }) {
|
|
34
|
+
super();
|
|
35
|
+
|
|
36
|
+
this.queueManager = queueManager;
|
|
37
|
+
this.serviceName = serviceName;
|
|
38
|
+
this.redis = redis;
|
|
39
|
+
|
|
40
|
+
// Use injected StorageConnector or default one
|
|
41
|
+
this.StorageConnectorClass = StorageConnector || DefaultStorageConnector;
|
|
42
|
+
|
|
43
|
+
// Initialize storage connector for MinIO access (only if available)
|
|
44
|
+
this.storage = this.StorageConnectorClass ? new this.StorageConnectorClass({
|
|
45
|
+
...storageConfig,
|
|
46
|
+
cacheEnabled,
|
|
47
|
+
maxCacheSize
|
|
48
|
+
}) : null;
|
|
49
|
+
|
|
50
|
+
// Local service index: serviceName -> { fingerprint, version, status, path }
|
|
51
|
+
this.serviceIndex = new Map();
|
|
52
|
+
|
|
53
|
+
// Local spec cache: fingerprint -> spec content
|
|
54
|
+
this.specCache = new Map();
|
|
55
|
+
|
|
56
|
+
// Registry events queue name
|
|
57
|
+
this.eventsQueueName = `${serviceName}.registry`;
|
|
58
|
+
|
|
59
|
+
// Exchange name for registry events
|
|
60
|
+
this.exchangeName = 'registry.changes';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Initialize storage connector
|
|
65
|
+
*/
|
|
66
|
+
async initStorage() {
|
|
67
|
+
if (this.storage) {
|
|
68
|
+
await this.storage.initialize();
|
|
69
|
+
this.emit('storageReady');
|
|
70
|
+
} else {
|
|
71
|
+
console.warn('Storage connector not available - spec downloading disabled');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Subscribe to registry change events (opt-in)
|
|
77
|
+
* Creates service-specific queue bound to registry.changes exchange
|
|
78
|
+
*/
|
|
79
|
+
async subscribeToChanges() {
|
|
80
|
+
try {
|
|
81
|
+
const channel = this.queueManager.channel;
|
|
82
|
+
|
|
83
|
+
// Assert exchange exists (fanout type for broadcasting)
|
|
84
|
+
await channel.assertExchange(this.exchangeName, 'fanout', {
|
|
85
|
+
durable: true
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Create service-specific queue for registry events
|
|
89
|
+
// Try to assert queue with arguments, fall back to simple assert if conflict
|
|
90
|
+
try {
|
|
91
|
+
await channel.assertQueue(this.eventsQueueName, {
|
|
92
|
+
durable: true,
|
|
93
|
+
arguments: {
|
|
94
|
+
'x-message-ttl': 60000, // Messages expire after 1 minute
|
|
95
|
+
'x-max-length': 1000 // Keep max 1000 messages
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error.code === 406) {
|
|
100
|
+
// Queue exists with different arguments, use it as-is
|
|
101
|
+
await channel.assertQueue(this.eventsQueueName, {
|
|
102
|
+
durable: true
|
|
103
|
+
});
|
|
104
|
+
} else {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Bind queue to exchange (receive all registry events)
|
|
110
|
+
await channel.bindQueue(this.eventsQueueName, this.exchangeName, '');
|
|
111
|
+
|
|
112
|
+
// Load initial snapshot from Redis if available
|
|
113
|
+
await this.loadIndexFromCache();
|
|
114
|
+
|
|
115
|
+
// Start consuming events
|
|
116
|
+
await channel.consume(
|
|
117
|
+
this.eventsQueueName,
|
|
118
|
+
msg => this._handleRegistryEvent(msg),
|
|
119
|
+
{ noAck: false }
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
this.emit('subscribed', { queue: this.eventsQueueName, exchange: this.exchangeName });
|
|
123
|
+
console.log(`[${this.serviceName}] Subscribed to registry changes on ${this.eventsQueueName}`);
|
|
124
|
+
|
|
125
|
+
// Request initial snapshot
|
|
126
|
+
await this.requestSnapshot();
|
|
127
|
+
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.emit('error', error);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Handle incoming registry event
|
|
136
|
+
* @private
|
|
137
|
+
*/
|
|
138
|
+
async _handleRegistryEvent(msg) {
|
|
139
|
+
try {
|
|
140
|
+
const event = JSON.parse(msg.content.toString());
|
|
141
|
+
|
|
142
|
+
switch (event.type) {
|
|
143
|
+
case 'SERVICE_SPEC_PUBLISHED':
|
|
144
|
+
await this.handleSpecPublished(event);
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case 'SERVICE_STATUS_CHANGED':
|
|
148
|
+
await this.handleStatusChanged(event);
|
|
149
|
+
break;
|
|
150
|
+
|
|
151
|
+
case 'REGISTRY_SNAPSHOT':
|
|
152
|
+
await this.handleSnapshot(event);
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case 'SERVICE_REMOVED':
|
|
156
|
+
await this.handleServiceRemoved(event);
|
|
157
|
+
break;
|
|
158
|
+
|
|
159
|
+
default:
|
|
160
|
+
console.log(`[${this.serviceName}] Unknown event type: ${event.type}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Persist index after update
|
|
164
|
+
await this.persistIndex();
|
|
165
|
+
|
|
166
|
+
// Acknowledge message
|
|
167
|
+
this.queueManager.channel.ack(msg);
|
|
168
|
+
|
|
169
|
+
// Emit event for service to react
|
|
170
|
+
this.emit('registryEvent', event);
|
|
171
|
+
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error(`[${this.serviceName}] Failed to handle registry event:`, error);
|
|
174
|
+
this.emit('error', error);
|
|
175
|
+
|
|
176
|
+
// Reject message without requeue
|
|
177
|
+
this.queueManager.channel.nack(msg, false, false);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Handle SERVICE_SPEC_PUBLISHED event
|
|
183
|
+
*/
|
|
184
|
+
async handleSpecPublished(event) {
|
|
185
|
+
const { service, fingerprint, version, status, bucket, path } = event;
|
|
186
|
+
|
|
187
|
+
// Update local index (not content!)
|
|
188
|
+
this.serviceIndex.set(service, {
|
|
189
|
+
fingerprint,
|
|
190
|
+
version,
|
|
191
|
+
status: status || 'ACTIVE',
|
|
192
|
+
bucket: bucket || 'registry',
|
|
193
|
+
path,
|
|
194
|
+
updatedAt: event.timestamp
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
this.emit('serviceUpdated', { service, fingerprint, version });
|
|
198
|
+
console.log(`[${this.serviceName}] Service ${service} updated with fingerprint ${fingerprint}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Handle SERVICE_STATUS_CHANGED event
|
|
203
|
+
*/
|
|
204
|
+
async handleStatusChanged(event) {
|
|
205
|
+
const { service, status } = event;
|
|
206
|
+
|
|
207
|
+
const entry = this.serviceIndex.get(service);
|
|
208
|
+
if (entry) {
|
|
209
|
+
entry.status = status;
|
|
210
|
+
entry.updatedAt = event.timestamp;
|
|
211
|
+
|
|
212
|
+
this.emit('statusChanged', { service, status });
|
|
213
|
+
console.log(`[${this.serviceName}] Service ${service} status changed to ${status}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Handle REGISTRY_SNAPSHOT event
|
|
219
|
+
*/
|
|
220
|
+
async handleSnapshot(event) {
|
|
221
|
+
const { services } = event;
|
|
222
|
+
|
|
223
|
+
// Clear and rebuild index
|
|
224
|
+
this.serviceIndex.clear();
|
|
225
|
+
|
|
226
|
+
for (const service of services) {
|
|
227
|
+
this.serviceIndex.set(service.name, {
|
|
228
|
+
fingerprint: service.fingerprint,
|
|
229
|
+
version: service.version,
|
|
230
|
+
status: service.status,
|
|
231
|
+
bucket: service.bucket || 'registry',
|
|
232
|
+
path: service.path,
|
|
233
|
+
updatedAt: event.timestamp
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.emit('snapshotReceived', { count: services.length });
|
|
238
|
+
console.log(`[${this.serviceName}] Received snapshot with ${services.length} services`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Handle SERVICE_REMOVED event
|
|
243
|
+
*/
|
|
244
|
+
async handleServiceRemoved(event) {
|
|
245
|
+
const { service } = event;
|
|
246
|
+
|
|
247
|
+
if (this.serviceIndex.has(service)) {
|
|
248
|
+
this.serviceIndex.delete(service);
|
|
249
|
+
|
|
250
|
+
// Clear cached specs for this service
|
|
251
|
+
const fingerprintsToRemove = [];
|
|
252
|
+
for (const [fingerprint, spec] of this.specCache.entries()) {
|
|
253
|
+
if (spec.serviceName === service) {
|
|
254
|
+
fingerprintsToRemove.push(fingerprint);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
fingerprintsToRemove.forEach(fp => this.specCache.delete(fp));
|
|
258
|
+
|
|
259
|
+
this.emit('serviceRemoved', { service });
|
|
260
|
+
console.log(`[${this.serviceName}] Service ${service} removed from index`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Request initial snapshot from registry
|
|
266
|
+
*/
|
|
267
|
+
async requestSnapshot() {
|
|
268
|
+
// Publish request for snapshot
|
|
269
|
+
const request = {
|
|
270
|
+
type: 'SNAPSHOT_REQUEST',
|
|
271
|
+
requester: this.serviceName,
|
|
272
|
+
timestamp: new Date().toISOString()
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
await this.queueManager.channel.publish(
|
|
276
|
+
this.exchangeName,
|
|
277
|
+
'',
|
|
278
|
+
Buffer.from(JSON.stringify(request))
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get service spec with lazy loading from MinIO
|
|
284
|
+
* @param {string} serviceName - Name of the service
|
|
285
|
+
* @returns {Promise<Object>} - Service specification
|
|
286
|
+
*/
|
|
287
|
+
async getServiceSpec(serviceName) {
|
|
288
|
+
// Get entry from index
|
|
289
|
+
const entry = this.serviceIndex.get(serviceName);
|
|
290
|
+
if (!entry) {
|
|
291
|
+
throw new Error(`Service ${serviceName} not found in index`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (entry.status !== 'ACTIVE') {
|
|
295
|
+
throw new Error(`Service ${serviceName} is not active (status: ${entry.status})`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { fingerprint, bucket, path } = entry;
|
|
299
|
+
|
|
300
|
+
// Check local cache first
|
|
301
|
+
if (this.specCache.has(fingerprint)) {
|
|
302
|
+
this.emit('cacheHit', { service: serviceName, fingerprint });
|
|
303
|
+
return this.specCache.get(fingerprint);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Check if storage is available
|
|
307
|
+
if (!this.storage) {
|
|
308
|
+
throw new Error('Storage connector not available for downloading specs');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Download from MinIO with verification
|
|
312
|
+
console.log(`[${this.serviceName}] Downloading spec for ${serviceName} from ${bucket}/${path}`);
|
|
313
|
+
|
|
314
|
+
const content = await this.storage.downloadWithVerification(
|
|
315
|
+
bucket,
|
|
316
|
+
path,
|
|
317
|
+
fingerprint
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const spec = JSON.parse(content);
|
|
321
|
+
|
|
322
|
+
// Cache forever (immutable content)
|
|
323
|
+
this.specCache.set(fingerprint, spec);
|
|
324
|
+
|
|
325
|
+
this.emit('specLoaded', { service: serviceName, fingerprint });
|
|
326
|
+
|
|
327
|
+
return spec;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Check if service is active (synchronous, from local index)
|
|
332
|
+
* @param {string} serviceName - Name of the service
|
|
333
|
+
* @returns {boolean}
|
|
334
|
+
*/
|
|
335
|
+
isServiceActive(serviceName) {
|
|
336
|
+
const entry = this.serviceIndex.get(serviceName);
|
|
337
|
+
return entry && entry.status === 'ACTIVE';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Get list of active services (synchronous, from local index)
|
|
342
|
+
* @returns {Array<string>}
|
|
343
|
+
*/
|
|
344
|
+
getActiveServices() {
|
|
345
|
+
return Array.from(this.serviceIndex.entries())
|
|
346
|
+
.filter(([, entry]) => entry.status === 'ACTIVE')
|
|
347
|
+
.map(([name]) => name);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Get service info from local index
|
|
352
|
+
* @param {string} serviceName
|
|
353
|
+
* @returns {Object|null}
|
|
354
|
+
*/
|
|
355
|
+
getServiceInfo(serviceName) {
|
|
356
|
+
return this.serviceIndex.get(serviceName) || null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Persist index to Redis (if configured)
|
|
361
|
+
*/
|
|
362
|
+
async persistIndex() {
|
|
363
|
+
if (!this.redis) return;
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const indexData = JSON.stringify(Array.from(this.serviceIndex.entries()));
|
|
367
|
+
await this.redis.set(
|
|
368
|
+
`registry:index:${this.serviceName}`,
|
|
369
|
+
indexData,
|
|
370
|
+
'EX',
|
|
371
|
+
3600 // Expire after 1 hour
|
|
372
|
+
);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
console.error(`[${this.serviceName}] Failed to persist index:`, error);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Load index from Redis cache
|
|
380
|
+
*/
|
|
381
|
+
async loadIndexFromCache() {
|
|
382
|
+
if (!this.redis) return;
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const indexData = await this.redis.get(`registry:index:${this.serviceName}`);
|
|
386
|
+
if (indexData) {
|
|
387
|
+
const entries = JSON.parse(indexData);
|
|
388
|
+
this.serviceIndex = new Map(entries);
|
|
389
|
+
|
|
390
|
+
this.emit('indexLoaded', { count: this.serviceIndex.size });
|
|
391
|
+
console.log(`[${this.serviceName}] Loaded ${this.serviceIndex.size} services from cache`);
|
|
392
|
+
}
|
|
393
|
+
} catch (error) {
|
|
394
|
+
console.error(`[${this.serviceName}] Failed to load index from cache:`, error);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Clear all caches
|
|
400
|
+
*/
|
|
401
|
+
clearCache() {
|
|
402
|
+
this.specCache.clear();
|
|
403
|
+
if (this.storage && this.storage.clearCache) {
|
|
404
|
+
this.storage.clearCache();
|
|
405
|
+
}
|
|
406
|
+
this.emit('cacheCleared');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Get stats about current state
|
|
411
|
+
*/
|
|
412
|
+
getStats() {
|
|
413
|
+
return {
|
|
414
|
+
indexSize: this.serviceIndex.size,
|
|
415
|
+
activeServices: this.getActiveServices().length,
|
|
416
|
+
cachedSpecs: this.specCache.size,
|
|
417
|
+
queueName: this.eventsQueueName
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
module.exports = RegistryEventConsumer;
|