Bug 216769 - Indexed DB transactions outdated immediately after it just created
Summary: Indexed DB transactions outdated immediately after it just created
Status: RESOLVED FIXED
Alias: None
Product: WebKit
Classification: Unclassified
Component: New Bugs (show other bugs)
Version: Safari 13
Hardware: All All
: P2 Normal
Assignee: Sihui Liu
URL:
Keywords: InRadar
: 222746 (view as bug list)
Depends on:
Blocks:
 
Reported: 2020-09-21 03:00 PDT by zjwpeter
Modified: 2021-03-12 07:21 PST (History)
14 users (show)

See Also:


Attachments
Patch (10.34 KB, patch)
2020-10-01 11:26 PDT, Sihui Liu
ews-feeder: commit-queue-
Details | Formatted Diff | Diff
Patch (6.55 KB, patch)
2020-10-01 11:41 PDT, Sihui Liu
no flags Details | Formatted Diff | Diff
Patch (10.33 KB, patch)
2020-10-01 11:47 PDT, Sihui Liu
no flags Details | Formatted Diff | Diff
Patch (11.07 KB, patch)
2021-03-09 08:45 PST, Sihui Liu
no flags Details | Formatted Diff | Diff
Patch (10.92 KB, patch)
2021-03-10 10:20 PST, Sihui Liu
no flags Details | Formatted Diff | Diff
Patch for landing (10.12 KB, patch)
2021-03-10 23:57 PST, Sihui Liu
no flags Details | Formatted Diff | Diff

Note You need to log in before you can comment on or make changes to this bug.
Description zjwpeter 2020-09-21 03:00:31 PDT
Reproduction:

Note: idb is a famous library that wrap IndexedDB API into Promise style to make it easier to use.

It does not contains high level abstractions so it is likely not the bug of idb itself.

For reference, this issue is also tracked in the https://github.com/jakearchibald/idb/issues/201

Expected result on Chrome and Firefox:
https://user-images.githubusercontent.com/5390719/93753730-358a2900-fc33-11ea-80a5-b7d4f3eb731e.png

Bad result on Safari:
https://user-images.githubusercontent.com/5390719/93753660-12f81000-fc33-11ea-94c5-7771dab160c2.png

```js
import('https://cdn.skypack.dev/idb@5.0.6/with-async-ittr').then(async (idb) => {
    await idb.deleteDB('test')
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
    const openDB = (() => {
        /** @type {import('idb').IDBPDatabase<unknown>} */
        let db = undefined
        return async () => {
            if (db) return db
            if (!db)
                db = await idb.openDB('test', 1, {
                    upgrade(db, oldVersion, newVersion, transaction) {
                        db.createObjectStore('store')
                    },
                })
            db.addEventListener('close', () => (db = undefined))
            return db
        }
    })()
    /** @type {import('idb').IDBPTransaction<unknown, ["store"]>} */
    let transaction = undefined
    const beforeTx = async () => {
        try {
            await transaction.objectStore('store').openCursor(IDBKeyRange.only('a'))
            console.log('The transaction is alive!')
        } catch (e) {
            console.log('Transaction outdated', e, 'creating new one')
            transaction = (await openDB()).transaction('store', 'readwrite')
        }
    }
    await beforeTx()
    await transaction.store.add({ a: 1 }, 'a')
    console.log('added a')
    await beforeTx()
    await transaction.store.add({ a: 1 }, 'b')
    console.log('added b')

    await sleep(120)
    await beforeTx()
    await transaction.store.add({ a: 1 }, 'c')
    console.log('added c')

    await sleep(120)
    console.log('before call beforeTx')
    await beforeTx()
    console.log('after call beforeTx')
    const cursor = await transaction.store.openCursor(IDBKeyRange.only('a'))
    console.log(cursor.value)
    const next = await cursor.continue()
    console.log(next)
})
```
Comment 1 zjwpeter 2020-09-21 03:01:47 PDT
This issues happens on Safari 12 to 14.0 (15610.1.28.1.9, 15610)
Comment 2 Radar WebKit Bug Importer 2020-09-21 12:48:04 PDT
<rdar://problem/69321075>
Comment 3 Sihui Liu 2020-09-22 16:57:41 PDT
(In reply to zjwpeter from comment #1)
> This issues happens on Safari 12 to 14.0 (15610.1.28.1.9, 15610)

Hi, 

Are you referring to the exception thrown after sleep(120)?

The behavior seems correct according to spec:
https://w3c.github.io/IndexedDB/#transaction-finished, "automatic committing". Transaction is supposed to be short-lived, and it will be automatically if there is no pending request and no request is to be created synchronously.
Comment 4 zjwpeter 2020-09-22 17:23:28 PDT
Hi I understand this idea of transaction auto commits.
You can see I use a function to check if the transaction has been outdated every time before I start a transaction operation.
My check do work, if the transaction has been outdated, it will create a new transaction immediately.

After the new transaction created there is no any synchronous operation, on the chrome and Firefox, this work well. But on the safari it seems like it is possible that the transaction is outdated immediately just after it has been created.

Please carefully check my example again, I didn't include a wrong usage of indexed DB transaction.
Comment 5 Sihui Liu 2020-09-22 19:18:30 PDT
(In reply to zjwpeter from comment #4)
> Hi I understand this idea of transaction auto commits.
> You can see I use a function to check if the transaction has been outdated
> every time before I start a transaction operation.
> My check do work, if the transaction has been outdated, it will create a new
> transaction immediately.
> 
> After the new transaction created there is no any synchronous operation, on
> the chrome and Firefox, this work well. But on the safari it seems like it
> is possible that the transaction is outdated immediately just after it has
> been created.
> 
> Please carefully check my example again, I didn't include a wrong usage of
> indexed DB transaction.

ah sorry, I misunderstood your question. You meant 
await transaction.store.add({ a: 1 }, 'c')
fails as newly created transaction by beforeTx() is inactive. This seems a real bug.

Specifically, we are deactivating transaction when vm is idle:
vm.whenIdle([protectedThis = makeRef(*this)]() {
    protectedThis->deactivate();
});

I don't know why we need this, changelog does not provide explanation either: https://trac.webkit.org/changeset/198762/webkit. Maybe we should remove this.
Comment 6 Sihui Liu 2020-09-23 09:59:03 PDT
(In reply to Sihui Liu from comment #5)
> (In reply to zjwpeter from comment #4)
> > Hi I understand this idea of transaction auto commits.
> > You can see I use a function to check if the transaction has been outdated
> > every time before I start a transaction operation.
> > My check do work, if the transaction has been outdated, it will create a new
> > transaction immediately.
> > 
> > After the new transaction created there is no any synchronous operation, on
> > the chrome and Firefox, this work well. But on the safari it seems like it
> > is possible that the transaction is outdated immediately just after it has
> > been created.
> > 
> > Please carefully check my example again, I didn't include a wrong usage of
> > indexed DB transaction.
> 
> ah sorry, I misunderstood your question. You meant 
> await transaction.store.add({ a: 1 }, 'c')
> fails as newly created transaction by beforeTx() is inactive. This seems a
> real bug.
> 
> Specifically, we are deactivating transaction when vm is idle:
> vm.whenIdle([protectedThis = makeRef(*this)]() {
>     protectedThis->deactivate();
> });
> 
> I don't know why we need this, changelog does not provide explanation
> either: https://trac.webkit.org/changeset/198762/webkit. Maybe we should
> remove this.

Another look at the spec today, https://w3c.github.io/IndexedDB/#transaction-inactive
says transaction is inactive when control returns to event loop. For this test case, IIUC, transaction being and records being added are two tasks, i.e. execute JS -> JS VM idle(deactivating transaction) -> execute JS... in this perspective, it seems right to deactivate the transaction :|. @Brady may know more as he initially added this.
Comment 7 zjwpeter 2020-09-23 17:57:48 PDT
> Another look at the spec today, https://w3c.github.io/IndexedDB/#transaction-inactive
> says transaction is inactive when control returns to event loop. For this test case, IIUC, transaction being and records being added are two tasks, i.e. execute JS -> JS VM idle(deactivating transaction) -> execute JS... in this perspective, it seems right to deactivate the transaction :|. @Brady may know more as he initially added this.


It says "A transaction is in this state(inactive) after control returns to the event loop after its creation". But Promises and async function are not join the event loop, they are microtasks.

> A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment

(https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide)
Comment 8 Sihui Liu 2020-09-23 21:40:08 PDT
(In reply to zjwpeter from comment #7)
> > Another look at the spec today, https://w3c.github.io/IndexedDB/#transaction-inactive
> > says transaction is inactive when control returns to event loop. For this test case, IIUC, transaction being and records being added are two tasks, i.e. execute JS -> JS VM idle(deactivating transaction) -> execute JS... in this perspective, it seems right to deactivate the transaction :|. @Brady may know more as he initially added this.
> 
> 
> It says "A transaction is in this state(inactive) after control returns to
> the event loop after its creation". But Promises and async function are not
> join the event loop, they are microtasks.
> 
> > A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment
> 
> (https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/
> Microtask_guide)

Thanks for the info. I confirmed transaction being created is in the same runloop task with record being added, so VM::whenIdle is definitely not what should be used here, as it does not tell us when control is returned.
Comment 9 Sihui Liu 2020-10-01 11:26:55 PDT
Created attachment 410245 [details]
Patch
Comment 10 Sihui Liu 2020-10-01 11:41:29 PDT
Created attachment 410249 [details]
Patch
Comment 11 Sihui Liu 2020-10-01 11:47:47 PDT
Created attachment 410252 [details]
Patch
Comment 12 Ryosuke Niwa 2020-10-01 20:19:40 PDT
Comment on attachment 410252 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=410252&action=review

> Source/WebCore/dom/EventLoop.cpp:71
> +void EventLoop::queueIndexedDBTask(TaskFunction&& function)

I wouldn't call this IndexedDBTask.
Rather, I'd call this queueWorkAtEndOfCurrentTask or something.

> Source/WebCore/dom/EventLoop.cpp:73
> +    m_indexedDBTaskFunctions.append(WTFMove(function));

It's possible for a task to be executing outside of EventLoop::run so we'd need to schedule it here.
Just call scheduleToRunIfNeeded

> Source/WebCore/dom/EventLoop.cpp:79
> +    auto functions = std::exchange(m_indexedDBTaskFunctions, { });
> +    for (auto& function : functions)

This isn't right. Some of these functions may be associated with a page that has gotten into a page cache or otherwise stopped.

> Source/WebCore/dom/EventLoop.cpp:180
> +    m_eventLoop->queueIndexedDBTask(WTFMove(function));

We need to create EventLoopTask here and associate that task with this group.
Comment 13 Ryosuke Niwa 2020-11-10 11:30:20 PST
Comment on attachment 410252 [details]
Patch

Don't set r- on your own patch. The right thing to do is to remove r?.
Comment 14 Sihui Liu 2021-03-09 08:45:28 PST
Created attachment 422706 [details]
Patch
Comment 15 Ryosuke Niwa 2021-03-09 15:39:10 PST
Comment on attachment 422706 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=422706&action=review

> Source/WebCore/Modules/indexeddb/IDBTransaction.cpp:101
> +        context->eventLoop().completeAfterMicrotaskCheckpoint([protectedThis = makeRef(*this)] {

I'd call this runAtEndOfMicrotaskCheckpoint instead.

> Source/WebCore/dom/EventLoop.cpp:189
> +    m_functionsAfterMicrotaskCheckpoint.append(WTFMove(function));
> +    microtaskQueue().addCheckpointListener(makeWeakPtr(this));

I'd preferred if we just had this mapping in EventLoop itself so that we preserve ordering
between functions coming from different groups.
Otherwise, we may inadvertently introduce a race condition depending on
whether this task group had already been scheduled on the event loop or not.
Comment 16 Sihui Liu 2021-03-10 10:20:51 PST
Created attachment 422842 [details]
Patch
Comment 17 Ryosuke Niwa 2021-03-10 14:24:07 PST
Comment on attachment 422842 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=422842&action=review

> LayoutTests/storage/indexeddb/transaction-state-active-after-creation.html:7
> +<script src="resources/transaction-state-active-after-creation.js"></script>

Can we just inline the script? It's old style of js-test to split the main testing script into a separate file.
Comment 18 Sihui Liu 2021-03-10 23:57:40 PST
Created attachment 422907 [details]
Patch for landing
Comment 19 EWS 2021-03-11 00:41:28 PST
Committed r274269: <https://commits.webkit.org/r274269>

All reviewed patches have been landed. Closing bug and clearing flags on attachment 422907 [details].
Comment 20 Sihui Liu 2021-03-11 10:30:09 PST
*** Bug 222746 has been marked as a duplicate of this bug. ***