Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transactions auto-close when queuing microtasks between executeSql() calls #46

Open
trajano opened this issue Jun 21, 2021 · 13 comments
Open

Comments

@trajano
Copy link

trajano commented Jun 21, 2021

I'm tracing through the code for a bug I opened in Expo and led me to this repo because they have a revendor of your code.

What I am trying to do is avoid doing chained callbacks and use async/await, for me to do that I wrapped some of the WebSQL code so that it uses the promise API to resolve the callbacks.

Almost everything goes well except when I chain two executesql in a single transaction where I await. I am suspecting it may be immediate triggering the execution of the SQL tasks, but when it does it, it also completes the transaction since the queue is empty already.

I've created a snack that shows this. But I am unsure how to fix it.

@trajano
Copy link
Author

trajano commented Jun 22, 2021

Looking further into it when using promises alone things are okay. So I narrowed it down even further in that it is due to the async / await. Which "yields" and may be triggering the immediate to run. Just promise chaining will not do that.

@nolanlawson
Copy link
Owner

Hmm, based on your repro, expo-sqlite is implemented on top of iOS using React Native, and you have a custom implementation of node-websql's SQLite adapter running on top of that. Are you able to repro using only node-websql? If not, could this be a problem in expo-sqlite or in your adapter?

@trajano
Copy link
Author

trajano commented Jun 29, 2021

I am suspecting it could be in expo itself too. But Expo's implementation is pretty light in terms of code. However, it is running on top of React Native iOS and Android

@trajano
Copy link
Author

trajano commented Jun 29, 2021

I have an mvce https://github.com/trajano/websql-mvce that proves that it does not work when doing the async/await style. I tweaked it around since the @types/websql does not work and I don't have Expo's typings.

When the asyncAwait is used. It runs the first SQL in the transaction but does not execute the other two. Just like in Expo.

@nolanlawson
Copy link
Owner

I think I might understand after looking at your repro. It sounds like basically you're saying that the code works when you use callbacks, but not async await. In particular, if you try to break up a single transaction into multiple operations across multiple promises (each handled using async/await), then the transaction automatically closes.

This makes sense to me, because the WebSQL API was designed before Promises ever arrived in browsers. It wasn't really designed to work well with Promises.

I guess the closest analog is how IndexedDB eventually allowed microtasks to execute without closing the transaction: w3c/IndexedDB#87. Based on this analog, I guess you could say that node-websql should match what happened with IndexedDB.

Here is the minimal repro of your issue, as I understand it:

const openDatabase = require('websql')
const db = openDatabase(':memory:', '1.0', 'yolo', 100000);

async function main() {
  const txn = await new Promise(resolve => {
    db.transaction(resolve)
  })
  await new Promise(resolve => {
    txn.executeSql('CREATE TABLE foo (bar text);', [], resolve)
  })
  await new Promise(resolve => {
    txn.executeSql('INSERT INTO foo VALUES ("baz")', [], resolve)
  })
  const res = await new Promise(resolve => {
    txn.executeSql('SELECT * FROM foo', [], (txn, res) => resolve(res))
  })
  console.log('res', res)
}

main()

As I understand it, you would expect to be able to chain all these operations in the same transaction, but instead what actually happens is that the transaction closes after the CREATE call.

@trajano
Copy link
Author

trajano commented Jul 1, 2021

Yup. I worked around it by creating a new Transaction class that does not use websql transaction semantics. Instead I relied on Expo's SQLite itself, but I think it can be expanded to plain sqlite3

The concept was to use db.exec(...) and use begin transaction commit and rollback. I basically wrapped the exec method which allowed for multiple SQLs to only run one single SQL.

  /**
   * Wraps the exec command but only supports ONE statement without notion of a transaction context.
   * @param sqlStatement SQL statement
   * @param args  arguments array
   * @param readOnly if the exec should be readonly
   * @returns result set.
   */
  async executeSqlAsync(
    sqlStatement: string,
    args: any[] = [],
    readOnly = false
  ): Promise<ResultSet> {
    return new Promise<ResultSet>((resolve, reject) => {
      this.db.exec(
        [{ sql: sqlStatement, args }],
        readOnly,
        (error, resultSet) => {
          if (error) {
            reject(error);
          }
          if (resultSet && resultSet[0]) {
            const result = resultSet[0];
            if ((result as ResultSetError).error) {
              reject((result as ResultSetError).error);
            } else {
              resolve(result as unknown as ResultSet);
            }
          }
        }
      );
    });
  }

