@joystick.js/db-canary 0.0.0-canary.2275 → 0.0.0-canary.2276
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 +87 -104
- package/debug_test_runner.js +208 -0
- package/dist/server/index.js +1 -1
- package/dist/server/lib/operation_dispatcher.js +1 -1
- package/dist/server/lib/operations/admin.js +1 -1
- package/dist/server/lib/simple_sync_manager.js +1 -0
- package/dist/server/lib/sync_receiver.js +1 -0
- package/full_debug_test_runner.js +197 -0
- package/package.json +2 -2
- package/src/server/index.js +25 -24
- package/src/server/lib/operation_dispatcher.js +16 -10
- package/src/server/lib/operations/admin.js +64 -31
- package/src/server/lib/simple_sync_manager.js +444 -0
- package/src/server/lib/sync_receiver.js +461 -0
- package/tests/server/lib/simple_sync_system.test.js +124 -0
- package/dist/server/lib/replication_manager.js +0 -1
- package/dist/server/lib/write_forwarder.js +0 -1
- package/src/server/lib/replication_manager.js +0 -727
- package/src/server/lib/write_forwarder.js +0 -636
- package/tests/server/lib/replication_manager.test.js +0 -202
- package/tests/server/lib/write_forwarder.test.js +0 -258
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Sync receiver for secondary nodes in JoystickDB.
|
|
3
|
+
* Receives and processes sync messages from primary nodes with API_KEY authentication.
|
|
4
|
+
* Secondary nodes are read-only except for authenticated sync operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import net from 'net';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import { get_settings } from './load_settings.js';
|
|
10
|
+
import { create_message_parser, encode_message } from './tcp_protocol.js';
|
|
11
|
+
import create_logger from './logger.js';
|
|
12
|
+
import insert_one from './operations/insert_one.js';
|
|
13
|
+
import update_one from './operations/update_one.js';
|
|
14
|
+
import delete_one from './operations/delete_one.js';
|
|
15
|
+
import delete_many from './operations/delete_many.js';
|
|
16
|
+
import bulk_write from './operations/bulk_write.js';
|
|
17
|
+
import create_index_operation from './operations/create_index.js';
|
|
18
|
+
import drop_index_operation from './operations/drop_index.js';
|
|
19
|
+
|
|
20
|
+
const { create_context_logger } = create_logger('sync_receiver');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sync receiver that processes authenticated sync messages from primary nodes.
|
|
24
|
+
* Validates API_KEY and applies operations to local database.
|
|
25
|
+
*/
|
|
26
|
+
class SyncReceiver {
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new SyncReceiver instance.
|
|
29
|
+
*/
|
|
30
|
+
constructor() {
|
|
31
|
+
/** @type {boolean} Whether this node is configured as secondary */
|
|
32
|
+
this.is_secondary = false;
|
|
33
|
+
|
|
34
|
+
/** @type {string|null} API_KEY loaded from file for authentication */
|
|
35
|
+
this.api_key = null;
|
|
36
|
+
|
|
37
|
+
/** @type {string|null} Path to API_KEY file */
|
|
38
|
+
this.api_key_file_path = null;
|
|
39
|
+
|
|
40
|
+
/** @type {net.Server|null} TCP server for receiving sync messages */
|
|
41
|
+
this.server = null;
|
|
42
|
+
|
|
43
|
+
/** @type {number} TCP port for sync operations */
|
|
44
|
+
this.sync_port = 1985;
|
|
45
|
+
|
|
46
|
+
/** @type {Object} Logger instance */
|
|
47
|
+
this.log = create_context_logger();
|
|
48
|
+
|
|
49
|
+
/** @type {Object} Sync statistics */
|
|
50
|
+
this.stats = {
|
|
51
|
+
total_received: 0,
|
|
52
|
+
successful_syncs: 0,
|
|
53
|
+
failed_syncs: 0,
|
|
54
|
+
auth_failures: 0,
|
|
55
|
+
operations_applied: 0
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Initializes the sync receiver with settings configuration.
|
|
61
|
+
*/
|
|
62
|
+
async initialize() {
|
|
63
|
+
try {
|
|
64
|
+
const settings = get_settings();
|
|
65
|
+
|
|
66
|
+
if (settings.primary === true) {
|
|
67
|
+
this.log.info('Node configured as primary - sync receiver disabled');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (settings.primary === false) {
|
|
72
|
+
this.is_secondary = true;
|
|
73
|
+
this.api_key_file_path = settings.secondary_sync_key;
|
|
74
|
+
this.sync_port = settings.sync_port || 1985;
|
|
75
|
+
|
|
76
|
+
if (!this.api_key_file_path) {
|
|
77
|
+
this.log.error('Secondary node missing secondary_sync_key configuration');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await this.load_api_key();
|
|
82
|
+
|
|
83
|
+
if (!this.api_key) {
|
|
84
|
+
this.log.error('Failed to load API_KEY - sync receiver disabled');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.log.info('Initializing sync receiver for secondary node', {
|
|
89
|
+
api_key_file: this.api_key_file_path,
|
|
90
|
+
sync_port: this.sync_port
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
this.start_server();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.log.warn('Could not initialize sync receiver - settings not loaded', {
|
|
98
|
+
error: error.message
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Loads API_KEY from configured file path.
|
|
105
|
+
*/
|
|
106
|
+
async load_api_key() {
|
|
107
|
+
try {
|
|
108
|
+
const key_content = await fs.readFile(this.api_key_file_path, 'utf8');
|
|
109
|
+
this.api_key = key_content.trim();
|
|
110
|
+
|
|
111
|
+
this.log.info('API_KEY loaded successfully', {
|
|
112
|
+
file_path: this.api_key_file_path,
|
|
113
|
+
key_length: this.api_key.length
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
} catch (error) {
|
|
117
|
+
this.log.error('Failed to load API_KEY from file', {
|
|
118
|
+
file_path: this.api_key_file_path,
|
|
119
|
+
error: error.message
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Starts the TCP server to receive sync messages.
|
|
126
|
+
*/
|
|
127
|
+
start_server() {
|
|
128
|
+
try {
|
|
129
|
+
this.server = net.createServer((socket) => {
|
|
130
|
+
this.log.debug('Sync connection established', {
|
|
131
|
+
remote_address: socket.remoteAddress,
|
|
132
|
+
remote_port: socket.remotePort
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const message_parser = create_message_parser();
|
|
136
|
+
|
|
137
|
+
socket.on('data', (data) => {
|
|
138
|
+
try {
|
|
139
|
+
const messages = message_parser.parse_messages(data);
|
|
140
|
+
for (const message of messages) {
|
|
141
|
+
this.handle_sync_message(socket, message).catch((error) => {
|
|
142
|
+
this.log.error('Failed to handle sync message', {
|
|
143
|
+
error: error.message,
|
|
144
|
+
remote_address: socket.remoteAddress
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
this.log.error('Failed to parse sync message', {
|
|
150
|
+
error: error.message,
|
|
151
|
+
remote_address: socket.remoteAddress
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
socket.on('error', (error) => {
|
|
157
|
+
this.log.error('Sync connection error', {
|
|
158
|
+
error: error.message,
|
|
159
|
+
remote_address: socket.remoteAddress
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
socket.on('close', () => {
|
|
164
|
+
this.log.debug('Sync connection closed', {
|
|
165
|
+
remote_address: socket.remoteAddress
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
this.server.listen(this.sync_port, () => {
|
|
171
|
+
this.log.info('Sync receiver server started', {
|
|
172
|
+
port: this.sync_port
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
this.server.on('error', (error) => {
|
|
177
|
+
this.log.error('Sync receiver server error', {
|
|
178
|
+
error: error.message,
|
|
179
|
+
port: this.sync_port
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this.log.error('Failed to start sync receiver server', {
|
|
184
|
+
error: error.message,
|
|
185
|
+
port: this.sync_port
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Handles incoming sync message from primary node.
|
|
192
|
+
* @param {net.Socket} socket - Connection socket
|
|
193
|
+
* @param {Object|string} raw_message - Raw sync message
|
|
194
|
+
*/
|
|
195
|
+
async handle_sync_message(socket, raw_message) {
|
|
196
|
+
this.stats.total_received++;
|
|
197
|
+
|
|
198
|
+
let message;
|
|
199
|
+
try {
|
|
200
|
+
message = typeof raw_message === 'string' ? JSON.parse(raw_message) : raw_message;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.send_sync_response(socket, null, 'error', 'Invalid JSON message');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { type, api_key, sequence, operation, collection, data } = message;
|
|
207
|
+
|
|
208
|
+
if (type !== 'operation_sync') {
|
|
209
|
+
this.send_sync_response(socket, sequence, 'error', 'Invalid message type');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate API_KEY
|
|
214
|
+
if (!this.validate_api_key(api_key)) {
|
|
215
|
+
this.stats.auth_failures++;
|
|
216
|
+
this.log.error('Sync authentication failed', {
|
|
217
|
+
sequence,
|
|
218
|
+
operation,
|
|
219
|
+
remote_address: socket.remoteAddress
|
|
220
|
+
});
|
|
221
|
+
this.send_sync_response(socket, sequence, 'auth_failed', 'Invalid API_KEY');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Apply operation to local database
|
|
226
|
+
try {
|
|
227
|
+
await this.apply_sync_operation(operation, collection, data);
|
|
228
|
+
this.stats.successful_syncs++;
|
|
229
|
+
this.stats.operations_applied++;
|
|
230
|
+
|
|
231
|
+
this.log.debug('Sync operation applied successfully', {
|
|
232
|
+
sequence,
|
|
233
|
+
operation,
|
|
234
|
+
collection
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
this.send_sync_response(socket, sequence, 'success', null);
|
|
238
|
+
|
|
239
|
+
} catch (error) {
|
|
240
|
+
this.stats.failed_syncs++;
|
|
241
|
+
|
|
242
|
+
this.log.error('Failed to apply sync operation', {
|
|
243
|
+
sequence,
|
|
244
|
+
operation,
|
|
245
|
+
collection,
|
|
246
|
+
error: error.message
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
this.send_sync_response(socket, sequence, 'error', error.message);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Validates API_KEY from sync message.
|
|
255
|
+
* @param {string} provided_key - API_KEY from sync message
|
|
256
|
+
* @returns {boolean} True if key is valid
|
|
257
|
+
*/
|
|
258
|
+
validate_api_key(provided_key) {
|
|
259
|
+
if (!provided_key || !this.api_key) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return provided_key === this.api_key;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Applies sync operation to local database.
|
|
268
|
+
* @param {string} operation - Operation type
|
|
269
|
+
* @param {string} collection - Collection name
|
|
270
|
+
* @param {Object} data - Operation data
|
|
271
|
+
*/
|
|
272
|
+
async apply_sync_operation(operation, collection, data) {
|
|
273
|
+
const database_name = data.database || 'default';
|
|
274
|
+
|
|
275
|
+
switch (operation) {
|
|
276
|
+
case 'insert_one':
|
|
277
|
+
return await insert_one(database_name, collection, data.document, data.options);
|
|
278
|
+
|
|
279
|
+
case 'update_one':
|
|
280
|
+
return await update_one(database_name, collection, data.filter, data.update, data.options);
|
|
281
|
+
|
|
282
|
+
case 'delete_one':
|
|
283
|
+
return await delete_one(database_name, collection, data.filter, data.options);
|
|
284
|
+
|
|
285
|
+
case 'delete_many':
|
|
286
|
+
return await delete_many(database_name, collection, data.filter, data.options);
|
|
287
|
+
|
|
288
|
+
case 'bulk_write':
|
|
289
|
+
return await bulk_write(database_name, collection, data.operations, data.options);
|
|
290
|
+
|
|
291
|
+
case 'create_index':
|
|
292
|
+
return await create_index_operation(database_name, collection, data.field, data.options);
|
|
293
|
+
|
|
294
|
+
case 'drop_index':
|
|
295
|
+
return await drop_index_operation(database_name, collection, data.field);
|
|
296
|
+
|
|
297
|
+
default:
|
|
298
|
+
throw new Error(`Unsupported sync operation: ${operation}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Sends sync response back to primary node.
|
|
304
|
+
* @param {net.Socket} socket - Connection socket
|
|
305
|
+
* @param {number|null} sequence - Sequence number
|
|
306
|
+
* @param {string} status - Response status
|
|
307
|
+
* @param {string|null} error - Error message if any
|
|
308
|
+
*/
|
|
309
|
+
send_sync_response(socket, sequence, status, error) {
|
|
310
|
+
const response = {
|
|
311
|
+
type: 'sync_acknowledged',
|
|
312
|
+
sequence,
|
|
313
|
+
status,
|
|
314
|
+
timestamp: Date.now()
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
if (error) {
|
|
318
|
+
response.error = error;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const encoded_response = encode_message(response);
|
|
323
|
+
socket.write(encoded_response);
|
|
324
|
+
} catch (write_error) {
|
|
325
|
+
this.log.error('Failed to send sync response', {
|
|
326
|
+
sequence,
|
|
327
|
+
status,
|
|
328
|
+
error: write_error.message
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Checks if a client operation should be blocked on secondary.
|
|
335
|
+
* @param {string} operation_type - Type of operation
|
|
336
|
+
* @returns {boolean} True if operation should be blocked
|
|
337
|
+
*/
|
|
338
|
+
should_block_client_operation(operation_type) {
|
|
339
|
+
if (!this.is_secondary) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Allow read operations on secondary
|
|
344
|
+
const read_operations = ['find', 'find_one', 'count_documents', 'get_indexes'];
|
|
345
|
+
|
|
346
|
+
if (read_operations.includes(operation_type)) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Block all write operations for direct clients
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Gets current sync receiver status and statistics.
|
|
356
|
+
* @returns {Object} Sync receiver status
|
|
357
|
+
*/
|
|
358
|
+
get_sync_status() {
|
|
359
|
+
return {
|
|
360
|
+
is_secondary: this.is_secondary,
|
|
361
|
+
api_key_loaded: !!this.api_key,
|
|
362
|
+
api_key_file: this.api_key_file_path,
|
|
363
|
+
server_running: !!this.server && this.server.listening,
|
|
364
|
+
sync_port: this.sync_port,
|
|
365
|
+
stats: this.stats
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Reloads API_KEY from file (for key rotation).
|
|
371
|
+
*/
|
|
372
|
+
async reload_api_key() {
|
|
373
|
+
if (!this.api_key_file_path) {
|
|
374
|
+
throw new Error('No API_KEY file path configured');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const old_key_length = this.api_key ? this.api_key.length : 0;
|
|
378
|
+
await this.load_api_key();
|
|
379
|
+
|
|
380
|
+
this.log.info('API_KEY reloaded', {
|
|
381
|
+
old_key_length,
|
|
382
|
+
new_key_length: this.api_key ? this.api_key.length : 0
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Promotes secondary to primary (manual failover support).
|
|
388
|
+
*/
|
|
389
|
+
promote_to_primary() {
|
|
390
|
+
if (!this.is_secondary) {
|
|
391
|
+
throw new Error('Node is not configured as secondary');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.log.info('Promoting secondary to primary');
|
|
395
|
+
|
|
396
|
+
// Stop sync receiver server
|
|
397
|
+
if (this.server) {
|
|
398
|
+
this.server.close(() => {
|
|
399
|
+
this.log.info('Sync receiver server stopped for primary promotion');
|
|
400
|
+
});
|
|
401
|
+
this.server = null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
this.is_secondary = false;
|
|
405
|
+
|
|
406
|
+
this.log.info('Node promoted to primary - sync receiver disabled');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Shuts down the sync receiver and closes server.
|
|
411
|
+
* @returns {Promise<void>}
|
|
412
|
+
*/
|
|
413
|
+
async shutdown() {
|
|
414
|
+
this.log.info('Shutting down sync receiver');
|
|
415
|
+
|
|
416
|
+
if (this.server) {
|
|
417
|
+
return new Promise((resolve) => {
|
|
418
|
+
this.server.close(() => {
|
|
419
|
+
this.log.info('Sync receiver server closed');
|
|
420
|
+
resolve();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
this.log.info('Sync receiver shutdown complete');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** @type {SyncReceiver|null} Singleton instance */
|
|
430
|
+
let sync_receiver_instance = null;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Gets the sync receiver singleton instance.
|
|
434
|
+
* @returns {SyncReceiver} Sync receiver instance
|
|
435
|
+
*/
|
|
436
|
+
export const get_sync_receiver = () => {
|
|
437
|
+
if (!sync_receiver_instance) {
|
|
438
|
+
sync_receiver_instance = new SyncReceiver();
|
|
439
|
+
}
|
|
440
|
+
return sync_receiver_instance;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Initializes the sync receiver singleton.
|
|
445
|
+
* @returns {Promise<void>}
|
|
446
|
+
*/
|
|
447
|
+
export const initialize_sync_receiver = async () => {
|
|
448
|
+
const receiver = get_sync_receiver();
|
|
449
|
+
await receiver.initialize();
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Shuts down the sync receiver singleton.
|
|
454
|
+
* @returns {Promise<void>}
|
|
455
|
+
*/
|
|
456
|
+
export const shutdown_sync_receiver = async () => {
|
|
457
|
+
if (sync_receiver_instance) {
|
|
458
|
+
await sync_receiver_instance.shutdown();
|
|
459
|
+
sync_receiver_instance = null;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Basic tests for the simplified primary/secondary sync system.
|
|
3
|
+
* Tests API_KEY authentication, sync operations, and basic functionality.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import test from 'ava';
|
|
7
|
+
import { get_simple_sync_manager } from '../../../src/server/lib/simple_sync_manager.js';
|
|
8
|
+
import { get_sync_receiver } from '../../../src/server/lib/sync_receiver.js';
|
|
9
|
+
|
|
10
|
+
test('simple sync manager should initialize correctly', t => {
|
|
11
|
+
const sync_manager = get_simple_sync_manager();
|
|
12
|
+
|
|
13
|
+
t.truthy(sync_manager);
|
|
14
|
+
t.is(typeof sync_manager.initialize, 'function');
|
|
15
|
+
t.is(typeof sync_manager.queue_sync, 'function');
|
|
16
|
+
t.is(typeof sync_manager.get_sync_status, 'function');
|
|
17
|
+
t.is(typeof sync_manager.shutdown, 'function');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('sync receiver should initialize correctly', t => {
|
|
21
|
+
const sync_receiver = get_sync_receiver();
|
|
22
|
+
|
|
23
|
+
t.truthy(sync_receiver);
|
|
24
|
+
t.is(typeof sync_receiver.initialize, 'function');
|
|
25
|
+
t.is(typeof sync_receiver.should_block_client_operation, 'function');
|
|
26
|
+
t.is(typeof sync_receiver.get_sync_status, 'function');
|
|
27
|
+
t.is(typeof sync_receiver.shutdown, 'function');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('sync receiver should block write operations on secondary nodes', t => {
|
|
31
|
+
const sync_receiver = get_sync_receiver();
|
|
32
|
+
|
|
33
|
+
// Mock secondary mode
|
|
34
|
+
sync_receiver.is_secondary = true;
|
|
35
|
+
|
|
36
|
+
// Should block write operations
|
|
37
|
+
t.true(sync_receiver.should_block_client_operation('insert_one'));
|
|
38
|
+
t.true(sync_receiver.should_block_client_operation('update_one'));
|
|
39
|
+
t.true(sync_receiver.should_block_client_operation('delete_one'));
|
|
40
|
+
t.true(sync_receiver.should_block_client_operation('bulk_write'));
|
|
41
|
+
|
|
42
|
+
// Should allow read operations
|
|
43
|
+
t.false(sync_receiver.should_block_client_operation('find'));
|
|
44
|
+
t.false(sync_receiver.should_block_client_operation('find_one'));
|
|
45
|
+
t.false(sync_receiver.should_block_client_operation('count_documents'));
|
|
46
|
+
t.false(sync_receiver.should_block_client_operation('get_indexes'));
|
|
47
|
+
|
|
48
|
+
// Reset
|
|
49
|
+
sync_receiver.is_secondary = false;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('sync receiver should not block operations on primary nodes', t => {
|
|
53
|
+
const sync_receiver = get_sync_receiver();
|
|
54
|
+
|
|
55
|
+
// Mock primary mode (default)
|
|
56
|
+
sync_receiver.is_secondary = false;
|
|
57
|
+
|
|
58
|
+
// Should not block any operations on primary
|
|
59
|
+
t.false(sync_receiver.should_block_client_operation('insert_one'));
|
|
60
|
+
t.false(sync_receiver.should_block_client_operation('update_one'));
|
|
61
|
+
t.false(sync_receiver.should_block_client_operation('find'));
|
|
62
|
+
t.false(sync_receiver.should_block_client_operation('find_one'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('simple sync manager should provide status information', t => {
|
|
66
|
+
const sync_manager = get_simple_sync_manager();
|
|
67
|
+
const status = sync_manager.get_sync_status();
|
|
68
|
+
|
|
69
|
+
t.is(typeof status, 'object');
|
|
70
|
+
t.is(typeof status.is_primary, 'boolean');
|
|
71
|
+
t.is(typeof status.secondary_count, 'number');
|
|
72
|
+
t.is(typeof status.stats, 'object');
|
|
73
|
+
t.is(typeof status.secondaries, 'object');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('sync receiver should provide status information', t => {
|
|
77
|
+
const sync_receiver = get_sync_receiver();
|
|
78
|
+
const status = sync_receiver.get_sync_status();
|
|
79
|
+
|
|
80
|
+
t.is(typeof status, 'object');
|
|
81
|
+
t.is(typeof status.is_secondary, 'boolean');
|
|
82
|
+
t.is(typeof status.api_key_loaded, 'boolean');
|
|
83
|
+
t.is(typeof status.server_running, 'boolean');
|
|
84
|
+
t.is(typeof status.sync_port, 'number');
|
|
85
|
+
t.is(typeof status.stats, 'object');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('sync manager should handle empty secondary nodes list', t => {
|
|
89
|
+
const sync_manager = get_simple_sync_manager();
|
|
90
|
+
|
|
91
|
+
// Should not throw when updating with empty list
|
|
92
|
+
t.notThrows(() => {
|
|
93
|
+
sync_manager.update_secondary_nodes([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('sync receiver should handle manual promotion to primary', t => {
|
|
98
|
+
const sync_receiver = get_sync_receiver();
|
|
99
|
+
|
|
100
|
+
// Mock secondary mode
|
|
101
|
+
sync_receiver.is_secondary = true;
|
|
102
|
+
sync_receiver.server = { close: () => {} }; // Mock server
|
|
103
|
+
|
|
104
|
+
// Should be able to promote to primary
|
|
105
|
+
t.notThrows(() => {
|
|
106
|
+
sync_receiver.promote_to_primary();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Should now be in primary mode
|
|
110
|
+
t.false(sync_receiver.is_secondary);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test.after.always(async t => {
|
|
114
|
+
// Clean up any resources
|
|
115
|
+
try {
|
|
116
|
+
const sync_manager = get_simple_sync_manager();
|
|
117
|
+
const sync_receiver = get_sync_receiver();
|
|
118
|
+
|
|
119
|
+
await sync_manager.shutdown();
|
|
120
|
+
await sync_receiver.shutdown();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// Ignore cleanup errors in tests
|
|
123
|
+
}
|
|
124
|
+
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import p from"net";import u from"crypto";import{get_settings as g}from"./load_settings.js";import{encode_message as d,create_message_parser as m}from"./tcp_protocol.js";import y from"./logger.js";const{create_context_logger:f}=y("replication");class w{constructor(){this.secondary_connections=new Map,this.replication_queue=[],this.processing_replication=!1,this.sequence_number=0,this.enabled=!1,this.mode="async",this.timeout_ms=5e3,this.retry_attempts=3,this.batch_size=100,this.log=f(),this.stats={total_operations_replicated:0,successful_replications:0,failed_replications:0,connected_secondaries:0,avg_replication_latency_ms:0,total_replication_time_ms:0}}initialize(){try{const e=g().replication;if(!e||!e.enabled){this.log.info("Replication disabled in settings");return}this.enabled=e.enabled,this.mode=e.mode||"async",this.timeout_ms=e.timeout_ms||5e3,this.retry_attempts=e.retry_attempts||3,this.batch_size=e.batch_size||100;const n=e.secondaries||[];this.log.info("Initializing replication manager",{enabled:this.enabled,mode:this.mode,secondary_count:n.length,timeout_ms:this.timeout_ms,batch_size:this.batch_size});for(const s of n)s.enabled&&this.connect_to_secondary(s);this.start_replication_processing()}catch(t){this.log.warn("Could not initialize replication - settings not loaded",{error:t.message})}}async connect_to_secondary(t){const{id:e,ip:n,port:s,private_key:i}=t;this.log.info("Connecting to secondary node",{id:e,ip:n,port:s});try{const o=new p.Socket,l=m();o.connect(s,n,()=>{this.log.info("Connected to secondary node",{id:e,ip:n,port:s}),this.authenticate_with_secondary(o,e,i)}),o.on("data",a=>{try{const c=l.parse_messages(a);for(const _ of c)this.handle_secondary_response(e,_)}catch(c){this.log.error("Failed to parse secondary response",{secondary_id:e,error:c.message})}}),o.on("error",a=>{this.log.error("Secondary connection error",{secondary_id:e,error:a.message}),this.secondary_connections.delete(e),this.stats.connected_secondaries=this.secondary_connections.size,setTimeout(()=>{this.connect_to_secondary(t)},5e3)}),o.on("close",()=>{this.log.warn("Secondary connection closed",{secondary_id:e}),this.secondary_connections.delete(e),this.stats.connected_secondaries=this.secondary_connections.size,setTimeout(()=>{this.connect_to_secondary(t)},5e3)}),this.secondary_connections.set(e,{socket:o,id:e,ip:n,port:s,authenticated:!1,last_ping:Date.now(),pending_operations:new Map}),this.stats.connected_secondaries=this.secondary_connections.size}catch(o){this.log.error("Failed to connect to secondary",{secondary_id:e,ip:n,port:s,error:o.message})}}authenticate_with_secondary(t,e,n){try{const s=Date.now(),i=`${e}:${s}`,o=u.createHmac("sha256",Buffer.from(n,"base64")).update(i).digest("base64"),a=d({type:"replication_auth",node_id:e,timestamp:s,signature:o});t.write(a),this.log.debug("Sent authentication to secondary",{secondary_id:e})}catch(s){this.log.error("Failed to authenticate with secondary",{secondary_id:e,error:s.message})}}handle_secondary_response(t,e){const n=this.secondary_connections.get(t);if(n)try{const s=typeof e=="string"?JSON.parse(e):e;switch(s.type){case"auth_success":n.authenticated=!0,this.log.info("Secondary authentication successful",{secondary_id:t});break;case"auth_failed":this.log.error("Secondary authentication failed",{secondary_id:t}),n.socket.end();break;case"replication_ack":this.handle_replication_acknowledgment(t,s);break;case"ping_response":n.last_ping=Date.now();break;default:this.log.debug("Unknown message from secondary",{secondary_id:t,type:s.type})}}catch(s){this.log.error("Failed to handle secondary response",{secondary_id:t,error:s.message})}}handle_replication_acknowledgment(t,e){const n=this.secondary_connections.get(t);if(!n)return;const{sequence_number:s,status:i,error:o}=e,l=n.pending_operations.get(s);if(l){const a=Date.now()-l.sent_at;this.stats.total_replication_time_ms+=a,i==="success"?(this.stats.successful_replications++,this.log.debug("Replication acknowledged",{secondary_id:t,sequence_number:s,latency_ms:a})):(this.stats.failed_replications++,this.log.error("Replication failed on secondary",{secondary_id:t,sequence_number:s,error:o,latency_ms:a})),n.pending_operations.delete(s);const c=this.stats.successful_replications+this.stats.failed_replications;this.stats.avg_replication_latency_ms=c>0?Math.round(this.stats.total_replication_time_ms/c):0}}queue_replication(t,e,n,s=null){if(!this.enabled||this.secondary_connections.size===0)return;const i={operation:t,collection:e,data:n,transaction_id:s,timestamp:Date.now(),sequence_number:++this.sequence_number};this.replication_queue.push(i),this.stats.total_operations_replicated++,this.log.debug("Queued operation for replication",{operation:t,collection:e,sequence_number:i.sequence_number,queue_length:this.replication_queue.length}),this.processing_replication||setImmediate(()=>this.process_replication_queue())}start_replication_processing(){this.processing_replication||(this.processing_replication=!0,this.process_replication_queue())}async process_replication_queue(){for(;this.replication_queue.length>0&&this.enabled;){const t=this.replication_queue.splice(0,this.batch_size);if(t.length===0)break;await this.replicate_batch(t),this.replication_queue.length>0&&await new Promise(e=>setTimeout(e,10))}this.replication_queue.length>0&&setImmediate(()=>this.process_replication_queue())}async replicate_batch(t){const e={type:"replication",timestamp:Date.now(),sequence_number:t[0].sequence_number,operations:t},n=d(e),s=Array.from(this.secondary_connections.values()).filter(i=>i.authenticated);if(s.length===0){this.log.warn("No authenticated secondaries available for replication");return}this.log.debug("Replicating batch to secondaries",{batch_size:t.length,secondary_count:s.length,sequence_number:t[0].sequence_number});for(const i of s)try{i.socket.write(n);for(const o of t)i.pending_operations.set(o.sequence_number,{operation:o,sent_at:Date.now()})}catch(o){this.log.error("Failed to send replication to secondary",{secondary_id:i.id,error:o.message})}}async add_secondary(t){const{id:e}=t;if(this.secondary_connections.has(e))throw new Error(`Secondary node ${e} already exists`);return this.log.info("Adding new secondary node",{id:e}),await this.connect_to_secondary(t),{success:!0,message:`Secondary node ${e} added successfully`,secondary_id:e}}remove_secondary(t){const e=this.secondary_connections.get(t);if(!e)throw new Error(`Secondary node ${t} not found`);return this.log.info("Removing secondary node",{secondary_id:t}),e.socket.end(),this.secondary_connections.delete(t),this.stats.connected_secondaries=this.secondary_connections.size,{success:!0,message:`Secondary node ${t} removed successfully`,secondary_id:t}}async sync_secondaries(){if(!this.enabled)throw new Error("Replication is not enabled");this.log.info("Forcing secondary synchronization"),await this.process_replication_queue();const t={type:"ping",timestamp:Date.now()},e=d(t),n=[];for(const[s,i]of this.secondary_connections)try{i.authenticated?(i.socket.write(e),n.push({secondary_id:s,status:"ping_sent"})):n.push({secondary_id:s,status:"not_authenticated"})}catch(o){n.push({secondary_id:s,status:"error",error:o.message})}return{success:!0,message:"Secondary synchronization initiated",results:n}}get_replication_status(){const t=[];for(const[e,n]of this.secondary_connections)t.push({id:e,ip:n.ip,port:n.port,authenticated:n.authenticated,last_ping:n.last_ping,pending_operations:n.pending_operations.size,connected:!n.socket.destroyed});return{enabled:this.enabled,mode:this.mode,connected_secondaries:this.stats.connected_secondaries,queue_length:this.replication_queue.length,processing:this.processing_replication,stats:this.stats,secondaries:t}}get_secondary_health(){const t=[],e=Date.now();for(const[n,s]of this.secondary_connections){const i=e-s.last_ping,o=s.authenticated&&!s.socket.destroyed&&i<3e4;t.push({id:n,ip:s.ip,port:s.port,healthy:o,authenticated:s.authenticated,connected:!s.socket.destroyed,last_ping_age_ms:i,pending_operations:s.pending_operations.size})}return{total_secondaries:t.length,healthy_secondaries:t.filter(n=>n.healthy).length,secondaries:t}}async shutdown(){this.log.info("Shutting down replication manager"),this.enabled=!1,this.processing_replication=!1;for(const[t,e]of this.secondary_connections)try{e.socket.end()}catch(n){this.log.warn("Error closing secondary connection",{secondary_id:t,error:n.message})}this.secondary_connections.clear(),this.replication_queue=[],this.stats.connected_secondaries=0,this.log.info("Replication manager shutdown complete")}}let r=null;const b=()=>(r||(r=new w),r),v=()=>{b().initialize()},R=async()=>{r&&(await r.shutdown(),r=null)};export{b as get_replication_manager,v as initialize_replication_manager,R as shutdown_replication_manager};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import h from"net";import m from"crypto";import{get_settings as p}from"./load_settings.js";import{encode_message as a,create_message_parser as l}from"./tcp_protocol.js";import f from"./logger.js";const{create_context_logger:w}=f("write_forwarder");class g{constructor(){this.primary_connection=null,this.is_secondary_mode=!1,this.primary_config=null,this.pending_forwards=new Map,this.forward_timeout_ms=1e4,this.reconnect_delay_ms=5e3,this.log=w(),this.stats={total_forwards:0,successful_forwards:0,failed_forwards:0,avg_forward_latency_ms:0,total_forward_time_ms:0,connection_attempts:0,last_connection_attempt:null}}initialize(){try{const r=p();if(r.mode!=="secondary"||!r.primary){this.log.info("Node not configured as secondary - write forwarding disabled");return}this.is_secondary_mode=!0,this.primary_config=r.primary,this.forward_timeout_ms=r.replication?.timeout_ms||1e4,this.log.info("Initializing write forwarder for secondary mode",{primary_ip:this.primary_config.ip,primary_port:this.primary_config.port,timeout_ms:this.forward_timeout_ms}),this.connect_to_primary()}catch(r){this.log.warn("Could not initialize write forwarder - settings not loaded",{error:r.message})}}async connect_to_primary(){if(!this.is_secondary_mode||!this.primary_config)return;const{ip:r,port:t,public_key:o}=this.primary_config;this.log.info("Connecting to primary node",{ip:r,port:t}),this.stats.connection_attempts++,this.stats.last_connection_attempt=Date.now();try{const e=new h.Socket,n=l();e.connect(t,r,()=>{this.log.info("Connected to primary node",{ip:r,port:t}),this.authenticate_with_primary(e,o)}),e.on("data",i=>{try{const s=n.parse_messages(i);for(const d of s)this.handle_primary_response(d)}catch(s){this.log.error("Failed to parse primary response",{error:s.message})}}),e.on("error",i=>{this.log.error("Primary connection error",{error:i.message}),this.primary_connection=null,this.fail_pending_forwards("Primary connection error"),setTimeout(()=>{this.connect_to_primary()},this.reconnect_delay_ms)}),e.on("close",()=>{this.log.warn("Primary connection closed"),this.primary_connection=null,this.fail_pending_forwards("Primary connection closed"),setTimeout(()=>{this.connect_to_primary()},this.reconnect_delay_ms)}),this.primary_connection={socket:e,authenticated:!1,last_ping:Date.now()}}catch(e){this.log.error("Failed to connect to primary",{ip:r,port:t,error:e.message}),setTimeout(()=>{this.connect_to_primary()},this.reconnect_delay_ms)}}authenticate_with_primary(r,t){try{const o=Date.now(),e=`secondary-${process.pid}`,n=`${e}:${o}`,s={op:"authentication",data:{password:m.createHmac("sha256",Buffer.from(t,"base64")).update(n).digest("base64"),node_type:"secondary",node_id:e}},d=a(s);r.write(d),this.log.debug("Sent authentication to primary")}catch(o){this.log.error("Failed to authenticate with primary",{error:o.message})}}handle_primary_response(r){if(this.primary_connection)try{const t=typeof r=="string"?JSON.parse(r):r;if(t.ok===1&&t.message==="Authentication successful"){this.primary_connection.authenticated=!0,this.log.info("Primary authentication successful");return}if(t.forward_id){this.handle_forward_response(t);return}if(t.ok===1&&!t.forward_id){this.primary_connection.last_ping=Date.now();return}this.log.debug("Unhandled primary response",{type:typeof t,keys:Object.keys(t)})}catch(t){this.log.error("Failed to handle primary response",{error:t.message})}}handle_forward_response(r){const{forward_id:t}=r,o=this.pending_forwards.get(t);if(!o){this.log.warn("Received response for unknown forward",{forward_id:t});return}const e=Date.now()-o.sent_at;this.stats.total_forward_time_ms+=e,clearTimeout(o.timeout),this.pending_forwards.delete(t);const n={...r};delete n.forward_id,r.ok===1||r.ok===!0?(this.stats.successful_forwards++,this.log.debug("Forward operation successful",{forward_id:t,latency_ms:e,operation:o.operation})):(this.stats.failed_forwards++,this.log.error("Forward operation failed",{forward_id:t,latency_ms:e,operation:o.operation,error:r.error}));const i=this.stats.successful_forwards+this.stats.failed_forwards;this.stats.avg_forward_latency_ms=i>0?Math.round(this.stats.total_forward_time_ms/i):0;try{const s=a(n);o.client_socket.write(s)}catch(s){this.log.error("Failed to send response to client",{forward_id:t,error:s.message})}}should_forward_operation(r){return this.is_secondary_mode?["insert_one","update_one","delete_one","bulk_write","create_index","drop_index"].includes(r):!1}async forward_operation(r,t,o){if(!this.is_secondary_mode||!this.should_forward_operation(t))return!1;if(!this.primary_connection||!this.primary_connection.authenticated){this.log.error("Cannot forward operation - not connected to primary",{operation:t,connected:!!this.primary_connection,authenticated:this.primary_connection?.authenticated||!1});const s=a({ok:0,error:"Secondary node not connected to primary"});return r.write(s),!0}const e=this.generate_forward_id();this.log.debug("Forwarding operation to primary",{forward_id:e,operation:t,client_id:r.id});const n={op:t,data:o,forward_id:e,forwarded_by:`secondary-${process.pid}`,original_client_id:r.id,timestamp:Date.now()};try{const i=a(n);this.primary_connection.socket.write(i);const s=setTimeout(()=>{this.handle_forward_timeout(e)},this.forward_timeout_ms);return this.pending_forwards.set(e,{client_socket:r,operation:t,data:o,sent_at:Date.now(),timeout:s}),this.stats.total_forwards++,!0}catch(i){this.log.error("Failed to forward operation",{forward_id:e,operation:t,error:i.message});const s={ok:0,error:`Failed to forward operation: ${i.message}`},d=a(s);return r.write(d),!0}}handle_forward_timeout(r){const t=this.pending_forwards.get(r);if(!t)return;this.log.error("Forward operation timed out",{forward_id:r,operation:t.operation,timeout_ms:this.forward_timeout_ms}),this.pending_forwards.delete(r),this.stats.failed_forwards++;const o=Date.now()-t.sent_at;this.stats.total_forward_time_ms+=o;const e=this.stats.successful_forwards+this.stats.failed_forwards;this.stats.avg_forward_latency_ms=e>0?Math.round(this.stats.total_forward_time_ms/e):0;try{const i=a({ok:0,error:"Operation forwarding timed out"});t.client_socket.write(i)}catch(n){this.log.error("Failed to send timeout error to client",{forward_id:r,error:n.message})}}fail_pending_forwards(r){for(const[t,o]of this.pending_forwards){clearTimeout(o.timeout),this.log.error("Failing pending forward operation",{forward_id:t,operation:o.operation,reason:r});try{const e={ok:0,error:`Forward operation failed: ${r}`},n=a(e);o.client_socket.write(n)}catch(e){this.log.error("Failed to send error to client",{forward_id:t,error:e.message})}}this.stats.failed_forwards+=this.pending_forwards.size,this.pending_forwards.clear()}generate_forward_id(){return`fwd_${Date.now()}_${Math.random().toString(36).substr(2,9)}`}get_forwarder_status(){return{enabled:this.is_secondary_mode,connected_to_primary:!!this.primary_connection&&this.primary_connection.authenticated,primary_config:this.primary_config?{ip:this.primary_config.ip,port:this.primary_config.port}:null,pending_forwards:this.pending_forwards.size,stats:this.stats}}async shutdown(){if(this.log.info("Shutting down write forwarder"),this.fail_pending_forwards("Server shutting down"),this.primary_connection){try{this.primary_connection.socket.end()}catch(r){this.log.warn("Error closing primary connection",{error:r.message})}this.primary_connection=null}this.is_secondary_mode=!1,this.log.info("Write forwarder shutdown complete")}}let c=null;const u=()=>(c||(c=new g),c),$=()=>{u().initialize()},z=async()=>{c&&(await c.shutdown(),c=null)};export{u as get_write_forwarder,$ as initialize_write_forwarder,z as shutdown_write_forwarder};
|