Repository navigation
Model-reload reverts to old-state when saving inside immediate-transaction with SQLite in WAL-mode #18344
Description
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 valuesWhat 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.valueAto - call
savewithin immediate-transaction myModel.valueAis <old_value>- call
reloadafter transaction has fully completed myModel.valueAis <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
reloadagain. 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
usualdesigner commentedon Sep 12, 2026
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.
N247S commentedon Sep 12, 2026
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':
But it caused a lot of problems when several requests are queued asyncrhonously, since the
thenclause 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.
findByPkcalls) 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':
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.
tevinch commentedon Sep 28, 2026
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:
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/sqlcipher5.3.1, the posted PRAGMA sequence and verified WAL mode showed:BEGIN → COMMIT; the unawaited save rejected because the transaction had committed, and the database retained the old value.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.
N247S commentedon Oct 10, 2026
@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
awaitcalls.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:
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:
myModel.save()afterUpdateis triggeredmyModel.reload()eventually gets called and reads the now saved dataWhat happens after implementing WAL-mode:
myModel.save()afterUpdateis triggeredmyModel.reload()eventually gets called and reads old data since the transaction is not commited yetSequelize does not reload a model after save (which I wrongly assumed) but will contain incorrect/previous data because of the
hook-reloadcalls.In fact when I would call
reloadwithin the transaction after thesavecall it would cause a race-condition with the out-of-transaction/default-transactionreloadcall. Which is why thereloadin 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?
tevinch commentedon Oct 11, 2026
The hook you found explains the remaining reversion; the missing-
awaitobservation in my earlier reply did not explain your application.Yes—Sequelize 6 documents this under the
afterCommithook.afterUpdateruns after the update query, and its second argument includesoptions.transaction. Defer the wrapper refresh there:Also return the promise from
onChanged: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
afterCommitpromise before that transaction call settles, and a rollback does not run it. Do not makeafterUpdateitself wait for the commit—that would preventsavefrom 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 back1. With this pattern, three queued updates retained the same instance and displayed2, 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.