@avalw/search-worker 1.1.0 → 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 +320 -358
- package/bin/cli.js +239 -38
- package/index.js +887 -0
- package/package.json +15 -22
- package/LICENSE +0 -21
- package/examples/basic-usage.js +0 -32
- package/examples/express-integration.js +0 -73
- package/examples/search-api.js +0 -58
- package/lib/worker.js +0 -319
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
|
+
};
|