electric-sql / electric-sql/pglite

[BUG]: PGlite suppresses synchronization while inside a transaction

Open
#1,065 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
TypeScript
Stars
16k
Forks
442
Avg merge
20h 19m
Merged PRs (30d)
7

Description

**Describe the bug**
## Summary

When `relaxedDurability: false`, successful top-level `query()` and `exec()` calls await `syncToFs()` before returning. `transaction()` does not provide the equivalent synchronization boundary.

Inside a transaction, `#runQuery()` and `#runExec()` correctly suppress synchronization while `#inTransaction` is `true`. However, `transaction()` executes `COMMIT` while that flag is still true, then clears the flag and returns without calling `syncToFs()`.

**To Reproduce** - include code sample(s)

As a result, this can resolve without synchronizing the filesystem:

```ts
await pg.transaction(async (tx) => {
await tx.exec("INSERT INTO example ...");
});

// No syncToFs() has occurred after COMMIT.
```

**Details**
- PGlite version: 0.5.4

**Additional context**

BEGIN currently causes a sync before #inTransaction is set, but there is no sync after the transaction’s filesystem changes.

## Impact

This is significant for custom filesystem implementations where `syncToFs()` is the awaited durability and health boundary.

Even with `relaxedDurability: false`:

- `await pg.transaction(...)` can resolve before the committed transaction is persisted by the filesystem.
- Worker, tab, or process termination immediately afterwards can lose work that callers reasonably believed had crossed the same boundary as a successful `query()` or `exec()`.
- Filesystem synchronization failures or latched health failures are not delivered until a later top-level operation calls `syncToFs()`.

This makes transaction completion behave differently from other successful PGlite operations.

## Relevant control flow

`#runQuery()` and `#runExec()` contain:

```ts
if (!this.#inTransaction) {
await this.syncToFs();
}
```

But the successful transaction path is effectively:

```ts
await this.#runExec("COMMIT"); // #inTransaction is still true
this.#inTransaction = false;
return result; // No syncToFs()
```

Explicit rollback and exception-driven rollback have the same missing terminal synchronization boundary.

## Expected behavior

Before `transaction()` resolves, its terminal path should perform the normal awaited filesystem synchronization after leaving the in-transaction state.

Ideally, this should happen while the transaction exclusivity boundary is still held, so another operation cannot interleave between the transaction ending and its filesystem sync.

The relevant paths are:

- successful `COMMIT`;
- explicit `tx.rollback()`;
- exception-driven `ROLLBACK`.

Error precedence on the rollback/exception path may need an explicit decision, but the filesystem should at least receive the terminal sync or health check before transaction exclusivity is released.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with transaction(), #runQuery(), #runExec(), and syncToFs() to trace the successful COMMIT, explicit rollback, and exception-driven rollback paths. Confirm that each terminal path leaves the transaction state before awaiting filesystem synchronization while exclusivity remains held, and decide the required error precedence for rollback failures.

Written by the indexing model from the issue text.

Assessment

Tech stack
postgres, typescript
Domain
databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.