@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.
@@ -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;