Sign Up

Sign Up to our social questions and Answers Engine to ask questions, answer people’s questions, and connect with other people.

Have an account? Sign In

Have an account? Sign In Now

Sign In

Login to our social questions & Answers Engine to ask questions answer people’s questions & connect with other people.

Sign Up Here

Forgot Password?

Don't have account, Sign Up Here

Forgot Password

Lost your password? Please enter your email address. You will receive a link and will create a new password via email.

Have an account? Sign In Now

Sorry, you do not have permission to ask a question, You must login to ask a question.

Forgot Password?

Need An Account, Sign Up Here

Please type your username.

Please type your E-Mail.

Please choose an appropriate title for the post.

Please choose the appropriate section so your post can be easily searched.

Please choose suitable Keywords Ex: post, video.

Browse

Need An Account, Sign Up Here

Please briefly explain why you feel this question should be reported.

Please briefly explain why you feel this answer should be reported.

Please briefly explain why you feel this user should be reported.

Sign InSign Up

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise Logo Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise Logo

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise Navigation

  • Home
  • About Us
  • Contact Us
Search
Ask A Question

Mobile menu

Close
Ask a Question
  • Home
  • About Us
  • Contact Us
Home/ Questions/Q 2002

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise Latest Questions

Author
  • 61k
Author
Asked: November 26, 20242024-11-26T01:24:07+00:00 2024-11-26T01:24:07+00:00

Breaking IndexedDB consistency to explore its transactions

  • 61k

Did you know every browser has a built-in key-value database?


I’ve been building a Chrome extension, and learning about IndexedDB while using it for an offline mode. Everything that the app reads from the API gets processed and stored locally. Then the app renders and is reactive to changes from the database.

Having done loads of backend development, I'm curious to know how I can use IndexedDB (IDB for short) with strong consistency – basically without losing data. After reading in some threads that data is easy to corrupt in IDB, it's time to explore the transaction model, and suss out what code patterns might lead to unexepected inconsistency.

So I coded up a little app that would do lots of writes into the same “store” (IDBs name for table). The idea is to use web workers to execute simultaneous writes across threads, and see if we can stomp on parallel updates to lose data. “Stomping” means using concurrency to have code execute and overwrite each others' state.

Since I use Svelte for everything, I set up a simple app with tailwind and daisyUI. You can see an example of this in my post “Svelte Without Kit”.

First, the docs

After perusing IDB docs on transactions, I didn't see any details about thread safety or anything about under the hood. Is there some kind of multiversion concurrency control happening? Hard to say. But there was one example, and it emphasized that transactions are applied in order by when the transaction is created. Still not sure what that means across threads, but an ordering guarantee is a good sign that transactions provide some predictability, so let's write some code to find out!

You can find the complete code of the following files:

  • App.svelte // Svelte component and UI
  • worker.js // web worker that interacts with IDB
  • pool.js // utility function for distributing work among workers
  • database.js // utility function for connecting to IDB

Test One: update inside transaction

My first test sends messages to a web worker to update a “thread” object in IDB, and then verifies that the update succeeded. The workers each add a random number “labelId” to an array on the thread object. The idea is to test – if workers update the same thread object's array simultaneously, will one overwrite array the other in IDB and lose a labelId?

Here's the database code for updating the thread object, notice I am adding the labelId after starting the transaction and fetching the thread object from the store –

// threadId and labelId variables are in scope database() // returns a promise with an instance of IDBDatabase .then((db) => {   // start transaction   const txn = db.transaction(['threads'], 'readwrite')   txn.oncomplete = () => db.close()   const store = txn.objectStore('threads')    return new Promise((resolve, reject) => {     // read thread object from IDB store     const getThread = store.get(threadId)     getThread.onsuccess = ({ target }) => {       // create new thread object if it doesn't exist       const thread = target.result || { id: threadId, labelIds: [], attr: true }       // add labelId to array in thread object       if (!thread.labelIds.includes(labelId)) {         thread.labelIds.push(labelId)       }        // write thread object to IDB store       const putThread = store.put(thread)       putThread.onsuccess = () => resolve(thread)       putThread.onerror = () => reject(new Error(`Error writing message: ${putThread.error}`))     }     getThread.onerror = () => reject(new Error(`Error getting message: ${getThread.error}`))   }) }) 
Enter fullscreen mode Exit fullscreen mode

