Skip to content

Commit 17869e7

Browse files
authored
fix: Fix createDiffTrigger deadlock when called with a setupContext on single-connection platforms (#989)
1 parent b9cc03d commit 17869e7

5 files changed

Lines changed: 55 additions & 5 deletions

File tree

.changeset/plenty-goats-relax.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@powersync/common': minor
3+
---
4+
5+
Fix `createDiffTrigger` acquiring its own read lock before running setup, even when a `setupContext` was provided.
6+
On platforms where read and write access share a single connection (e.g. web), this deadlocked when `createDiffTrigger` was called inside a write lock.

packages/common/api-extractor.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
*
7777
* DEFAULT VALUE: "crlf"
7878
*/
79-
// "newlineKind": "crlf",
79+
"newlineKind": "lf",
8080

8181
/**
8282
* Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output

packages/common/etc/common.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2228,7 +2228,7 @@ export class TriggerManagerImpl implements TriggerManager {
22282228
// (undocumented)
22292229
protected generateTriggerName(operation: DiffTriggerOperation, destinationTable: string, triggerId: string): string;
22302230
// (undocumented)
2231-
protected getUUID(): Promise<string>;
2231+
protected getUUID(ctx?: LockContext): Promise<string>;
22322232
// (undocumented)
22332233
protected isDisposed: boolean;
22342234
// Warning: (ae-forgotten-export) The symbol "TriggerManagerImplOptions" needs to be exported by the entry point index.d.ts

packages/common/src/client/triggers/TriggerManagerImpl.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ export class TriggerManagerImpl implements TriggerManager {
9797
return this.options.db;
9898
}
9999

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

240-
const id = await this.getUUID();
240+
const id = await this.getUUID(setupContext);
241241

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

packages/web/tests/triggers.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,50 @@ describe('Triggers', () => {
190190
);
191191
});
192192

193+
it('should create a trigger inside an already-held write lock via setupContext', async () => {
194+
const db = generateTestDb();
195+
196+
const destination = 'temp_setup_context_diff';
197+
198+
// Mirrors the on-demand sync path: the caller holds the write lock and passes
199+
// its context in as setupContext. createDiffTrigger must not acquire any
200+
// additional locks, since read and write access share a single connection
201+
// queue on web — a nested lock request deadlocks against the held write lock.
202+
const triggerCreated = db.writeLock(async (tx) =>
203+
db.triggers.createDiffTrigger({
204+
source: TEST_SCHEMA.props.customers.name,
205+
destination,
206+
when: {
207+
[DiffTriggerOperation.INSERT]: 'TRUE'
208+
},
209+
setupContext: tx
210+
})
211+
);
212+
213+
const dispose = await Promise.race([
214+
triggerCreated,
215+
new Promise<never>((_, reject) =>
216+
setTimeout(
217+
() =>
218+
reject(
219+
new Error('Deadlock: createDiffTrigger requested a new lock while the setupContext write lock was held')
220+
),
221+
5_000
222+
)
223+
)
224+
]);
225+
226+
onTestFinished(() => dispose());
227+
228+
// Sanity check that the trigger is functional
229+
await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'setup-context')");
230+
231+
await vi.waitFor(async () => {
232+
const rows = await db.getAll(`SELECT * FROM ${destination}`);
233+
expect(rows.length).toEqual(1);
234+
});
235+
});
236+
193237
it('should report diff operations across clients (insert from client B observed by client A)', async () => {
194238
const openDB = (filename: string) =>
195239
generateTestDb({

0 commit comments

Comments
 (0)