Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/plenty-goats-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@powersync/common': minor
---

Fix `createDiffTrigger` acquiring its own read lock before running setup, even when a `setupContext` was provided.
On platforms where read and write access share a single connection (e.g. web), this deadlocked when `createDiffTrigger` was called inside a write lock.
2 changes: 1 addition & 1 deletion packages/common/api-extractor.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
*
* DEFAULT VALUE: "crlf"
*/
// "newlineKind": "crlf",
"newlineKind": "lf",

/**
* Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output
Expand Down
2 changes: 1 addition & 1 deletion packages/common/etc/common.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2228,7 +2228,7 @@ export class TriggerManagerImpl implements TriggerManager {
// (undocumented)
protected generateTriggerName(operation: DiffTriggerOperation, destinationTable: string, triggerId: string): string;
// (undocumented)
protected getUUID(): Promise<string>;
protected getUUID(ctx?: LockContext): Promise<string>;
// (undocumented)
protected isDisposed: boolean;
// Warning: (ae-forgotten-export) The symbol "TriggerManagerImplOptions" needs to be exported by the entry point index.d.ts
Expand Down
6 changes: 3 additions & 3 deletions packages/common/src/client/triggers/TriggerManagerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ export class TriggerManagerImpl implements TriggerManager {
return this.options.db;
}

protected async getUUID() {
const { id: uuid } = await this.db.get<{ id: string }>(/* sql */ `
protected async getUUID(ctx?: LockContext) {
const { id: uuid } = await (ctx ?? this.db).get<{ id: string }>(/* sql */ `
SELECT
uuid () as id
`);
Expand Down Expand Up @@ -237,7 +237,7 @@ export class TriggerManagerImpl implements TriggerManager {
const internalSource = sourceDefinition.internalName;
const triggerIds: string[] = [];

const id = await this.getUUID();
const id = await this.getUUID(setupContext);

const releaseStorageClaim = useStorage ? await this.options.claimManager.obtainClaim(id) : null;

Expand Down
44 changes: 44 additions & 0 deletions packages/web/tests/triggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,50 @@ describe('Triggers', () => {
);
});

it('should create a trigger inside an already-held write lock via setupContext', async () => {
const db = generateTestDb();

const destination = 'temp_setup_context_diff';

// Mirrors the on-demand sync path: the caller holds the write lock and passes
// its context in as setupContext. createDiffTrigger must not acquire any
// additional locks, since read and write access share a single connection
// queue on web — a nested lock request deadlocks against the held write lock.
const triggerCreated = db.writeLock(async (tx) =>
db.triggers.createDiffTrigger({
source: TEST_SCHEMA.props.customers.name,
destination,
when: {
[DiffTriggerOperation.INSERT]: 'TRUE'
},
setupContext: tx
})
);

const dispose = await Promise.race([
triggerCreated,
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error('Deadlock: createDiffTrigger requested a new lock while the setupContext write lock was held')
),
5_000
)
)
]);

onTestFinished(() => dispose());

// Sanity check that the trigger is functional
await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'setup-context')");

await vi.waitFor(async () => {
const rows = await db.getAll(`SELECT * FROM ${destination}`);
expect(rows.length).toEqual(1);
});
});

it('should report diff operations across clients (insert from client B observed by client A)', async () => {
const openDB = (filename: string) =>
generateTestDb({
Expand Down
Loading