For our App UI, we have a button to toggle the test running or not, and a running test will messages to a pool of workers. Refer to below for the full code, but it basically looks like this:

  function startStopTest() {     if (testRunning) {       const promises = []       for (let step = 0; step < 15; step++) {         const lId = Math.floor(Math.random() * 1000)         const p = messageWorker({ threadId: step % 5, labelId: lId, action: 'addLabel' }, url)           .then(//... another message)           .then(//... another message)         promises.push(p)       }        Promise.all(promises)         .then(function (results) {           //... process results         })         .then(tick)         .then(startStopTest)     }   } 
Enter fullscreen mode Exit fullscreen mode

Let's fire it up!

Test one results

Result

There are no missing labelIds in any thread object's list, so no data is being corrupted across simultaneous updates. Well that was underwhelming!

Test Two: Add another update

Next I'll try adding a second update to the worker, this time flipping a boolean on the object. The database code is the same as before – start transaction, get a thread object, modify it, put object back in store. My idea is to see if adding more updates to the same object can cause corruption.

./worker.js function toggleAttr(threadId, labelId) {   return database().then((db) => {     const txn = db.transaction(['threads'], 'readwrite')     txn.oncomplete = () => db.close()     const store = txn.objectStore('threads')      return new Promise((resolve, reject) => {       const getThread = store.get(threadId)       getThread.onsuccess = ({ target }) => {         const thread = target.result         thread.attr = !thread.attr         const putThread = store.put(thread)         putThread.onsuccess = () => resolve()         putThread.onerror = () => reject(new Error(`Error writing thread: ${putThread.error}`))       }       getThread.onerror = () => reject(new Error(`Error getting thread: ${getThread.error}`))     })   }) } 
Enter fullscreen mode Exit fullscreen mode

Get it going!

Test 2 results

Result

Same result, no corruption

Test Three: separate read and write transactions

I'm curious what happens if I read the object in one transaction, update the list, then write the object in a separate transaction. This will test if the consistency I've achieved so far is a result of transaction serialization or something.

Let's modify our worker code, it will fetch a thread object in one transaction:

function getThread(db, threadId) {   const txn = db.transaction(['threads'], 'readonly')   const store = txn.objectStore('threads')    return new Promise((resolve, reject) => {     const getThread = store.get(threadId)     getThread.onsuccess = ({ target }) => {       const thread = target.result || { id: threadId, labelIds: [], attr: true }       resolve({ db, thread })     }     getThread.onerror = () => reject(new Error(`Error getting thread: ${getThread.error}`))   }) } 
Enter fullscreen mode Exit fullscreen mode

Then it updates the object list, before starting a new transaction and writing the object to IDB:

database()   .then((db) => getThread(db, threadId))   .then(({ db, thread }) => {     if (!thread.labelIds.includes(labelId)) {       thread.labelIds.push(labelId)     }      return new Promise((resolve, reject) => {       const txn = db.transaction(['threads'], 'readwrite')       txn.oncomplete = () => db.close()       const store = txn.objectStore('threads')       const putThread = store.put(thread)       putThread.onsuccess = () => resolve()       putThread.onerror = () => reject(new Error(`Error writing thread: ${putThread.error}`))     })   }) 
Enter fullscreen mode Exit fullscreen mode

Let 'er rip!

Result

Immediately we see errors, missing labelIds:

Test 3 results

Corruption accomplished! Almost half of the objects are missing labelIds. That's bad!

This suggests that updating a thread object outside of a transaction, then writing it to the store, is susceptible to data loss. But before you jump up and say “obviously!”, think about the complex applications you've built. It's pretty common for data modifications to happen outside of database transactions, for example in response to user input, and then to push that data to storage, for example through an API. But IDB is only consistent with modifications to an object that is also fetched/read within the same transaction. At least according to this test.

Test Four: mixing updates inside and outside transactions

Finally, I wanted to know if updates made outside transactions could conflict with updates happening inside transactions. So I added another “conflicting” action to the worker, one that deletes the labelId outside of a transaction. And reverted the original add labelId action to use an in-transaction update, which should be safe from data loss?

Result

Tests were still passing:

Test 4 results

But labelIds were not being removed from the thread objects:

labelId array is not empty

Once I update the delete labelId worker to modify inside a transaction, labelId lists look more reasonable:

labelId array is empty

Analysis

Ok so what's going on here? IndexedDB seems capable of handling concurrent updates as long as objects are read, updated and written in the same transaction.

If I go back to the IDB docs, I recall how it highlighted that transactions followed some kind of ordering, based on when they are created. This leads me to think that transactions are “serialized”, meaning they act as if they are executed one at a time (very watered-down definition of serialization).

Here's a sketch of the pattern that leads to data loss –

Order won't stop data loss

Notice that there is no way to order the “put” transactions that doesn't lose data. If Worker1 writes first, its array of labelIds will get overwritten by Worker2, and vice versa.

Now what if we update within a transaction –

Order doesn't cause data loss

Order doesn't matter for these transactions, as long as they are executed serially, the object in IDB doesn't lose any data!

Conclusion

So it seems that IDB is safe as long as updates are made to objects fetched within the same transaction. I even tested transactions with long running code between the get and put, and data consistency held. (There are some situations where transactions get closed after they are passed between closures, but that raises an error).

While it's great to see that IDB can be used with consistency, this mechanism dramatically affects how code using IDB is written! You can't reliably accumulate changes to an object, and then write that object to the database, without potential for stomping on another update happening simultaneously. So if your app has multiple code paths for updating IDB, this is a risk.

I should also note that there are some updates where order does really matter. For example, adding then removing a value relies on the “add” happening before the “remove”, otherwise the final value is wrong. Again, pretty common for this to happen in a web app, say a user clicks really fast.

These kind of consistency problems are exacerbated by the fact that IDB only supports a primitive put method – there's no way to make partial or conditional updates. So as a developer, I'm left with solving for consistency, and it requires me to be very deliberate about how state is updated.

Ah well, keep on coding!

App.svelte

Simple page with a button to start and stop tests, as well as a summary of the results of the running test.

<script>   import { messageWorker } from './pool'   import { tick } from 'svelte'    let successCount = 0,     failedCount = 0,     totalCount = 0,     lastError = undefined,     testRunning = false    const url = new URL('./worker.js', import.meta.url)    function toggleTest() {     testRunning = !testRunning     if (testRunning) {       successCount = failedCount = totalCount = 0       lastError = undefined     }     startStopTest()   }    function startStopTest() {     if (testRunning) {       const promises = []       for (let step = 0; step < 15; step++) {         const lId = Math.floor(Math.random() * 1000)         const p = messageWorker({ threadId: step % 5, labelId: lId, action: 'addLabel' }, url)           .then(function ({ threadId, labelId }) {             return messageWorker({ threadId, labelId, action: 'toggleAttr' }, url)           })           .then(function ({ threadId, labelId }) {             return messageWorker({ threadId, labelId, action: 'verifyLabel' }, url)           })           .then(function ({ threadId, labelId }) {             return messageWorker({ threadId, labelId, action: 'deleteLabel' }, url)           })         promises.push(p)       }        Promise.all(promises)         .then(function (results) {           results.forEach(({ error, threadId, labelId }) => {             if (error) {               failedCount = failedCount + 1               lastError = { error, threadId, labelId }             } else {               successCount = successCount + 1             }              totalCount = totalCount + 1           })           return Promise.resolve()         })         .then(tick)         .then(startStopTest)     }   } </script>  <main>   {#if testRunning}     <button class="btn btn-primary" on:click={toggleTest}>Stop test</button>   {:else}     <button class="btn btn-warning" on:click={toggleTest}>Start test</button>   {/if}   <p class="text-sm font-light py-4">     {totalCount} total tests, {successCount} passed, {failedCount} failed   </p>   {#if lastError != undefined}     <p class="text-lg text-error">       {lastError.error?.message || 'Unknown Error'}     </p>   {:else}     <p class="font-semibold italic text-lg text-primary-content">No errors</p>   {/if} </main> 
Enter fullscreen mode Exit fullscreen mode

database.js

A utility function to open a connection to the database, and return the connection in a promise.

// ./database.js export function database() {   return new Promise((resolve, reject) => {     const request = indexedDB.open('test_db', 1)     request.onsuccess = () => resolve(request.result)     request.onerror = () => reject(request.error)     request.onblocked = () => console.warn('pending till unblocked')     request.onupgradeneeded = (e) => {       const up = request.result       if (e.oldVersion < 1) {         const threads = up.createObjectStore('threads', {           keyPath: 'id',           autoIncrement: false         })       }     }   }) } 
Enter fullscreen mode Exit fullscreen mode

pool.js

A utility function to manage a pool of web workers and message a worker. A promise is returned, that will be invoked when the worker responds. Promises are tracked by a nonce value is that is passed with the message.

// ./pool.js  const workers = [] const promises = {}  function resolveMessage(data) {   promises[data.nonce].resolve(data)   delete promises[data.nonce] }  export function messageWorker(message, url) {   const ix = Math.floor(Math.random() * 5)   if (workers.at(ix) == undefined) {     workers[ix] = new Worker(url, { type: 'module' })     workers[ix].onmessage = (event) => resolveMessage(event.data)   }    const rand = Math.floor(Math.random() * 50000)   const nonce = `${rand}`   const promise = new Promise((resolve) => {     promises[nonce] = { resolve }   })    workers[ix].postMessage({ nonce, ...message })   return promise } 
Enter fullscreen mode Exit fullscreen mode

worker.js

A background worker for manipulating data with various transaction patterns.

//./worker.js import { database } from './database'  function handleMessage(message, respond) {   let pr   if (message.action == 'addLabel')     pr = addLabel(message.threadId, message.labelId)   else if (message.action == 'toggleAttr')     pr = toggleAttr(message.threadId, message.labelId)   else if (message.action == 'verifyLabel')     pr = verifyLabel(message.threadId, message.labelId)   else if (message.action == 'deleteLabel')     pr = deleteLabel(message.threadId, message.labelId)   else     pr = Promise.reject(new Error('No action in message'))    pr    .then(() => respond({ success: true, ...message }))    .catch((error) =>     respond({ error, success: false, ...message })   ) }  function addLabel(threadId, labelId) {   return database().then((db) => {     const txn = db.transaction(['threads'], 'readwrite')     txn.oncomplete = () => db.close()     const store = txn.objectStore('threads')      return new Promise((resolve, reject) => {       const getThread = store.get(threadId)       getThread.onsuccess = ({ target }) => {         const thread = target.result || { id: threadId, labelIds: [], attr: true }         if (!thread.labelIds.includes(labelId)) {           thread.labelIds.push(labelId)         }          const putThread = store.put(thread)         putThread.onsuccess = () => resolve()         putThread.onerror = () => reject(new Error(`Error writing thread: ${putThread.error}`))       }       getThread.onerror = () => reject(new Error(`Error getting thread: ${getThread.error}`))     })   }) }  function toggleAttr(threadId, labelId) {   return database()     .then((db) => getThread(db, threadId))     .then(({ db, thread }) => {       thread.attr = !thread.attr       return new Promise(function (resolve, reject) {         const txn = db.transaction(['threads'], 'readwrite')         txn.oncomplete = () => db.close()         const store = txn.objectStore('threads')         const putThread = store.put(thread)         putThread.onsuccess = () => resolve()         putThread.onerror = () => reject(new Error(`Error writing thread: ${putThread.error}`))       })     }) }  function verifyLabel(threadId, labelId) {   return database()     .then((db) => getThread(db, threadId))     .then(({ db, thread }) => {       db.close()       if (thread == undefined) {         return Promise.reject(new Error(`Missing thread ${threadId}`))       } else if (!thread.labelIds.includes(labelId)) {         return Promise.reject(new Error(`Thread ${threadId} is missing label ${labelId}`))       } else {         return Promise.resolve()       }     }) }  function deleteLabel(threadId, labelId) {   return database()     .then((db) => getThread(db, threadId))     .then(({ db, thread }) => {       thread.labelIds = thread.labelIds.filter((lid) => lid != labelId)        return new Promise((resolve, reject) => {         const txn = db.transaction(['threads'], 'readwrite')         txn.oncomplete = () => db.close()         const store = txn.objectStore('threads')          const putThread = store.put(thread)         putThread.onsuccess = () => resolve()         putThread.onerror = () => reject(new Error(`Error writing thread: ${putThread.error}`))       })     }) }  function getThread(db, threadId) {   const txn = db.transaction(['threads'], 'readonly')   const store = txn.objectStore('threads')    return new Promise((resolve, reject) => {     const getThread = store.get(threadId)     getThread.onsuccess = ({ target }) => {       const thread = target.result || { id: threadId, labelIds: [], attr: true }       resolve({ db, thread })     }     getThread.onerror = () => reject(new Error(`Error getting thread: ${getThread.error}`))   }) }  self.onmessage = (message) => {   handleMessage(message.data, postMessage) } 
Enter fullscreen mode Exit fullscreen mode

databaseidbjavascriptwebdev
  • 0 0 Answers
  • 0 Views
  • 0 Followers
  • 0
Share
  • Facebook
  • Report

Leave an answer
Cancel reply

You must login to add an answer.

Forgot Password?

Need An Account, Sign Up Here

Sidebar

Ask A Question

Stats

  • Questions 4k
  • Answers 0
  • Best Answers 0
  • Users 2k
  • Popular
  • Answers
  • Author

    How to ensure that all the routes on my Symfony ...

    • 0 Answers
  • Author

    Insights into Forms in Flask

    • 0 Answers
  • Author

    Kick Start Your Next Project With Holo Theme

    • 0 Answers

Top Members

Samantha Carter

Samantha Carter

  • 0 Questions
  • 20 Points
Begginer
Ella Lewis

Ella Lewis

  • 0 Questions
  • 20 Points
Begginer
Isaac Anderson

Isaac Anderson

  • 0 Questions
  • 20 Points
Begginer

Explore

  • Home
  • Add group
  • Groups page
  • Communities
  • Questions
    • New Questions
    • Trending Questions
    • Must read Questions
    • Hot Questions
  • Polls
  • Tags
  • Badges
  • Users
  • Help

Footer

Querify Question Shop: Explore Expert Solutions and Unique Q&A Merchandise

Querify Question Shop: Explore, ask, and connect. Join our vibrant Q&A community today!

About Us

  • About Us
  • Contact Us
  • All Users

Legal Stuff

  • Terms of Use
  • Privacy Policy
  • Cookie Policy

Help

  • Knowledge Base
  • Support

Follow

© 2022 Querify Question. All Rights Reserved

Insert/edit link

Enter the destination URL

Or link to existing content

    No search term specified. Showing recent items. Search or use up and down arrow keys to select an item.