I still have a AsyncSqlTransaction class that provides an executeSqlAsync method but does not track the transactions. I coded it so it does not need a AsyncDatabase but it meant I had to copy and paste the code for executeSqlAsync

import type { ResultSet, ResultSetError, WebSQLDatabase } from 'expo-sqlite';

export class AsyncSQLTransaction {
  constructor(private db: WebSQLDatabase, private readOnly = false) {}

  /**
   * This is the same logic as in SQLiteAsyncDatabase, but the database that is
   * passed into this transaction is NOT a SQLiteAsyncDatabase but a WebSQLDatabase
   * for interop.
   * @param sqlStatement
   * @param args
   * @returns
   */
  async executeSqlAsync(
    sqlStatement: string,
    ...args: any
  ): Promise<ResultSet> {
    return new Promise<ResultSet>((resolve, reject) => {
      this.db.exec(
        [{ sql: sqlStatement, args }],
        this.readOnly,
        (error, resultSet) => {
          if (error) {
            reject(error);
          }
          if (resultSet && resultSet[0]) {
            const result = resultSet[0];
            if ((result as ResultSetError).error) {
              reject((result as ResultSetError).error);
            } else {
              resolve(result as unknown as ResultSet);
            }
          }
        }
      );
    });
  }
}

The transactions are wrapped using txn and rtxn which are analogues to transaction and readtransaction which I coded as

  /**
   * Creates a transaction and executes a callback passing in the transaction wrapped with an async API
   * @param callback callback function that would get a transaction that provides async capability.  The return value of the callback will be the return value of this method.
   */
  async txn<T>(callback: AsyncTransactionCallback<T>): Promise<T> {
    try {
      await this.executeSqlAsync('begin transaction');
      const tx = new AsyncSQLTransaction(this);
      const rs = await callback(tx);
      await this.executeSqlAsync('commit');
      return rs;
    } catch (error) {
      await this.executeSqlAsync('rollback');
      throw error;
    }
  }

@nolanlawson
Copy link
Owner

I'm glad you found a solution. I may never get around to solving this bug, especially since it's somewhat against the spirit of the library, as I said years ago that my goal was just to emulate the WebSQL API as designed (circa 2010!) and not "carry the flame" of WebSQL.

I'll leave this issue open, though, as it seems potentially solvable without breaking backwards compatibility semantics.

@nolanlawson nolanlawson changed the title When chaining transaction steps using promises instead of callbacks, the transaction gets completed on the first step Transactions auto-close when queuing microtasks between executeSql() calls Jul 1, 2021
nolanlawson added a commit that referenced this issue Jul 2, 2021
@nolanlawson
Copy link
Owner

Added a failing test for this issue: aaf5c0f

@DerGuteMoritz
Copy link
Contributor

FWIW, we were able to make this work by patching in a way to disable the auto-commit mechanism and adding explicit commit and rollback methods to WebSQLTransaction. We also extended the test suite accordingly. @nolanlawson As far as I understand you wouldn't be interested in a PR to that effect, right?

@trajano
Copy link
Author

trajano commented Aug 29, 2021

@DerGuteMoritz would it work in Expo?

@DerGuteMoritz
Copy link
Contributor

@trajano Pretty sure, yes. IIRC the patch that Expo originally vendored node-websql for has since been upstreamed. We're using it with https://github.com/craftzdog/react-native-sqlite-2 rather than Expo's sqlite module which definitely works.

@nolanlawson
Copy link
Owner

@DerGuteMoritz

As far as I understand you wouldn't be interested in a PR to that effect, right?

Correct, I would prefer not to add new extensions to the WebSQL API. The goal of this project is to pretty narrowly just match the original WebSQL semantics and not invent new ones. Microtasks are kind of fuzzy, though, as the original WebSQL API predated promises.

@trajano
Copy link
Author

trajano commented Sep 1, 2021

@DerGuteMoritz from the way your code looks, it appears that it requires bare workflow is there a specific place I can discuss your SQLite implementation since it's outside the scope of this project.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants