mrmd-sync 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +1 -1
- package/src/index.js +348 -39
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Maxime Rivest
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -24,11 +24,31 @@ import {
|
|
|
24
24
|
mkdirSync,
|
|
25
25
|
statSync,
|
|
26
26
|
rmSync,
|
|
27
|
+
readdirSync,
|
|
27
28
|
} from 'fs';
|
|
28
29
|
import { join, dirname, relative, resolve } from 'path';
|
|
29
30
|
import { createHash } from 'crypto';
|
|
30
31
|
import { tmpdir } from 'os';
|
|
31
32
|
|
|
33
|
+
const DOC_EXTENSIONS = ['.md', '.qmd'];
|
|
34
|
+
|
|
35
|
+
function getDocExtension(docName) {
|
|
36
|
+
if (!docName) return '';
|
|
37
|
+
const lower = docName.toLowerCase();
|
|
38
|
+
for (const ext of DOC_EXTENSIONS) {
|
|
39
|
+
if (lower.endsWith(ext)) return ext;
|
|
40
|
+
}
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveDocFilePath(docName, resolvedDir) {
|
|
45
|
+
const hasDocExt = !!getDocExtension(docName);
|
|
46
|
+
if (docName.startsWith('/')) {
|
|
47
|
+
return hasDocExt ? docName : `${docName}.md`;
|
|
48
|
+
}
|
|
49
|
+
return hasDocExt ? join(resolvedDir, docName) : join(resolvedDir, `${docName}.md`);
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
// =============================================================================
|
|
33
53
|
// PID FILE - Prevents multiple instances on same directory
|
|
34
54
|
// =============================================================================
|
|
@@ -279,6 +299,89 @@ const DEFAULT_CONFIG = {
|
|
|
279
299
|
// Paths that require dangerouslyAllowSystemPaths: true
|
|
280
300
|
const DANGEROUS_PATHS = ['/', '/etc', '/usr', '/var', '/bin', '/sbin', '/root', '/home'];
|
|
281
301
|
|
|
302
|
+
// =============================================================================
|
|
303
|
+
// DATA LOSS PREVENTION - Memory Monitoring
|
|
304
|
+
// =============================================================================
|
|
305
|
+
// Added after investigating unexplained data loss on 2026-01-16.
|
|
306
|
+
// The sync server crashed with OOM after ~9 minutes, consuming 4GB for a 2.5KB
|
|
307
|
+
// document. User lost ~2.5 hours of work because the editor gave no warning.
|
|
308
|
+
//
|
|
309
|
+
// These safeguards ensure:
|
|
310
|
+
// 1. Memory usage is monitored and warnings are logged
|
|
311
|
+
// 2. Y.Doc compaction runs periodically to bound memory growth
|
|
312
|
+
// 3. Server fails fast (512MB limit in electron main.js) rather than OOM later
|
|
313
|
+
// =============================================================================
|
|
314
|
+
|
|
315
|
+
const MEMORY_WARNING_MB = 200; // Warn at 200MB
|
|
316
|
+
// DISABLED: Compaction causes document duplication!
|
|
317
|
+
// When clients reconnect after compaction, Yjs merges their state with the
|
|
318
|
+
// server's fresh state, causing content to double. Need a different approach.
|
|
319
|
+
// Keeping memory monitoring for warnings only.
|
|
320
|
+
const MEMORY_COMPACT_MB = Infinity; // Disabled
|
|
321
|
+
const MEMORY_CHECK_INTERVAL_MS = 30000; // Check every 30 seconds
|
|
322
|
+
const COMPACTION_INTERVAL_MS = Infinity; // Disabled
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get current memory usage in MB
|
|
326
|
+
*/
|
|
327
|
+
function getMemoryUsageMB() {
|
|
328
|
+
const usage = process.memoryUsage();
|
|
329
|
+
return {
|
|
330
|
+
heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
|
|
331
|
+
heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
|
|
332
|
+
rss: Math.round(usage.rss / 1024 / 1024),
|
|
333
|
+
external: Math.round(usage.external / 1024 / 1024),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Compact a Y.Doc by creating a fresh doc with only current content.
|
|
339
|
+
* This discards all operation history and tombstones, dramatically reducing memory.
|
|
340
|
+
*
|
|
341
|
+
* NOTE: This will disconnect all clients, who will need to reconnect.
|
|
342
|
+
* The content itself is preserved via the file and snapshot.
|
|
343
|
+
*
|
|
344
|
+
* @param {Object} docData - The document data object from getDoc()
|
|
345
|
+
* @param {Object} log - Logger instance
|
|
346
|
+
* @returns {Object} - New Y.Doc and Y.Text
|
|
347
|
+
*/
|
|
348
|
+
function compactYDoc(docData, log) {
|
|
349
|
+
const { ydoc, ytext, docName } = docData;
|
|
350
|
+
|
|
351
|
+
// Get current content before compaction
|
|
352
|
+
const currentContent = ytext.toString();
|
|
353
|
+
const oldStateSize = Y.encodeStateAsUpdate(ydoc).length;
|
|
354
|
+
|
|
355
|
+
log.info('Compacting Y.Doc', {
|
|
356
|
+
doc: docName,
|
|
357
|
+
contentLength: currentContent.length,
|
|
358
|
+
oldStateSize,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Create fresh Y.Doc
|
|
362
|
+
const newYdoc = new Y.Doc();
|
|
363
|
+
newYdoc.name = docName;
|
|
364
|
+
const newYtext = newYdoc.getText('content');
|
|
365
|
+
|
|
366
|
+
// Insert current content into fresh doc
|
|
367
|
+
if (currentContent.length > 0) {
|
|
368
|
+
newYdoc.transact(() => {
|
|
369
|
+
newYtext.insert(0, currentContent);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const newStateSize = Y.encodeStateAsUpdate(newYdoc).length;
|
|
374
|
+
|
|
375
|
+
log.info('Y.Doc compacted', {
|
|
376
|
+
doc: docName,
|
|
377
|
+
oldStateSize,
|
|
378
|
+
newStateSize,
|
|
379
|
+
reduction: `${Math.round((1 - newStateSize / oldStateSize) * 100)}%`,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return { newYdoc, newYtext };
|
|
383
|
+
}
|
|
384
|
+
|
|
282
385
|
// =============================================================================
|
|
283
386
|
// UTILITY FUNCTIONS
|
|
284
387
|
// =============================================================================
|
|
@@ -352,6 +455,87 @@ function atomicWriteFile(filePath, content) {
|
|
|
352
455
|
}
|
|
353
456
|
}
|
|
354
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Clean up stale temp files left behind by crashed processes.
|
|
460
|
+
* Temp files have pattern: {filename}.tmp.{pid}.{timestamp}
|
|
461
|
+
* A file is stale if:
|
|
462
|
+
* - The PID no longer exists (process died)
|
|
463
|
+
* - OR the timestamp is older than maxAgeMs (fallback safety)
|
|
464
|
+
*/
|
|
465
|
+
function cleanupStaleTempFiles(dir, log, maxAgeMs = 3600000) {
|
|
466
|
+
const tmpPattern = /\.tmp\.(\d+)\.(\d+)$/;
|
|
467
|
+
let cleaned = 0;
|
|
468
|
+
let errors = 0;
|
|
469
|
+
|
|
470
|
+
function walkDir(currentDir) {
|
|
471
|
+
let entries;
|
|
472
|
+
try {
|
|
473
|
+
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
474
|
+
} catch {
|
|
475
|
+
return; // Skip directories we can't read
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const entry of entries) {
|
|
479
|
+
const fullPath = join(currentDir, entry.name);
|
|
480
|
+
|
|
481
|
+
if (entry.isDirectory()) {
|
|
482
|
+
// Skip hidden directories and node_modules
|
|
483
|
+
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
484
|
+
walkDir(fullPath);
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Check if this is a temp file
|
|
490
|
+
const match = entry.name.match(tmpPattern);
|
|
491
|
+
if (!match) continue;
|
|
492
|
+
|
|
493
|
+
const pid = parseInt(match[1], 10);
|
|
494
|
+
const timestamp = parseInt(match[2], 10);
|
|
495
|
+
const age = Date.now() - timestamp;
|
|
496
|
+
|
|
497
|
+
// Check if process is dead
|
|
498
|
+
let processIsDead = false;
|
|
499
|
+
try {
|
|
500
|
+
process.kill(pid, 0); // Signal 0 = check if process exists
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (err.code === 'ESRCH') {
|
|
503
|
+
processIsDead = true; // Process doesn't exist
|
|
504
|
+
}
|
|
505
|
+
// EPERM means process exists but we can't signal it
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Remove if process is dead OR file is older than maxAgeMs
|
|
509
|
+
if (processIsDead || age > maxAgeMs) {
|
|
510
|
+
try {
|
|
511
|
+
unlinkSync(fullPath);
|
|
512
|
+
cleaned++;
|
|
513
|
+
log.info('Removed stale temp file', {
|
|
514
|
+
path: fullPath,
|
|
515
|
+
pid,
|
|
516
|
+
processIsDead,
|
|
517
|
+
ageMs: age,
|
|
518
|
+
});
|
|
519
|
+
} catch (err) {
|
|
520
|
+
errors++;
|
|
521
|
+
log.warn('Failed to remove stale temp file', {
|
|
522
|
+
path: fullPath,
|
|
523
|
+
error: err.message,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
walkDir(dir);
|
|
531
|
+
|
|
532
|
+
if (cleaned > 0 || errors > 0) {
|
|
533
|
+
log.info('Temp file cleanup complete', { cleaned, errors });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return { cleaned, errors };
|
|
537
|
+
}
|
|
538
|
+
|
|
355
539
|
/**
|
|
356
540
|
* Save Yjs document state for crash recovery
|
|
357
541
|
*/
|
|
@@ -495,6 +679,9 @@ export function createServer(options = {}) {
|
|
|
495
679
|
// Acquire PID lock to prevent multiple instances on same directory
|
|
496
680
|
const releasePidLock = acquirePidLock(snapshotDir, port, log);
|
|
497
681
|
|
|
682
|
+
// Clean up stale temp files from previous crashed processes
|
|
683
|
+
cleanupStaleTempFiles(resolvedDir, log);
|
|
684
|
+
|
|
498
685
|
// Document storage
|
|
499
686
|
const docs = new Map();
|
|
500
687
|
|
|
@@ -521,16 +708,8 @@ export function createServer(options = {}) {
|
|
|
521
708
|
const mutex = new AsyncMutex();
|
|
522
709
|
|
|
523
710
|
// Support absolute paths for files outside the docs directory
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (docName.startsWith('/')) {
|
|
527
|
-
// Absolute path - use directly
|
|
528
|
-
isAbsolutePath = true;
|
|
529
|
-
filePath = docName.endsWith('.md') ? docName : `${docName}.md`;
|
|
530
|
-
} else {
|
|
531
|
-
// Relative path - join with docs directory
|
|
532
|
-
filePath = join(resolvedDir, docName.endsWith('.md') ? docName : `${docName}.md`);
|
|
533
|
-
}
|
|
711
|
+
const isAbsolutePath = docName.startsWith('/');
|
|
712
|
+
const filePath = resolveDocFilePath(docName, resolvedDir);
|
|
534
713
|
|
|
535
714
|
// For snapshots, always use the snapshot dir with a safe name
|
|
536
715
|
const safeSnapshotName = docName.replace(/\//g, '__').replace(/^_+/, '');
|
|
@@ -539,6 +718,21 @@ export function createServer(options = {}) {
|
|
|
539
718
|
: null;
|
|
540
719
|
|
|
541
720
|
const ytext = ydoc.getText('content');
|
|
721
|
+
const docData = {
|
|
722
|
+
docName,
|
|
723
|
+
ydoc,
|
|
724
|
+
ytext,
|
|
725
|
+
awareness,
|
|
726
|
+
conns,
|
|
727
|
+
mutex,
|
|
728
|
+
filePath,
|
|
729
|
+
snapshotPath,
|
|
730
|
+
applyFileChange: null,
|
|
731
|
+
flushWrite: null,
|
|
732
|
+
scheduleCleanup: null,
|
|
733
|
+
cancelCleanup: null,
|
|
734
|
+
scheduleWrite: null,
|
|
735
|
+
};
|
|
542
736
|
|
|
543
737
|
// Track state
|
|
544
738
|
let lastFileHash = null;
|
|
@@ -604,7 +798,7 @@ export function createServer(options = {}) {
|
|
|
604
798
|
if (isShuttingDown) return;
|
|
605
799
|
|
|
606
800
|
isWritingToFile = true;
|
|
607
|
-
const content = ytext.toString();
|
|
801
|
+
const content = docData.ytext.toString();
|
|
608
802
|
const hash = contentHash(content);
|
|
609
803
|
|
|
610
804
|
// Skip if content unchanged
|
|
@@ -614,7 +808,7 @@ export function createServer(options = {}) {
|
|
|
614
808
|
return;
|
|
615
809
|
}
|
|
616
810
|
|
|
617
|
-
const { success, error } = atomicWriteFile(filePath, content);
|
|
811
|
+
const { success, error } = atomicWriteFile(docData.filePath, content);
|
|
618
812
|
if (error) {
|
|
619
813
|
log.error('Error saving file', { path: filePath, error });
|
|
620
814
|
metrics.errorOccurred();
|
|
@@ -630,15 +824,17 @@ export function createServer(options = {}) {
|
|
|
630
824
|
}, debounceMs);
|
|
631
825
|
};
|
|
632
826
|
|
|
827
|
+
docData.scheduleWrite = scheduleWrite;
|
|
828
|
+
|
|
633
829
|
// Listen for Yjs updates
|
|
634
|
-
ydoc.on('update', scheduleWrite);
|
|
830
|
+
docData.ydoc.on('update', scheduleWrite);
|
|
635
831
|
|
|
636
832
|
// Schedule Yjs snapshot saves
|
|
637
833
|
if (persistYjsState && snapshotPath) {
|
|
638
834
|
const scheduleSnapshot = () => {
|
|
639
835
|
clearTimeout(snapshotTimeout);
|
|
640
836
|
snapshotTimeout = setTimeout(() => {
|
|
641
|
-
const { success, error } = saveYjsSnapshot(snapshotPath, ydoc);
|
|
837
|
+
const { success, error } = saveYjsSnapshot(snapshotPath, docData.ydoc);
|
|
642
838
|
if (error) {
|
|
643
839
|
log.warn('Failed to save Yjs snapshot', { doc: docName, error });
|
|
644
840
|
}
|
|
@@ -656,7 +852,7 @@ export function createServer(options = {}) {
|
|
|
656
852
|
const newHash = contentHash(newContent);
|
|
657
853
|
if (newHash === lastFileHash) return;
|
|
658
854
|
|
|
659
|
-
const oldContent = ytext.toString();
|
|
855
|
+
const oldContent = docData.ytext.toString();
|
|
660
856
|
if (oldContent === newContent) {
|
|
661
857
|
lastFileHash = newHash;
|
|
662
858
|
return;
|
|
@@ -665,14 +861,14 @@ export function createServer(options = {}) {
|
|
|
665
861
|
isWritingToYjs = true;
|
|
666
862
|
const changes = diffChars(oldContent, newContent);
|
|
667
863
|
|
|
668
|
-
ydoc.transact(() => {
|
|
864
|
+
docData.ydoc.transact(() => {
|
|
669
865
|
let pos = 0;
|
|
670
866
|
for (const change of changes) {
|
|
671
867
|
if (change.added) {
|
|
672
|
-
ytext.insert(pos, change.value);
|
|
868
|
+
docData.ytext.insert(pos, change.value);
|
|
673
869
|
pos += change.value.length;
|
|
674
870
|
} else if (change.removed) {
|
|
675
|
-
ytext.delete(pos, change.value.length);
|
|
871
|
+
docData.ytext.delete(pos, change.value.length);
|
|
676
872
|
} else {
|
|
677
873
|
pos += change.value.length;
|
|
678
874
|
}
|
|
@@ -689,10 +885,10 @@ export function createServer(options = {}) {
|
|
|
689
885
|
const flushWrite = async () => {
|
|
690
886
|
clearTimeout(writeTimeout);
|
|
691
887
|
await mutex.withLock(async () => {
|
|
692
|
-
const content = ytext.toString();
|
|
888
|
+
const content = docData.ytext.toString();
|
|
693
889
|
const hash = contentHash(content);
|
|
694
890
|
if (hash !== lastFileHash) {
|
|
695
|
-
const { error } = atomicWriteFile(filePath, content);
|
|
891
|
+
const { error } = atomicWriteFile(docData.filePath, content);
|
|
696
892
|
if (error) {
|
|
697
893
|
log.error('Error flushing file', { path: filePath, error });
|
|
698
894
|
} else {
|
|
@@ -701,7 +897,7 @@ export function createServer(options = {}) {
|
|
|
701
897
|
}
|
|
702
898
|
// Save final snapshot
|
|
703
899
|
if (snapshotPath) {
|
|
704
|
-
saveYjsSnapshot(snapshotPath, ydoc);
|
|
900
|
+
saveYjsSnapshot(snapshotPath, docData.ydoc);
|
|
705
901
|
}
|
|
706
902
|
});
|
|
707
903
|
pendingWrites.delete(docName);
|
|
@@ -714,8 +910,8 @@ export function createServer(options = {}) {
|
|
|
714
910
|
if (conns.size === 0) {
|
|
715
911
|
await flushWrite();
|
|
716
912
|
clearTimeout(snapshotTimeout);
|
|
717
|
-
awareness.destroy();
|
|
718
|
-
ydoc.destroy();
|
|
913
|
+
docData.awareness.destroy();
|
|
914
|
+
docData.ydoc.destroy();
|
|
719
915
|
docs.delete(docName);
|
|
720
916
|
log.info('Cleaned up document', { doc: docName });
|
|
721
917
|
}
|
|
@@ -726,19 +922,10 @@ export function createServer(options = {}) {
|
|
|
726
922
|
clearTimeout(cleanupTimeout);
|
|
727
923
|
};
|
|
728
924
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
conns,
|
|
734
|
-
mutex,
|
|
735
|
-
filePath,
|
|
736
|
-
snapshotPath,
|
|
737
|
-
applyFileChange,
|
|
738
|
-
flushWrite,
|
|
739
|
-
scheduleCleanup,
|
|
740
|
-
cancelCleanup,
|
|
741
|
-
};
|
|
925
|
+
docData.applyFileChange = applyFileChange;
|
|
926
|
+
docData.flushWrite = flushWrite;
|
|
927
|
+
docData.scheduleCleanup = scheduleCleanup;
|
|
928
|
+
docData.cancelCleanup = cancelCleanup;
|
|
742
929
|
|
|
743
930
|
docs.set(docName, docData);
|
|
744
931
|
return docData;
|
|
@@ -748,9 +935,18 @@ export function createServer(options = {}) {
|
|
|
748
935
|
// FILE WATCHER
|
|
749
936
|
// =============================================================================
|
|
750
937
|
|
|
751
|
-
const watcher = watch(
|
|
938
|
+
const watcher = watch([
|
|
939
|
+
join(resolvedDir, '**/*.md'),
|
|
940
|
+
join(resolvedDir, '**/*.qmd'),
|
|
941
|
+
], {
|
|
752
942
|
ignoreInitial: true,
|
|
753
943
|
awaitWriteFinish: { stabilityThreshold: 300 },
|
|
944
|
+
ignored: [
|
|
945
|
+
'**/node_modules/**',
|
|
946
|
+
'**/.venv/**',
|
|
947
|
+
'**/__pycache__/**',
|
|
948
|
+
'**/.git/**',
|
|
949
|
+
],
|
|
754
950
|
});
|
|
755
951
|
|
|
756
952
|
watcher.on('change', async (filePath) => {
|
|
@@ -770,7 +966,9 @@ export function createServer(options = {}) {
|
|
|
770
966
|
|
|
771
967
|
watcher.on('add', async (filePath) => {
|
|
772
968
|
const relativePath = relative(resolvedDir, filePath);
|
|
773
|
-
const docName = relativePath.
|
|
969
|
+
const docName = relativePath.toLowerCase().endsWith('.md')
|
|
970
|
+
? relativePath.replace(/\.md$/i, '')
|
|
971
|
+
: relativePath;
|
|
774
972
|
|
|
775
973
|
if (docs.has(docName)) {
|
|
776
974
|
const { content, error } = safeReadFile(filePath, maxFileSize);
|
|
@@ -1045,7 +1243,7 @@ export function createServer(options = {}) {
|
|
|
1045
1243
|
// GRACEFUL SHUTDOWN
|
|
1046
1244
|
// =============================================================================
|
|
1047
1245
|
|
|
1048
|
-
|
|
1246
|
+
let gracefulShutdown = async (signal) => {
|
|
1049
1247
|
if (isShuttingDown) return;
|
|
1050
1248
|
isShuttingDown = true;
|
|
1051
1249
|
|
|
@@ -1100,6 +1298,117 @@ export function createServer(options = {}) {
|
|
|
1100
1298
|
});
|
|
1101
1299
|
});
|
|
1102
1300
|
|
|
1301
|
+
// =============================================================================
|
|
1302
|
+
// DATA LOSS PREVENTION - Memory Monitoring
|
|
1303
|
+
// =============================================================================
|
|
1304
|
+
// Added after investigating unexplained data loss on 2026-01-16.
|
|
1305
|
+
// Monitors memory usage and triggers compaction if needed.
|
|
1306
|
+
// =============================================================================
|
|
1307
|
+
|
|
1308
|
+
let lastCompactionTime = Date.now();
|
|
1309
|
+
let memoryWarningLogged = false;
|
|
1310
|
+
let compactionInProgress = false;
|
|
1311
|
+
|
|
1312
|
+
const memoryMonitorInterval = setInterval(async () => {
|
|
1313
|
+
if (isShuttingDown) return;
|
|
1314
|
+
|
|
1315
|
+
const mem = getMemoryUsageMB();
|
|
1316
|
+
|
|
1317
|
+
// Log warning if memory is getting high
|
|
1318
|
+
if (mem.heapUsed >= MEMORY_WARNING_MB && !memoryWarningLogged) {
|
|
1319
|
+
log.warn('High memory usage detected', {
|
|
1320
|
+
heapUsed: `${mem.heapUsed}MB`,
|
|
1321
|
+
heapTotal: `${mem.heapTotal}MB`,
|
|
1322
|
+
rss: `${mem.rss}MB`,
|
|
1323
|
+
threshold: `${MEMORY_WARNING_MB}MB`,
|
|
1324
|
+
});
|
|
1325
|
+
memoryWarningLogged = true;
|
|
1326
|
+
} else if (mem.heapUsed < MEMORY_WARNING_MB) {
|
|
1327
|
+
memoryWarningLogged = false;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (compactionInProgress) return;
|
|
1331
|
+
|
|
1332
|
+
// Trigger compaction if memory is critical OR if enough time has passed
|
|
1333
|
+
const timeSinceLastCompaction = Date.now() - lastCompactionTime;
|
|
1334
|
+
const shouldCompact =
|
|
1335
|
+
mem.heapUsed >= MEMORY_COMPACT_MB ||
|
|
1336
|
+
timeSinceLastCompaction >= COMPACTION_INTERVAL_MS;
|
|
1337
|
+
|
|
1338
|
+
if (shouldCompact && docs.size > 0) {
|
|
1339
|
+
compactionInProgress = true;
|
|
1340
|
+
try {
|
|
1341
|
+
log.info('Triggering Y.Doc compaction', {
|
|
1342
|
+
reason: mem.heapUsed >= MEMORY_COMPACT_MB ? 'memory-pressure' : 'periodic',
|
|
1343
|
+
heapUsed: `${mem.heapUsed}MB`,
|
|
1344
|
+
docsCount: docs.size,
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
// Compact all documents
|
|
1348
|
+
for (const [docName, docData] of docs) {
|
|
1349
|
+
try {
|
|
1350
|
+
await docData.flushWrite();
|
|
1351
|
+
|
|
1352
|
+
// Disconnect all clients (they will reconnect and get fresh state)
|
|
1353
|
+
for (const ws of docData.conns) {
|
|
1354
|
+
try {
|
|
1355
|
+
ws.close(4000, 'Document compacted - please reconnect');
|
|
1356
|
+
} catch (e) {
|
|
1357
|
+
// Ignore close errors
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
docData.conns.clear();
|
|
1361
|
+
|
|
1362
|
+
// Destroy old doc and create fresh one
|
|
1363
|
+
const oldYdoc = docData.ydoc;
|
|
1364
|
+
const oldAwareness = docData.awareness;
|
|
1365
|
+
const { newYdoc, newYtext } = compactYDoc(docData, log);
|
|
1366
|
+
const newAwareness = new awarenessProtocol.Awareness(newYdoc);
|
|
1367
|
+
|
|
1368
|
+
oldYdoc.off('update', docData.scheduleWrite);
|
|
1369
|
+
|
|
1370
|
+
// Update the document entry
|
|
1371
|
+
docData.ydoc = newYdoc;
|
|
1372
|
+
docData.ytext = newYtext;
|
|
1373
|
+
docData.awareness = newAwareness;
|
|
1374
|
+
|
|
1375
|
+
// Re-register the update listener
|
|
1376
|
+
newYdoc.on('update', docData.scheduleWrite);
|
|
1377
|
+
|
|
1378
|
+
// Clean up old doc
|
|
1379
|
+
oldAwareness.destroy();
|
|
1380
|
+
oldYdoc.destroy();
|
|
1381
|
+
|
|
1382
|
+
log.info('Document compacted successfully', { doc: docName });
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
log.error('Error compacting document', { doc: docName, error: e.message });
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
lastCompactionTime = Date.now();
|
|
1389
|
+
|
|
1390
|
+
// Force garbage collection if available (--expose-gc flag)
|
|
1391
|
+
if (global.gc) {
|
|
1392
|
+
global.gc();
|
|
1393
|
+
const afterMem = getMemoryUsageMB();
|
|
1394
|
+
log.info('GC completed', {
|
|
1395
|
+
heapUsed: `${afterMem.heapUsed}MB`,
|
|
1396
|
+
freed: `${mem.heapUsed - afterMem.heapUsed}MB`,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
} finally {
|
|
1400
|
+
compactionInProgress = false;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}, MEMORY_CHECK_INTERVAL_MS);
|
|
1404
|
+
|
|
1405
|
+
// Clean up interval on shutdown
|
|
1406
|
+
const originalGracefulShutdown = gracefulShutdown;
|
|
1407
|
+
gracefulShutdown = async (signal) => {
|
|
1408
|
+
clearInterval(memoryMonitorInterval);
|
|
1409
|
+
return originalGracefulShutdown(signal);
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1103
1412
|
// =============================================================================
|
|
1104
1413
|
// PUBLIC API
|
|
1105
1414
|
// =============================================================================
|