Skip to content

Model-reload reverts to old-state when saving inside immediate-transaction with SQLite in WAL-mode #18344

Open

Description

@N247S

Bug Description

I am using sequelize with SQLite for an electron-based application. Since there are 'peaks' of data-trafic I opted for WAL mode (by calling PRAGMA journal_mode=WAL after database creation). Apart from stil needing to 'throttle'/synchronize write-queries manually it works well. But I noticed a side-effect when using transaction (type: Transaction.TYPES.IMMEDIATE, autocommit: true), which is that the model is reloaded incorrectly after modifying/saving within the transaction.

Relevant code portions

const sequelize = new Sequelize('main', '', password, {
	database: 'main',
	password: password,
	dialect: 'sqlite',
	dialectModulePath: '@journeyapps/sqlcipher',
	storage: <path_to_db_file>,
	logging: msg => myLogger(msg),
	define: {
		freezeTableName: true,
		timestamps: false,
	}
})

await sequelize.query('PRAGMA cipher_compatibility = 4');
await sequelize.query("PRAGMA cipher_use_hmac = ON");
await sequelize.query("PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA1");
await sequelize.query("PRAGMA cipher_hmac_algorithm = HMAC_SHA1");
await sequelize.query("PRAGMA cipher_page_size = 4096");
await sequelize.query("PRAGMA cipher  = 'aes-256-cbc'");
await sequelize.query("PRAGMA kdf_iter = 256000");
await sequelize.query("PRAGMA cipher_plaintext_header_size = 0");
await sequelize.query(`PRAGMA key = '${password}'`);
await sequelize.query("PRAGMA journal_mode=WAL");

await sequelize.transaction({ type: Transaction.TYPES.IMMEDIATE, autocommit: true}, (transaction) => {
	myModel.save({ transaction });
	// this does not work
	// myModel.reload({ transaction });
})
/*
//Uncomment this to 'fix the reload issue'
.then(() => {
	myModel.reload();
})*/

// Without the 'then clause' <myModel> would at this point have reverted to the old/pre-saved-old-state values

What do you expect to happen?

I would expect the model-instance to at least retain the values it has 'pushed' to the database, rather than reverse back to its old state

What is actually happening?

The model values gets reloaded back to an old/pre-saved version within the database.
So:

  • set myModel.valueA to
  • call save within immediate-transaction
  • myModel.valueA is <old_value>
  • call reload after transaction has fully completed
  • myModel.valueA is <new_value>

Workaround

  • Obviously this is not expected behavior in regards of the 'automatic-reload', but I found it can be 'fixed' by manualy calling reload again. the question is though if this is prone to race-conditions, as I suspect the reload fails because it has (not yet) realised the database had been written to when 'auto-reloaded'.

So if I missed anything obvious/helpfull, or if someone can suggest anything more robust/not prone to race-conditions, it would be verry helpfull.

Environment

sequelize@6.37.8
node@v22.20.0
├─┬ ts-essentials@10.2.1
│ └── typescript@5.9.3 deduped
├─┬ ts-loader@9.6.1
│ └── typescript@5.9.3 deduped
├─┬ ts-node@10.9.2
│ └── typescript@5.9.3 deduped
└── typescript@5.9.3
database: sqlite3
library: @journeyapps/sqlcipher@5.3.1


Indicate your interest in the resolution of this issue by adding the 👍 reaction. Comments such as "+1" will be removed.

