Skip to content

Commit e87bc35

Browse files
committed
feat(database): enhance SQLite adapter for network filesystem support
1 parent 66606f4 commit e87bc35

2 files changed

Lines changed: 100 additions & 13 deletions

File tree

src/db/index.ts

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,67 @@
66

77
import { SqliteDatabase, SqliteBackend, createDatabase } from './sqlite-adapter';
88
import * as fs from 'fs';
9+
import * as os from 'os';
910
import * as path from 'path';
1011
import { SchemaVersion } from '../types';
1112
import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations';
1213

1314
export { SqliteDatabase, SqliteBackend } from './sqlite-adapter';
1415

16+
/**
17+
* Detect whether a file path lives on a network filesystem (CIFS/NFS/etc.).
18+
*
19+
* On Linux, reads /proc/mounts and finds the deepest matching mount point.
20+
* On macOS, checks for /Volumes/ paths (network mounts land there by default).
21+
* On Windows, UNC paths (\\server\share) are always network.
22+
*
23+
* Returns false on any parse error so the caller degrades gracefully.
24+
*/
25+
function isNetworkFilesystem(filePath: string): boolean {
26+
const platform = os.platform();
27+
const resolved = path.resolve(filePath);
28+
29+
if (platform === 'linux') {
30+
try {
31+
const mounts = fs.readFileSync('/proc/mounts', 'utf-8');
32+
let bestMount = '';
33+
let bestFsType = '';
34+
for (const line of mounts.split('\n')) {
35+
const parts = line.trim().split(/\s+/);
36+
if (parts.length < 3) continue;
37+
const mountPoint = parts[1] as string;
38+
const fsType = parts[2] as string;
39+
if (resolved.startsWith(mountPoint + '/') || resolved === mountPoint) {
40+
if (mountPoint.length > bestMount.length) {
41+
bestMount = mountPoint;
42+
bestFsType = fsType;
43+
}
44+
}
45+
}
46+
const networkTypes = new Set([
47+
'cifs', 'smbfs', 'smb2', 'nfs', 'nfs4', 'nfs3',
48+
'davfs', 'fuse.sshfs', 'fuse.rclone', 'fuse.s3fs',
49+
'ncpfs', 'afs', 'coda', 'glusterfs', 'lustre',
50+
]);
51+
return networkTypes.has(bestFsType.toLowerCase());
52+
} catch {
53+
return false;
54+
}
55+
}
56+
57+
if (platform === 'darwin') {
58+
// Network mounts typically appear under /Volumes or /net
59+
return resolved.startsWith('/Volumes/') || resolved.startsWith('/net/');
60+
}
61+
62+
if (platform === 'win32') {
63+
// UNC paths: \\server\share\...
64+
return resolved.startsWith('\\\\');
65+
}
66+
67+
return false;
68+
}
69+
1570
/**
1671
* Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two
1772
* paths can't drift.
@@ -25,15 +80,36 @@ export { SqliteDatabase, SqliteBackend } from './sqlite-adapter';
2580
* 2-minute wait presented as a frozen, hung agent. With WAL, reads never block
2681
* on a writer, so this timeout only governs cross-process write contention
2782
* (e.g. the git-hook `codegraph sync` running while the MCP server writes).
83+
*
84+
* On network filesystems (CIFS/NFS/…) WAL mode is skipped: those mounts do
85+
* not support the shared-memory files (-wal/-shm) that WAL requires, and
86+
* mmap I/O is unreliable over the network. MEMORY journal avoids all disk
87+
* I/O for journaling; synchronous=OFF is safe because the index is fully
88+
* rebuildable from source.
2889
*/
29-
function configureConnection(db: SqliteDatabase): void {
30-
db.pragma('busy_timeout = 5000'); // MUST be first — see above
90+
function configureConnection(db: SqliteDatabase, dbPath: string): void {
91+
const networkFs = isNetworkFilesystem(dbPath);
92+
93+
// busy_timeout MUST come first — lets subsequent pragmas wait out any lock
94+
db.pragma(networkFs ? 'busy_timeout = 30000' : 'busy_timeout = 5000');
3195
db.pragma('foreign_keys = ON');
32-
db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform
33-
db.pragma('synchronous = NORMAL'); // safe with WAL mode
96+
97+
if (networkFs) {
98+
// WAL needs shared-memory files unsupported on most network mounts.
99+
// MEMORY journal avoids all file I/O for journaling (DELETE journal still
100+
// tries to create a -journal file on disk, which fails on CIFS/NFS).
101+
// synchronous=OFF is safe here: the index is fully rebuildable from source.
102+
db.pragma('journal_mode = MEMORY');
103+
db.pragma('synchronous = OFF');
104+
// skip mmap — unreliable on network filesystems
105+
} else {
106+
db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform
107+
db.pragma('synchronous = NORMAL'); // safe with WAL mode
108+
db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O
109+
}
110+
34111
db.pragma('cache_size = -64000'); // 64 MB page cache
35112
db.pragma('temp_store = MEMORY'); // temp tables in memory
36-
db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O
37113
}
38114

39115
/**
@@ -61,9 +137,10 @@ export class DatabaseConnection {
61137
}
62138

63139
// Create and configure database
64-
const { db, backend } = createDatabase(dbPath);
140+
const nolock = isNetworkFilesystem(dbPath);
141+
const { db, backend } = createDatabase(dbPath, { nolock });
65142

66-
configureConnection(db);
143+
configureConnection(db, dbPath);
67144

68145
// Run schema initialization
69146
const schemaPath = path.join(__dirname, 'schema.sql');
@@ -89,9 +166,10 @@ export class DatabaseConnection {
89166
throw new Error(`Database not found: ${dbPath}`);
90167
}
91168

92-
const { db, backend } = createDatabase(dbPath);
169+
const nolock = isNetworkFilesystem(dbPath);
170+
const { db, backend } = createDatabase(dbPath, { nolock });
93171

94-
configureConnection(db);
172+
configureConnection(db, dbPath);
95173

96174
// Check and run migrations if needed
97175
const conn = new DatabaseConnection(db, dbPath, backend);

src/db/sqlite-adapter.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,16 @@ export type SqliteBackend = 'node-sqlite';
4343
class NodeSqliteAdapter implements SqliteDatabase {
4444
private _db: any;
4545

46-
constructor(dbPath: string) {
46+
constructor(dbPath: string, options: { nolock?: boolean } = {}) {
4747
// eslint-disable-next-line @typescript-eslint/no-require-imports
4848
const { DatabaseSync } = require('node:sqlite');
49-
this._db = new DatabaseSync(dbPath);
49+
// On network filesystems (CIFS/NFS) fcntl() locks are unreliable.
50+
// nolock=1 bypasses SQLite's file-locking protocol entirely; safe when
51+
// only one process accesses the database at a time (codegraph's typical use).
52+
const openPath = options.nolock
53+
? `file://${dbPath}?nolock=1&mode=rwc`
54+
: dbPath;
55+
this._db = new DatabaseSync(openPath);
5056
}
5157

5258
get open(): boolean {
@@ -123,10 +129,13 @@ class NodeSqliteAdapter implements SqliteDatabase {
123129
* Returns the active backend alongside the db so each `DatabaseConnection` can
124130
* report it per-instance — MCP can open multiple project DBs in one process, so
125131
* a process-global would race.
132+
*
133+
* Pass `{ nolock: true }` when the database lives on a network filesystem
134+
* (CIFS/NFS) where fcntl() locking is unreliable.
126135
*/
127-
export function createDatabase(dbPath: string): { db: SqliteDatabase; backend: SqliteBackend } {
136+
export function createDatabase(dbPath: string, options: { nolock?: boolean } = {}): { db: SqliteDatabase; backend: SqliteBackend } {
128137
try {
129-
return { db: new NodeSqliteAdapter(dbPath), backend: 'node-sqlite' };
138+
return { db: new NodeSqliteAdapter(dbPath, options), backend: 'node-sqlite' };
130139
} catch (error) {
131140
const msg = error instanceof Error ? error.message : String(error);
132141
throw new Error(

0 commit comments

Comments
 (0)