Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
55 changes: 55 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,56 @@ 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)) {
throw new Error(
`PGlite data directory "${this.rootDir}" may be in use by another process (PID ${pid}). ` +
`Close the other instance or use a different data directory. ` +
`If PID ${pid} is no longer running or no longer needs pglite, remove or move the stale lock: mv ${lockPath} ${lockPath}.stale.${Date.now()}`,
)
}
} catch (e) {
// Re-throw lock errors, ignore parse errors (corrupt lock file = stale)
if (e instanceof Error && e.message.includes('may be 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)
}
}
}

async closeFs(): Promise<void> {
this.#releaseLock()
this.pg!.Module.FS.quit()
}
}
86 changes: 86 additions & 0 deletions packages/pglite/tests/nodefs-lock.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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('may be 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 throw for a stale lock and allow reopening after manual deletion', 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 throw - user must decide whether to delete the lock
let lockError = null
try {
const db = new PGlite(dataDir)
await db.waitReady
await db.close()
} catch (err) {
lockError = err
}

expect(lockError).not.toBeNull()
expect(lockError.message).toContain('may be in use')
expect(lockError.message).toContain('999999')

// After manually removing the stale lock, reopening should work
rmSync(dataDir + '.lock', { force: true })
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)
})
Loading