Activity

  1. added
    pending-approvalBug reports that have not been verified yet, or feature requests that have not been accepted yet
    on Sep 7, 2026
  2. added theissue type on Sep 7, 2026
  3. usualdesigner commented on Sep 12, 2026

    @usualdesigner

    This smells like a WAL snapshot-isolation read, not a Sequelize bug specifically. In WAL mode a reader that started its snapshot before your write commits won't see the new data until it takes a fresh read - if the auto-reload inside the transaction is issuing its SELECT on a connection/snapshot that predates the commit, it'll legitimately read the old row. Your manual reload-after-transaction-completes workaround is the correct fix, not a hack - I'd keep doing that rather than trying to reload from inside the transaction itself, since WAL readers and writers deliberately don't see each other's uncommitted state by design.

  4. N247S commented on Sep 12, 2026

    @N247S
    Author

    Thank you for the explanation. I get what you mean, but if a read is placed after a write within the same transaction, I assume it uses the same connection/snapshot, wouldn't that negate this desync entirely in that case? Or am I wrong on how the WAL system interacts with transactions?

    Otherwise I suspect Sequelize is internally also reloading the model but after the transaction (and therefore on a new connection, negating the reload within the transaction).

    I rolled out a test version with the following 'fix':

    const query = sequelize.transaction({ type: Transaction.TYPES.IMMEDIATE, autocommit: true}, (transaction) => {
    	myModel.save({ transaction });
    })
    
    // unfortunatly because of the current write-queue design, this is separated and therefor causing a lot of problems.
    await query.then(() => {
    	myModel.reload();
    })

    But it caused a lot of problems when several requests are queued asyncrhonously, since the then clause is delayed/not bound to the actual write-transaction (in my case).
    Since I try to 'reuse' model-instances to prevent a lot of extra qeury-trafic (i.e. findByPk calls) this becomes a problem since the actual model-state is inconsistent/wrong on sequential save-calls.
    I have transfered to another 'quick-fix' which works for now since I enforce a first-in-first-out write-queue, but it feels messy and negates the whoe 'model-reuse' mechanism IMO. But unfortunatly I cannot find another 'fast-solution' because everything is designed around there being a single model-instance in the first place.

    So this is the 'proven working new fix':

    const myModelCopy = await MyModel.findByPk(myModel.id);
    // re-apply changes to myModelCopy
    
    if (myModelCopy .changed() === false)
    	return; // don't start a transaction when not dirty
    
    await sequelize.transaction({ type: Transaction.TYPES.IMMEDIATE, autocommit: true}, (transaction) => {
    	myModelCopy.save({ transaction });
    });

    So essentially I don't save or reload the 'reused instance' which makes it a bit more awkward when checking for model-changes/dirty-states. But it doesn't break any existing logic at least.

    In short, either I need to have a 'post-write-query' with the 'write-query' within my DatabaseAPI, which maybe solves this, but potentially introduces a lot of extra latency with database-writing.
    Or (preferably/hopefully) sequelize can try and 'reload within the transaction' (if that works with WAL-mode).

    Again, maybe I have the wrong idea about WAL mode and transaction interactions. In any case I would love to hear your thoughts about this.

  5. tevinch commented on Sep 28, 2026

    @tevinch

    Both transaction callbacks shown return before myModel.save() settles. A managed transaction waits for the callback's returned promise, so the callback needs to return the save promise or await it. The .then(() => { myModel.reload(); }) example also does not wait for its reload.

    Within the same FIFO job that applies the model changes, try awaiting both operations inside the transaction:

    await sequelize.transaction(
      { type: Transaction.TYPES.IMMEDIATE, autocommit: true },
      async transaction => {
        await myModel.save({ transaction });
        await myModel.reload({ transaction });
      },
    );

    This follows the managed-transaction contract. SQLite permits reads to see earlier completed writes on the same connection; WAL does not require moving this reload outside the transaction.

    A local check with Sequelize 6.37.8, @journeyapps/sqlcipher 5.3.1, the posted PRAGMA sequence and verified WAL mode showed:

    • Original callback: BEGIN → COMMIT; the unawaited save rejected because the transaction had committed, and the database retained the old value.
    • Awaited callback: BEGIN → UPDATE → SELECT → COMMIT. Three queued updates reused the same model instance, and both its value and an independent post-commit query matched each update.

    The check used Node 24.19.0 on macOS. It did not reproduce automatic model reversion, and Electron plus your application's hooks/queue remain unverified. If reversion persists with the awaited callback, a minimal example including those parts would help isolate the remaining cause.

  6. N247S commented on Oct 10, 2026

    @N247S
    Author

    @tevinch Thank you for your answer and catching me out in not including await.
    I tried to minimize the code-dumps, and forgat to include the await calls.
    I double checked and this problem is not related to the missing 'await' calls.
    They will more likely result in 'transaction already used' kind of errors if I did miss them.

    While trying to replicate the problem within a minimal example I found out why this was happening.
    I am not sure if this is intended and/or documented behavior, but here we go.

    The actual cause

    I use wrappers around database-model instances to extend the database logic purly for displaying/client-side purpose. It is too much hasle and errorprone to put this logic into the database itself. Since not all records are shown/used simultaniously it is a big time/resourcesaver as well.

    Since some records can be altered outside the wrappers (some wrapper-types at least) I simply used the convinience of sequelize hooks:

    // Somehwere statically initialized
    MyModel.afterUpdate(hookID, (instance: MyModel) => findWrapper(instance)?.onChanged());
    
    /* MyModel.onChanged() */
    public onChanged() {
        this.myModel.reload().then(() => {
            // recalc stuff for client-side
        });
    }

    It turns out those hooks are run after MyModel.save() calls regardless if that is done within a transaction or not!
    I figured (incorrectly) it would send those events only after the transaction.commit() was called or when it autocommits.

    What happened before implementing WAL-mode:

    1. call myModel.save()
    2. afterUpdate is triggered
    3. myModel.reload() eventually gets called and reads the now saved data

    What happens after implementing WAL-mode:

    1. Start transaction
    2. call myModel.save()
    3. afterUpdate is triggered
    4. myModel.reload() eventually gets called and reads old data since the transaction is not commited yet
    5. transaction is committed

    Sequelize does not reload a model after save (which I wrongly assumed) but will contain incorrect/previous data because of the hook-reload calls.
    In fact when I would call reload within the transaction after the save call it would cause a race-condition with the out-of-transaction/default-transaction reload call. Which is why the reload in the then-clause worked better. It could however still cause a race-condition, although I did not find it to be the case in my testing.

    In any case the above is why I thought it came from WAL-mode related behavior, but is (luckly) fully related to the way sequelize handles transaction and hooks together.

    Question

    Is this there a way to delay the hook initiation until the transaction-commit?

  7. tevinch commented on Oct 11, 2026

    @tevinch

    The hook you found explains the remaining reversion; the missing-await observation in my earlier reply did not explain your application.

    Yes—Sequelize 6 documents this under the afterCommit hook. afterUpdate runs after the update query, and its second argument includes options.transaction. Defer the wrapper refresh there:

    MyModel.afterUpdate(hookID, (instance, options) => {
      const refresh = () => findWrapper(instance)?.onChanged();
      if (options.transaction) {
        options.transaction.afterCommit(refresh);
        return;
      }
      return refresh();
    });

    Also return the promise from onChanged:

    async onChanged() {
      await this.myModel.reload({ transaction: null });
      await this.recalculateForDisplay(); // your existing calculation
    }

    Replace the eager-refresh hook rather than leaving both installed. Keep mutation and the awaited managed transaction inside the same FIFO job. Sequelize waits for the returned afterCommit promise before that transaction call settles, and a rollback does not run it. Do not make afterUpdate itself wait for the commit—that would prevent save from finishing.

    I reproduced the hook-induced stale value with Sequelize 6.37.8, SQLCipher 5.3.1 and WAL enabled: the committed row was 2, while the wrapper read back 1. With this pattern, three queued updates retained the same instance and displayed 2, 3, 4; a delayed refresh held the next queue job. Rollback, saves without a transaction, and updates through a separate instance also passed. Runnable check and scope.

    Those checks used Node 24.19.0/macOS, not Electron. This covers top-level transactions. If refresh fails after a successful commit, the write is already persisted; retry the read/display refresh rather than automatically repeating the write.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    pending-approvalBug reports that have not been verified yet, or feature requests that have not been accepted yet

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions