@avalw/search-worker 1.1.0 → 2.1.0

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/index.js ADDED
@@ -0,0 +1,887 @@
1
+ /**
2
+ * @avalw/search-worker v2.1.0
3
+ * Distributed Cache Worker for AVALW Search Network
4
+ *
5
+ * Workers connect to the coordinator via WebSocket and serve as
6
+ * distributed cache nodes, reducing load on AVALW servers.
7
+ *
8
+ * Features:
9
+ * - Intelligent cache with size limits (500MB - 2GB)
10
+ * - Time-based eviction (7+ days unused = auto-remove)
11
+ * - Popularity scoring (frequently accessed stay longer)
12
+ * - Background auto-cleanup
13
+ */
14
+
15
+ const os = require('os');
16
+ const WebSocket = require('ws');
17
+ const https = require('https');
18
+ const http = require('http');
19
+
20
+ // Intelligent Cache implementation
21
+ // - Size-based limits (bytes)
22
+ // - Time-based eviction (configurable max age)
23
+ // - Popularity scoring (access frequency)
24
+ // - Auto-cleanup background process
25
+ class SmartCache {
26
+ constructor(options = {}) {
27
+ this.cache = new Map();
28
+
29
+ // Size limits (in bytes)
30
+ this.maxSizeBytes = options.maxSizeBytes || 500 * 1024 * 1024; // Default 500MB
31
+ this.currentSizeBytes = 0;
32
+
33
+ // Count limits (fallback)
34
+ this.maxEntries = options.maxEntries || 10000;
35
+
36
+ // Time-based eviction (default 7 days)
37
+ this.maxAgeDays = options.maxAgeDays || 7;
38
+ this.maxAgeMs = this.maxAgeDays * 24 * 60 * 60 * 1000;
39
+
40
+ // Default TTL for new entries (5 minutes minimum cache)
41
+ this.defaultTtlMs = options.defaultTtlMs || 5 * 60 * 1000;
42
+
43
+ // Cleanup interval (default every 1 hour)
44
+ this.cleanupIntervalMs = options.cleanupIntervalMs || 60 * 60 * 1000;
45
+ this.cleanupTimer = null;
46
+
47
+ // Stats
48
+ this.stats = {
49
+ hits: 0,
50
+ misses: 0,
51
+ evictions: 0,
52
+ sizeEvictions: 0,
53
+ timeEvictions: 0,
54
+ popularityEvictions: 0
55
+ };
56
+
57
+ // Callbacks
58
+ this.onEvict = options.onEvict || (() => {});
59
+ this.onCleanup = options.onCleanup || (() => {});
60
+
61
+ // Start cleanup process
62
+ this._startCleanup();
63
+ }
64
+
65
+ // Calculate byte size of a value
66
+ _getByteSize(value) {
67
+ if (typeof value === 'string') {
68
+ return Buffer.byteLength(value, 'utf8');
69
+ }
70
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
71
+ }
72
+
73
+ // Get item from cache
74
+ get(key) {
75
+ const item = this.cache.get(key);
76
+ if (!item) {
77
+ this.stats.misses++;
78
+ return null;
79
+ }
80
+
81
+ const now = Date.now();
82
+
83
+ // Check TTL expiry
84
+ if (item.expiry && now > item.expiry) {
85
+ this._evict(key, 'ttl');
86
+ this.stats.misses++;
87
+ return null;
88
+ }
89
+
90
+ // Check max age (time-based eviction)
91
+ const ageMs = now - item.createdAt;
92
+ if (ageMs > this.maxAgeMs) {
93
+ this._evict(key, 'age');
94
+ this.stats.misses++;
95
+ return null;
96
+ }
97
+
98
+ // Update access stats
99
+ item.lastAccess = now;
100
+ item.accessCount++;
101
+ item.popularityScore = this._calculatePopularity(item);
102
+
103
+ this.stats.hits++;
104
+ return item.value;
105
+ }
106
+
107
+ // Set item in cache
108
+ set(key, value, ttl = null) {
109
+ const now = Date.now();
110
+ const byteSize = this._getByteSize(value);
111
+ const evictedKeys = [];
112
+
113
+ // Check if value is too large (max 10% of total cache)
114
+ if (byteSize > this.maxSizeBytes * 0.1) {
115
+ console.warn(`[SmartCache] Value too large (${this._formatBytes(byteSize)}), skipping`);
116
+ return evictedKeys;
117
+ }
118
+
119
+ // Check if key already exists
120
+ if (this.cache.has(key)) {
121
+ const existing = this.cache.get(key);
122
+ this.currentSizeBytes -= existing.byteSize;
123
+ // Update existing entry
124
+ existing.value = value;
125
+ existing.byteSize = byteSize;
126
+ existing.lastAccess = now;
127
+ existing.accessCount++;
128
+ existing.expiry = now + (ttl || this.defaultTtlMs);
129
+ existing.popularityScore = this._calculatePopularity(existing);
130
+ this.currentSizeBytes += byteSize;
131
+ return evictedKeys;
132
+ }
133
+
134
+ // Evict items if necessary to make space
135
+ while (
136
+ (this.currentSizeBytes + byteSize > this.maxSizeBytes ||
137
+ this.cache.size >= this.maxEntries) &&
138
+ this.cache.size > 0
139
+ ) {
140
+ const evictedKey = this._evictLowestPriority();
141
+ if (evictedKey) {
142
+ evictedKeys.push(evictedKey);
143
+ } else {
144
+ break; // No more items to evict
145
+ }
146
+ }
147
+
148
+ // Store new item
149
+ this.cache.set(key, {
150
+ value,
151
+ byteSize,
152
+ createdAt: now,
153
+ lastAccess: now,
154
+ accessCount: 1,
155
+ popularityScore: 1,
156
+ expiry: now + (ttl || this.defaultTtlMs)
157
+ });
158
+
159
+ this.currentSizeBytes += byteSize;
160
+ return evictedKeys;
161
+ }
162
+
163
+ // Check if key exists
164
+ has(key) {
165
+ const item = this.cache.get(key);
166
+ if (!item) return false;
167
+
168
+ const now = Date.now();
169
+
170
+ // Check expiry
171
+ if (item.expiry && now > item.expiry) {
172
+ this._evict(key, 'ttl');
173
+ return false;
174
+ }
175
+
176
+ // Check max age
177
+ if (now - item.createdAt > this.maxAgeMs) {
178
+ this._evict(key, 'age');
179
+ return false;
180
+ }
181
+
182
+ return true;
183
+ }
184
+
185
+ // Get all valid keys
186
+ keys() {
187
+ const validKeys = [];
188
+ const now = Date.now();
189
+
190
+ for (const [key, item] of this.cache.entries()) {
191
+ // Skip expired
192
+ if (item.expiry && now > item.expiry) {
193
+ this._evict(key, 'ttl');
194
+ continue;
195
+ }
196
+ // Skip too old
197
+ if (now - item.createdAt > this.maxAgeMs) {
198
+ this._evict(key, 'age');
199
+ continue;
200
+ }
201
+ validKeys.push(key);
202
+ }
203
+
204
+ return validKeys;
205
+ }
206
+
207
+ // Get entry count
208
+ size() {
209
+ return this.cache.size;
210
+ }
211
+
212
+ // Get current size in bytes
213
+ sizeBytes() {
214
+ return this.currentSizeBytes;
215
+ }
216
+
217
+ // Get formatted size
218
+ getSizeFormatted() {
219
+ return this._formatBytes(this.currentSizeBytes);
220
+ }
221
+
222
+ // Clear all entries
223
+ clear() {
224
+ this.cache.clear();
225
+ this.currentSizeBytes = 0;
226
+ }
227
+
228
+ // Get cache stats
229
+ getStats() {
230
+ return {
231
+ entries: this.cache.size,
232
+ sizeBytes: this.currentSizeBytes,
233
+ sizeFormatted: this._formatBytes(this.currentSizeBytes),
234
+ maxSizeBytes: this.maxSizeBytes,
235
+ maxSizeFormatted: this._formatBytes(this.maxSizeBytes),
236
+ utilizationPercent: Math.round((this.currentSizeBytes / this.maxSizeBytes) * 100 * 10) / 10,
237
+ maxAgeDays: this.maxAgeDays,
238
+ ...this.stats,
239
+ hitRate: this.stats.hits + this.stats.misses > 0
240
+ ? Math.round((this.stats.hits / (this.stats.hits + this.stats.misses)) * 100)
241
+ : 0
242
+ };
243
+ }
244
+
245
+ // Calculate popularity score
246
+ // Higher score = more valuable to keep
247
+ _calculatePopularity(item) {
248
+ const now = Date.now();
249
+ const ageHours = (now - item.createdAt) / (1000 * 60 * 60);
250
+ const recentHours = (now - item.lastAccess) / (1000 * 60 * 60);
251
+
252
+ // Factors:
253
+ // - Access count (more accesses = more popular)
254
+ // - Recency (recently accessed = more valuable)
255
+ // - Age (older items with high access = stable popularity)
256
+
257
+ const accessFactor = Math.log2(item.accessCount + 1); // Logarithmic growth
258
+ const recencyFactor = Math.max(0, 1 - recentHours / (24 * 7)); // Decay over 7 days
259
+ const stabilityFactor = ageHours > 1 ? Math.log2(item.accessCount / ageHours + 1) : 1;
260
+
261
+ return accessFactor * recencyFactor * stabilityFactor;
262
+ }
263
+
264
+ // Evict a specific key
265
+ _evict(key, reason = 'manual') {
266
+ const item = this.cache.get(key);
267
+ if (!item) return false;
268
+
269
+ this.currentSizeBytes -= item.byteSize;
270
+ this.cache.delete(key);
271
+
272
+ this.stats.evictions++;
273
+ if (reason === 'size') this.stats.sizeEvictions++;
274
+ if (reason === 'age' || reason === 'ttl') this.stats.timeEvictions++;
275
+ if (reason === 'popularity') this.stats.popularityEvictions++;
276
+
277
+ this.onEvict(key, reason);
278
+ return true;
279
+ }
280
+
281
+ // Evict the lowest priority item
282
+ _evictLowestPriority() {
283
+ if (this.cache.size === 0) return null;
284
+
285
+ const now = Date.now();
286
+ let lowestKey = null;
287
+ let lowestScore = Infinity;
288
+
289
+ for (const [key, item] of this.cache.entries()) {
290
+ // First check for expired items
291
+ if (item.expiry && now > item.expiry) {
292
+ this._evict(key, 'ttl');
293
+ return key;
294
+ }
295
+
296
+ // Check for items past max age
297
+ if (now - item.createdAt > this.maxAgeMs) {
298
+ this._evict(key, 'age');
299
+ return key;
300
+ }
301
+
302
+ // Calculate eviction priority (lower = evict first)
303
+ const score = item.popularityScore;
304
+ if (score < lowestScore) {
305
+ lowestScore = score;
306
+ lowestKey = key;
307
+ }
308
+ }
309
+
310
+ if (lowestKey) {
311
+ this._evict(lowestKey, 'popularity');
312
+ return lowestKey;
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ // Background cleanup process
319
+ _startCleanup() {
320
+ if (this.cleanupTimer) return;
321
+
322
+ this.cleanupTimer = setInterval(() => {
323
+ this._runCleanup();
324
+ }, this.cleanupIntervalMs);
325
+ }
326
+
327
+ // Stop cleanup process
328
+ stopCleanup() {
329
+ if (this.cleanupTimer) {
330
+ clearInterval(this.cleanupTimer);
331
+ this.cleanupTimer = null;
332
+ }
333
+ }
334
+
335
+ // Run cleanup - remove expired and aged entries
336
+ _runCleanup() {
337
+ const now = Date.now();
338
+ const toRemove = [];
339
+
340
+ for (const [key, item] of this.cache.entries()) {
341
+ // Remove expired
342
+ if (item.expiry && now > item.expiry) {
343
+ toRemove.push({ key, reason: 'ttl' });
344
+ continue;
345
+ }
346
+
347
+ // Remove items not accessed for maxAge days
348
+ const sinceLastAccess = now - item.lastAccess;
349
+ if (sinceLastAccess > this.maxAgeMs) {
350
+ toRemove.push({ key, reason: 'age' });
351
+ continue;
352
+ }
353
+
354
+ // Update popularity scores during cleanup
355
+ item.popularityScore = this._calculatePopularity(item);
356
+ }
357
+
358
+ // Evict marked entries
359
+ for (const { key, reason } of toRemove) {
360
+ this._evict(key, reason);
361
+ }
362
+
363
+ this.onCleanup(toRemove.length);
364
+
365
+ // Log cleanup results
366
+ if (toRemove.length > 0) {
367
+ console.log(`[SmartCache] Cleanup: removed ${toRemove.length} stale entries, ${this._formatBytes(this.currentSizeBytes)} / ${this._formatBytes(this.maxSizeBytes)} used`);
368
+ }
369
+ }
370
+
371
+ // Format bytes to human readable
372
+ _formatBytes(bytes) {
373
+ if (bytes === 0) return '0 B';
374
+ const k = 1024;
375
+ const sizes = ['B', 'KB', 'MB', 'GB'];
376
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
377
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
378
+ }
379
+
380
+ // Set max size (for runtime configuration)
381
+ setMaxSize(sizeBytes) {
382
+ this.maxSizeBytes = Math.max(100 * 1024 * 1024, Math.min(sizeBytes, 2 * 1024 * 1024 * 1024)); // 100MB - 2GB
383
+
384
+ // Evict if over new limit
385
+ while (this.currentSizeBytes > this.maxSizeBytes && this.cache.size > 0) {
386
+ this._evictLowestPriority();
387
+ }
388
+ }
389
+
390
+ // Set max age in days (for runtime configuration)
391
+ setMaxAgeDays(days) {
392
+ this.maxAgeDays = Math.max(1, Math.min(days, 30)); // 1 - 30 days
393
+ this.maxAgeMs = this.maxAgeDays * 24 * 60 * 60 * 1000;
394
+
395
+ // Run cleanup to evict newly expired items
396
+ this._runCleanup();
397
+ }
398
+ }
399
+
400
+ // Legacy LRU Cache for backward compatibility
401
+ class LRUCache extends SmartCache {
402
+ constructor(maxSize = 500, ttlMs = 5 * 60 * 1000) {
403
+ super({
404
+ maxEntries: maxSize,
405
+ defaultTtlMs: ttlMs,
406
+ maxSizeBytes: 500 * 1024 * 1024 // 500MB default
407
+ });
408
+ this.maxSize = maxSize;
409
+ this.ttlMs = ttlMs;
410
+ }
411
+ }
412
+
413
+ class SearchWorker {
414
+ constructor(options = {}) {
415
+ this.workerToken = options.workerToken || process.env.AVALW_WORKER_TOKEN;
416
+ this.dashboardUrl = options.dashboardUrl || 'https://worker.avalw.org';
417
+ this.wsUrl = options.wsUrl || 'wss://worker.avalw.org/ws/cache';
418
+
419
+ // Worker state
420
+ this.workerId = null;
421
+ this.isConnected = false;
422
+ this.ws = null;
423
+ this.startTime = Date.now();
424
+ this.heartbeatInterval = null;
425
+ this.reconnectTimeout = null;
426
+ this.reconnectAttempts = 0;
427
+ this.maxReconnectAttempts = 10;
428
+
429
+ // Smart cache configuration
430
+ // Size: 500MB - 2GB (default 500MB)
431
+ // Max age: 1-30 days (default 7 days for unused entries)
432
+ const cacheSizeMB = options.cacheSizeMB || 500;
433
+ const maxAgeDays = options.maxAgeDays || 7;
434
+
435
+ // Local cache for distributed caching
436
+ this.cache = new SmartCache({
437
+ maxSizeBytes: Math.min(Math.max(cacheSizeMB, 100), 2048) * 1024 * 1024, // 100MB - 2GB
438
+ maxAgeDays: Math.min(Math.max(maxAgeDays, 1), 30), // 1 - 30 days
439
+ maxEntries: options.maxCacheEntries || 10000,
440
+ defaultTtlMs: options.cacheTtl || 5 * 60 * 1000,
441
+ cleanupIntervalMs: options.cleanupIntervalMs || 60 * 60 * 1000, // 1 hour
442
+ onEvict: (key, reason) => {
443
+ // Notify coordinator about evicted key
444
+ if (this.isConnected) {
445
+ this._send({ type: 'cache_evicted', key, reason });
446
+ }
447
+ }
448
+ });
449
+
450
+ // Stats
451
+ this.stats = {
452
+ cacheHits: 0,
453
+ cacheMisses: 0,
454
+ cacheStored: 0,
455
+ requestsServed: 0
456
+ };
457
+
458
+ // Callbacks
459
+ this.onConnected = options.onConnected || (() => {});
460
+ this.onDisconnected = options.onDisconnected || (() => {});
461
+ this.onCacheHit = options.onCacheHit || (() => {});
462
+ this.onCacheStore = options.onCacheStore || (() => {});
463
+ this.onError = options.onError || ((err) => console.error('[Worker] Error:', err));
464
+ this.onLog = options.onLog || ((msg) => console.log('[Worker]', msg));
465
+ }
466
+
467
+ // Get system information
468
+ getSystemInfo() {
469
+ const cpus = os.cpus();
470
+ const totalMemory = os.totalmem();
471
+ const freeMemory = os.freemem();
472
+ const cpuSpeed = cpus[0]?.speed || 2400;
473
+ const speedFactor = cpuSpeed / 3000; // Normalized to 3GHz
474
+
475
+ return {
476
+ platform: os.platform(),
477
+ arch: os.arch(),
478
+ cores: cpus.length,
479
+ cpu_model: cpus[0]?.model || 'Unknown',
480
+ cpu_speed_ghz: cpuSpeed / 1000,
481
+ speed_factor: Math.round(speedFactor * 100) / 100,
482
+ total_memory_gb: Math.round(totalMemory / (1024 * 1024 * 1024) * 10) / 10,
483
+ free_memory_gb: Math.round(freeMemory / (1024 * 1024 * 1024) * 10) / 10,
484
+ worker_uptime_hours: Math.round((Date.now() - this.startTime) / (1000 * 60 * 60) * 100) / 100,
485
+ cache_size: this.cache.size()
486
+ };
487
+ }
488
+
489
+ // Calculate current CPU usage
490
+ async getCpuUsage() {
491
+ return new Promise((resolve) => {
492
+ const startMeasure = this._cpuAverage();
493
+ setTimeout(() => {
494
+ const endMeasure = this._cpuAverage();
495
+ const idleDiff = endMeasure.idle - startMeasure.idle;
496
+ const totalDiff = endMeasure.total - startMeasure.total;
497
+ const usage = 100 - (100 * idleDiff / totalDiff);
498
+ resolve(Math.round(usage * 10) / 10);
499
+ }, 100);
500
+ });
501
+ }
502
+
503
+ _cpuAverage() {
504
+ const cpus = os.cpus();
505
+ let totalIdle = 0, totalTick = 0;
506
+ for (const cpu of cpus) {
507
+ for (const type in cpu.times) {
508
+ totalTick += cpu.times[type];
509
+ }
510
+ totalIdle += cpu.times.idle;
511
+ }
512
+ return { idle: totalIdle / cpus.length, total: totalTick / cpus.length };
513
+ }
514
+
515
+ // Connect to WebSocket coordinator
516
+ connect() {
517
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
518
+ this.onLog('Already connected to coordinator');
519
+ return;
520
+ }
521
+
522
+ this.onLog(`Connecting to ${this.wsUrl}...`);
523
+
524
+ try {
525
+ this.ws = new WebSocket(this.wsUrl);
526
+
527
+ this.ws.on('open', () => {
528
+ this.onLog('Connected to coordinator');
529
+ this.isConnected = true;
530
+ this.reconnectAttempts = 0;
531
+
532
+ // Register cached keys
533
+ this._sendCacheRegistry();
534
+
535
+ // Start heartbeat
536
+ this._startHeartbeat();
537
+
538
+ this.onConnected(this);
539
+ });
540
+
541
+ this.ws.on('message', (data) => {
542
+ try {
543
+ const message = JSON.parse(data.toString());
544
+ this._handleMessage(message);
545
+ } catch (e) {
546
+ this.onError(`Invalid message: ${e.message}`);
547
+ }
548
+ });
549
+
550
+ this.ws.on('close', () => {
551
+ this.onLog('Disconnected from coordinator');
552
+ this.isConnected = false;
553
+ this._stopHeartbeat();
554
+ this.onDisconnected(this);
555
+ this._scheduleReconnect();
556
+ });
557
+
558
+ this.ws.on('error', (error) => {
559
+ this.onError(`WebSocket error: ${error.message}`);
560
+ });
561
+
562
+ } catch (e) {
563
+ this.onError(`Connection failed: ${e.message}`);
564
+ this._scheduleReconnect();
565
+ }
566
+ }
567
+
568
+ // Handle incoming messages from coordinator
569
+ _handleMessage(message) {
570
+ switch (message.type) {
571
+ case 'welcome':
572
+ this.workerId = message.workerId;
573
+ this.onLog(`Registered as worker ${this.workerId.substring(0, 12)}...`);
574
+ if (message.config) {
575
+ // Update cache config from server
576
+ if (message.config.maxSizeMB) {
577
+ this.cache.setMaxSize(message.config.maxSizeMB * 1024 * 1024);
578
+ }
579
+ if (message.config.maxAgeDays) {
580
+ this.cache.setMaxAgeDays(message.config.maxAgeDays);
581
+ }
582
+ }
583
+ break;
584
+
585
+ case 'cache_get':
586
+ // Coordinator asking for a cached value
587
+ this._handleCacheGet(message.requestId, message.key);
588
+ break;
589
+
590
+ case 'cache_set':
591
+ // Coordinator sending a value to cache
592
+ this._handleCacheSet(message.key, message.value, message.ttl);
593
+ break;
594
+
595
+ case 'cache_invalidate':
596
+ // Invalidate a cache key
597
+ if (message.key) {
598
+ this.cache._evict(message.key, 'invalidate');
599
+ this.onLog(`Cache invalidated: ${message.key.substring(0, 30)}...`);
600
+ }
601
+ break;
602
+
603
+ case 'config_update':
604
+ // Update cache configuration at runtime
605
+ if (message.maxSizeMB) {
606
+ this.cache.setMaxSize(message.maxSizeMB * 1024 * 1024);
607
+ this.onLog(`Cache size limit updated to ${message.maxSizeMB}MB`);
608
+ }
609
+ if (message.maxAgeDays) {
610
+ this.cache.setMaxAgeDays(message.maxAgeDays);
611
+ this.onLog(`Cache max age updated to ${message.maxAgeDays} days`);
612
+ }
613
+ break;
614
+
615
+ case 'ping':
616
+ this._send({ type: 'pong' });
617
+ break;
618
+
619
+ default:
620
+ this.onLog(`Unknown message type: ${message.type}`);
621
+ }
622
+ }
623
+
624
+ // Handle cache get request from coordinator
625
+ _handleCacheGet(requestId, key) {
626
+ const value = this.cache.get(key);
627
+
628
+ if (value) {
629
+ this.stats.cacheHits++;
630
+ this.stats.requestsServed++;
631
+ this.onCacheHit(key);
632
+
633
+ this._send({
634
+ type: 'cache_response',
635
+ requestId,
636
+ hit: true,
637
+ value
638
+ });
639
+
640
+ this.onLog(`Cache HIT: ${key.substring(0, 30)}...`);
641
+ } else {
642
+ this.stats.cacheMisses++;
643
+
644
+ this._send({
645
+ type: 'cache_response',
646
+ requestId,
647
+ hit: false
648
+ });
649
+ }
650
+ }
651
+
652
+ // Handle cache set from coordinator
653
+ _handleCacheSet(key, value, ttl) {
654
+ const evictedKeys = this.cache.set(key, value, ttl);
655
+
656
+ // Notify coordinator about stored key
657
+ this._send({
658
+ type: 'cache_stored',
659
+ key,
660
+ cacheStats: this.cache.getStats()
661
+ });
662
+
663
+ // Notify coordinator about evicted keys (already sent via onEvict callback)
664
+ // The SmartCache handles this via the onEvict callback
665
+
666
+ this.stats.cacheStored++;
667
+ this.onCacheStore(key);
668
+
669
+ const cacheStats = this.cache.getStats();
670
+ this.onLog(`Cached: ${key.substring(0, 30)}... (${cacheStats.entries} entries, ${cacheStats.sizeFormatted})`);
671
+ }
672
+
673
+ // Send cache registry to coordinator
674
+ _sendCacheRegistry() {
675
+ const keys = this.cache.keys();
676
+ this._send({
677
+ type: 'register_cache',
678
+ keys
679
+ });
680
+ this.onLog(`Registered ${keys.length} cached keys with coordinator`);
681
+ }
682
+
683
+ // Send message to coordinator
684
+ _send(message) {
685
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
686
+ this.ws.send(JSON.stringify(message));
687
+ }
688
+ }
689
+
690
+ // Start heartbeat interval
691
+ _startHeartbeat() {
692
+ this._stopHeartbeat();
693
+ this.heartbeatInterval = setInterval(async () => {
694
+ await this._sendHeartbeat();
695
+ }, 30000); // 30 seconds
696
+
697
+ // Send initial heartbeat
698
+ this._sendHeartbeat();
699
+ }
700
+
701
+ // Stop heartbeat
702
+ _stopHeartbeat() {
703
+ if (this.heartbeatInterval) {
704
+ clearInterval(this.heartbeatInterval);
705
+ this.heartbeatInterval = null;
706
+ }
707
+ }
708
+
709
+ // Send heartbeat to coordinator
710
+ async _sendHeartbeat() {
711
+ const cpuUsage = await this.getCpuUsage();
712
+ const systemInfo = this.getSystemInfo();
713
+
714
+ // Send to WebSocket coordinator
715
+ this._send({
716
+ type: 'heartbeat',
717
+ cacheSize: this.cache.size(),
718
+ stats: this.stats,
719
+ cpu_usage: cpuUsage
720
+ });
721
+
722
+ // Also send to HTTP dashboard for tier tracking
723
+ if (this.workerToken) {
724
+ this._sendHttpHeartbeat(cpuUsage, systemInfo);
725
+ }
726
+ }
727
+
728
+ // Send heartbeat to HTTP dashboard
729
+ _sendHttpHeartbeat(cpuUsage, systemInfo) {
730
+ const data = JSON.stringify({
731
+ worker_token: this.workerToken,
732
+ system_info: {
733
+ ...systemInfo,
734
+ cpu_usage: cpuUsage,
735
+ memory_usage: Math.round((1 - os.freemem() / os.totalmem()) * 100 * 10) / 10,
736
+ session_core_hours: this._calculateCoreHours()
737
+ }
738
+ });
739
+
740
+ const url = new URL('/api/worker/heartbeat', this.dashboardUrl);
741
+ const isHttps = url.protocol === 'https:';
742
+
743
+ const options = {
744
+ hostname: url.hostname,
745
+ port: url.port || (isHttps ? 443 : 80),
746
+ path: url.pathname,
747
+ method: 'POST',
748
+ headers: {
749
+ 'Content-Type': 'application/json',
750
+ 'Content-Length': Buffer.byteLength(data)
751
+ }
752
+ };
753
+
754
+ const req = (isHttps ? https : http).request(options, (res) => {
755
+ let body = '';
756
+ res.on('data', chunk => body += chunk);
757
+ res.on('end', () => {
758
+ try {
759
+ const response = JSON.parse(body);
760
+ if (response.tier) {
761
+ this.currentTier = response.tier;
762
+ }
763
+ } catch (e) {
764
+ // Ignore parse errors
765
+ }
766
+ });
767
+ });
768
+
769
+ req.on('error', () => {
770
+ // Silent fail on heartbeat errors
771
+ });
772
+
773
+ req.write(data);
774
+ req.end();
775
+ }
776
+
777
+ // Calculate core-hours for this session
778
+ _calculateCoreHours() {
779
+ const cpus = os.cpus();
780
+ const cores = cpus.length;
781
+ const cpuSpeed = cpus[0]?.speed || 2400;
782
+ const speedFactor = cpuSpeed / 3000;
783
+ const uptimeHours = (Date.now() - this.startTime) / (1000 * 60 * 60);
784
+ const avgUsage = 0.5; // Assume 50% average usage
785
+
786
+ return cores * speedFactor * avgUsage * uptimeHours;
787
+ }
788
+
789
+ // Schedule reconnection
790
+ _scheduleReconnect() {
791
+ if (this.reconnectTimeout) {
792
+ clearTimeout(this.reconnectTimeout);
793
+ }
794
+
795
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
796
+ this.onError('Max reconnection attempts reached');
797
+ return;
798
+ }
799
+
800
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
801
+ this.reconnectAttempts++;
802
+
803
+ this.onLog(`Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
804
+
805
+ this.reconnectTimeout = setTimeout(() => {
806
+ this.connect();
807
+ }, delay);
808
+ }
809
+
810
+ // Disconnect from coordinator
811
+ disconnect() {
812
+ this._stopHeartbeat();
813
+
814
+ // Stop cache cleanup process
815
+ this.cache.stopCleanup();
816
+
817
+ if (this.reconnectTimeout) {
818
+ clearTimeout(this.reconnectTimeout);
819
+ this.reconnectTimeout = null;
820
+ }
821
+
822
+ if (this.ws) {
823
+ this.ws.close();
824
+ this.ws = null;
825
+ }
826
+
827
+ this.isConnected = false;
828
+ this.onLog('Disconnected');
829
+ }
830
+
831
+ // Get worker stats
832
+ getStats() {
833
+ const cacheStats = this.cache.getStats();
834
+ return {
835
+ workerId: this.workerId,
836
+ isConnected: this.isConnected,
837
+ uptime: Date.now() - this.startTime,
838
+ // Worker-level stats
839
+ cacheHits: this.stats.cacheHits,
840
+ cacheMisses: this.stats.cacheMisses,
841
+ cacheStored: this.stats.cacheStored,
842
+ requestsServed: this.stats.requestsServed,
843
+ hitRate: this.stats.cacheHits + this.stats.cacheMisses > 0
844
+ ? Math.round((this.stats.cacheHits / (this.stats.cacheHits + this.stats.cacheMisses)) * 100)
845
+ : 0,
846
+ // Cache-level stats
847
+ cache: cacheStats
848
+ };
849
+ }
850
+
851
+ // Get cache configuration
852
+ getCacheConfig() {
853
+ return {
854
+ maxSizeBytes: this.cache.maxSizeBytes,
855
+ maxSizeFormatted: this.cache._formatBytes(this.cache.maxSizeBytes),
856
+ maxAgeDays: this.cache.maxAgeDays,
857
+ currentSizeBytes: this.cache.currentSizeBytes,
858
+ currentSizeFormatted: this.cache._formatBytes(this.cache.currentSizeBytes),
859
+ entries: this.cache.size(),
860
+ utilizationPercent: Math.round((this.cache.currentSizeBytes / this.cache.maxSizeBytes) * 100 * 10) / 10
861
+ };
862
+ }
863
+
864
+ // Set cache size limit (MB)
865
+ setCacheSize(sizeMB) {
866
+ this.cache.setMaxSize(sizeMB * 1024 * 1024);
867
+ this.onLog(`Cache size limit set to ${sizeMB}MB`);
868
+ }
869
+
870
+ // Set cache max age (days)
871
+ setCacheMaxAge(days) {
872
+ this.cache.setMaxAgeDays(days);
873
+ this.onLog(`Cache max age set to ${days} days`);
874
+ }
875
+ }
876
+
877
+ // Factory function
878
+ function createWorker(options) {
879
+ return new SearchWorker(options);
880
+ }
881
+
882
+ module.exports = {
883
+ SearchWorker,
884
+ SmartCache,
885
+ LRUCache, // Backward compatible
886
+ createWorker
887
+ };