Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-nodefs-lock-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-sql/pglite': patch
---

Add data directory locking to NodeFS to prevent multi-process corruption
65 changes: 65 additions & 0 deletions packages/pglite/src/fs/nodefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { EmscriptenBuiltinFilesystem, PGDATA } from './base.js'
import type { PostgresMod } from '../postgresMod.js'
import { PGlite } from '../pglite.js'

// TODO: Add locking for browser backends via Web Locks API

export class NodeFS extends EmscriptenBuiltinFilesystem {
protected rootDir: string
#lockFd: number | null = null

constructor(dataDir: string) {
super(dataDir)
Expand All @@ -17,6 +20,9 @@ export class NodeFS extends EmscriptenBuiltinFilesystem {

async init(pg: PGlite, opts: Partial<PostgresMod>) {
this.pg = pg

this.#acquireLock()

const options: Partial<PostgresMod> = {
...opts,
preRun: [
Expand All @@ -31,7 +37,66 @@ export class NodeFS extends EmscriptenBuiltinFilesystem {
return { emscriptenOpts: options }
}

// Lock file is a sibling (mydb.lock) to avoid polluting the PG data dir
#acquireLock() {
const lockPath = this.rootDir + '.lock'

if (fs.existsSync(lockPath)) {
try {
const content = fs.readFileSync(lockPath, 'utf-8').trim()
const lines = content.split('\n')
const pid = parseInt(lines[0], 10)

if (pid && !isNaN(pid) && this.#isProcessAlive(pid)) {
throw new Error(
`PGlite data directory "${this.rootDir}" is already in use by another instance (PID ${pid}). ` +
`Close the other instance or use a different data directory. ` +
`Delete "${lockPath}" if PID ${pid} is no longer running.`,
)
}
// Stale lock from a dead process — safe to take over
} catch (e) {
// Re-throw lock errors, ignore parse errors (corrupt lock file = stale)
if (e instanceof Error && e.message.includes('already in use')) {
throw e
}
}
}

// Write our PID to the lock file and keep the fd open
this.#lockFd = fs.openSync(lockPath, 'w')
fs.writeSync(this.#lockFd, `${process.pid}\n${Date.now()}\n`)
}

#releaseLock() {
if (this.#lockFd !== null) {
try {
fs.closeSync(this.#lockFd)
} catch {
// Ignore errors on close
}
this.#lockFd = null

const lockPath = this.rootDir + '.lock'
try {
fs.unlinkSync(lockPath)
} catch {
// Ignore errors on unlink (dir may already be cleaned up)
}
}
}

#isProcessAlive(pid: number): boolean {
Comment thread
tdrz marked this conversation as resolved.
Outdated
try {
process.kill(pid, 0) // signal 0 = check if process exists
return true
} catch {
return false // ESRCH = process doesn't exist
}
}

async closeFs(): Promise<void> {
this.#releaseLock()
this.pg!.Module.FS.quit()
}
}
71 changes: 71 additions & 0 deletions packages/pglite/tests/nodefs-lock.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, afterAll } from 'vitest'
import { existsSync, writeFileSync, rmSync } from 'node:fs'

const dataDir = `/tmp/pglite-lock-test-${Date.now()}`

afterAll(async () => {
if (!process.env.RETAIN_DATA) {
for (const p of [dataDir, dataDir + '.lock']) {
if (existsSync(p)) rmSync(p, { recursive: true, force: true })
}
}
})

describe('NodeFS data directory locking', () => {
it('should block a second instance from opening the same data directory', async () => {
const { PGlite } = await import('../dist/index.js')

const db1 = new PGlite(dataDir)
await db1.waitReady

// Lock file should exist while db1 is open
expect(existsSync(dataDir + '.lock')).toBe(true)

// Second instance on same dir must throw
let lockError = null
try {
const db2 = new PGlite(dataDir)
await db2.waitReady
await db2.close()
} catch (err) {
lockError = err
}

expect(lockError).not.toBeNull()
expect(lockError.message).toContain('already in use')
expect(lockError.message).toContain(String(process.pid))

// First instance should still work fine
const result = await db1.query('SELECT 1 as ok')
expect(result.rows[0].ok).toBe(1)

await db1.close()
}, 30000)

it('should allow reopening after the first instance is closed', async () => {
const { PGlite } = await import('../dist/index.js')

// Lock file should be cleaned up after close
expect(existsSync(dataDir + '.lock')).toBe(false)

const db = new PGlite(dataDir)
await db.waitReady
const result = await db.query('SELECT 1 as ok')
expect(result.rows[0].ok).toBe(1)
await db.close()
}, 30000)

it('should override a stale lock from a dead process', async () => {
const { PGlite } = await import('../dist/index.js')

// Write a fake lock file with a PID that doesn't exist
writeFileSync(dataDir + '.lock', '999999\n0\n')

// Should succeed — stale lock gets overridden
const db = new PGlite(dataDir)
await db.waitReady
const result = await db.query('SELECT 1 as ok')
expect(result.rows[0].ok).toBe(1)
await db.close()
}, 30000)
})