Skip to content

Commit 67c840f

Browse files
fix(powersync): applyTransaction hangs forever when a transaction mixes delete+insert on one collection for a same-millisecond tie (#1649)
Fix for applyTransaction hangs forever when a transaction mixes delete+insert on one collection Co-authored-by: stevensJourney <51082125+stevensJourney@users.noreply.github.com> Co-authored-by: stevensJourney <steven@journeyapps.com>
1 parent 38682e4 commit 67c840f

3 files changed

Lines changed: 72 additions & 2 deletions

File tree

.changeset/hot-bats-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/powersync-db-collection': patch
3+
---
4+
5+
Fix: applyTransaction hangs forever when a transaction mixes delete+insert on one collection for a same-millisecond tie.

packages/powersync-db-collection/src/PowerSyncTransactor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ export class PowerSyncTransactor {
284284

285285
// Need to get the operation in order to wait for it
286286
const diffOperation = await context.get<{ id: string; timestamp: string }>(
287-
sanitizeSQL`SELECT id, timestamp FROM ${trackedTableName} ORDER BY timestamp DESC LIMIT 1`,
287+
sanitizeSQL`SELECT id, timestamp FROM ${trackedTableName} ORDER BY operation_id DESC LIMIT 1`,
288288
)
289289
return {
290290
tableName,

packages/powersync-db-collection/tests/powersync.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'
1717
import { powerSyncCollectionOptions } from '../src'
1818
import { PowerSyncTransactor } from '../src/PowerSyncTransactor'
1919
import { TEST_DATABASE_IMPLEMENTATION } from './test-db-implementation'
20-
import type { AbstractPowerSyncDatabase } from '@powersync/node'
20+
import type { AbstractPowerSyncDatabase, LockContext } from '@powersync/node'
21+
import type { PendingMutation } from '@tanstack/db'
2122

2223
const APP_SCHEMA = new Schema({
2324
users: new Table({
@@ -275,6 +276,70 @@ describePowerSync(`PowerSync Integration`, () => {
275276
).true
276277
})
277278

279+
it(`should complete transactions that delete one key and insert another (same-millisecond tie)`, async () => {
280+
const db = await createDatabase()
281+
282+
const options = powerSyncCollectionOptions({
283+
database: db,
284+
table: APP_SCHEMA.props.documents,
285+
})
286+
const collection = createCollection(options)
287+
onTestFinished(() => collection.cleanup())
288+
await collection.stateWhenReady()
289+
290+
// Seed the row which will be deleted in the transaction
291+
await collection.insert({ id: `a`, name: `row a` }).isPersisted.promise
292+
293+
const { trackedTableName } = options.utils.getMeta()
294+
295+
class SameMillisecondTransactor extends PowerSyncTransactor {
296+
protected override async handleDelete(
297+
mutation: PendingMutation<any>,
298+
context: LockContext,
299+
waitForCompletion?: boolean,
300+
) {
301+
const result = await super.handleDelete(
302+
mutation,
303+
context,
304+
waitForCompletion,
305+
)
306+
// Simulate the same-millisecond tie: ensure the delete's diff row
307+
// wins the `ORDER BY timestamp DESC` readback of the insert below.
308+
await context.execute(
309+
`UPDATE ${trackedTableName} SET timestamp = '9999-12-31T23:59:59.999Z'`,
310+
)
311+
return result
312+
}
313+
}
314+
315+
const transactor = new SameMillisecondTransactor({ database: db })
316+
317+
const tx = createTransaction({
318+
autoCommit: false,
319+
mutationFn: async ({ transaction }) => {
320+
await transactor.applyTransaction(transaction)
321+
},
322+
})
323+
324+
tx.mutate(() => {
325+
collection.delete(`a`)
326+
collection.insert({ id: `b`, name: `row b` })
327+
})
328+
329+
const outcome = await Promise.race([
330+
tx.commit().then(() => `persisted` as const),
331+
new Promise<`timed out`>((resolve) =>
332+
setTimeout(() => resolve(`timed out`), 2_000),
333+
),
334+
])
335+
expect(outcome).toBe(`persisted`)
336+
337+
const documents = await db.getAll<{ id: string }>(
338+
`SELECT id FROM documents`,
339+
)
340+
expect(documents.map((doc) => doc.id)).toEqual([`b`])
341+
})
342+
278343
it(`should handle transactions with multiple collections`, async () => {
279344
const db = await createDatabase()
280345
await createTestData(db)

0 commit comments

Comments
 (0)