Database
Ten NocoBase workflows, one increment: measuring lost updates
stale_orbit DEV Community
3 views
Someone asked this on the official forum (t/13660, July 31st):
Do different workflows run concurrently, interleaved with each other? I have several workflows that query the same table, compute something, and then update it. Could it happen that A queries, then B queries, and then A's update makes B's computation and update wrong?
If you have touched a database, you recognize the shape. That is a lost update.
The staff reply:
Workflows can be triggered concurrently. Sync/async mainly decides whether the caller waits for a return. If a flow enters a delay or a manual wait, other scheduled runs can still be dispatched. A and B may affect each other depending on your settings and trigger timing.
"May affect each other." Not wrong — but if you are moving inventory or money, what you actually want to know is whether it happens to you.
So I ran it and counted.
Test setup: NocoBase 2.2.5 (official Docker image) + PostgreSQL 16, fresh install. Workflows were created and triggered through the REST API; results were read straight out of the counters, executions, and jobs tables.
If you read the earlier post, where everything ran in parallel and the conclusion was that concurrency is not the thing to worry about: that was about wall-clock time. This one is about the value. They are the same concurrency seen from two sides — running in parallel is exactly what opens the window between a read and a write.
The short version
It happens — under one specific condition.
Between the read and the write
Lost update
Nothing (synchronous nodes only)
No
A delay node
Yes
A JavaScript node
Yes (*)
(*) Confirmed as far as "every execution reads the same stale value". I did not get a final number — see below.
What decides it is whether the run gets suspended partway through. With no suspension, NocoBase finishes one execution before starting the next. The moment something suspends, other executions cut in and everybody reads the same old value.
And the obvious remedy — the database transaction node — is Enterprise Edition only, and cannot wrap the shape of workflow that causes this.
How the test is built
The smallest thing that behaves like an inventory counter:
One row in a counters table, value starting at 0
A workflow that reads value → adds 1 → writes it back
Trigger N of them at the same moment
Start at 0, fire five, and a correct result is 5. A result of 1 means four updates evaporated.
Triggering is a row insert into a separate triggers table (collection event). Firing them together is just curl in the background:
for i in 1 2 3 4 5; do
curl -s -X POST "$B/api/triggers:create" \
-H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d "{\"tag\":\"r$i\"}" -o /dev/null &
done
wait
Test 1: with a delay, updates disappear
read → 2s delay → compute → write. The delay stands in for the real-world shapes — waiting on an approval, waiting on an external system.
Concurrent runs
Expected
Actual
Updates lost
2
2
1
1
5
5
1
4
10
10
1
9
The answer is 1 no matter how many I fire. Ten runs, nine increments gone.
The reason is visible in what each execution read:
exec | title | result
-----------------+---------+--------------------
384239840067584 | read | {"id":1,"value":0}
384239840067585 | read | {"id":1,"value":0}
384239840067586 | read | {"id":1,"value":0}
384239840067587 | read | {"id":1,"value":0}
384239840067588 | read | {"id":1,"value":0}
All five read value: 0. Every one of them computed "0 + 1 = 1", so only the last writer survives. Exactly the accident the person on the forum was worried about.
Test 2: without the delay, it does not happen
Same workflow, delay node removed: read → compute → write.
Concurrent runs
Run 1
Run 2
Run 3
2
0 lost
0 lost
0 lost
5
0 lost
0 lost
0 lost
10
0 lost
0 lost
0 lost
Nine trials, nothing lost.
The reads say why:
exec | read_value
-----------------+--------------------
384240130916352 | {"id":1,"value":0}
384240130916353 | {"id":1,"value":1}
384240130916354 | {"id":1,"value":2}
384240130916355 | {"id":1,"value":3}
384240130916356 | {"id":1,"value":4}
0 → 1 → 2 → 3 → 4. Each execution reads what the previous one wrote. Nothing interleaves.
Their start and end timestamps do overlap, so it isn't that the requests arrived one at a time. Once an execution is pulled off the queue, it runs its chain of nodes to the end before the next one moves.
Test 3: a JavaScript node opens the same window
This is the part that matters in practice. Delay nodes are a deliberate choice; computing something in a JavaScript node is just normal.
read → JavaScript (1s) → write, five at once:
exec | title | result
-----------------+-----------+--------------------
384241879875584 | read2 | {"id":1,"value":0}
384241879875585 | read2 | {"id":1,"value":0}
384241879875586 | read2 | {"id":1,"value":0}
384241879875587 | read2 | {"id":1,"value":0}
384241879875588 | read2 | {"id":1,"value":0}
Same as the delay node: everyone reads 0.
The server log shows the mechanism:
execution (...) run instruction [script] for node (...)
execution (...) resume instruction [script] for node (...)
resume. The JavaScript node suspends the run and picks it up later. The documentation says scripts are queued and executed in Worker threads. Suspension means the same window.
A caveat on this one: I passed a variable into the script incorrectly, so its return value came back null and I never got a final number. That is my configuration error, not a product defect. The "every execution reads the same stale value" part is what the dump above shows, and that part is solid.
So what do you do about it
The transaction node is Enterprise Edition only
2.2.0 added a database transaction node — group several data operations into one transaction. The natural thought is to reach for it.
It is not in the Community edition.
@nocobase/plugin-* in node_modules : 100
applicationPlugins table : 76
pm:list API : 98
anything matching "transaction" : 0 ← zero, however you count
(Plugin totals vary by how you count. The point is that transaction never appears in any of them.)
The documentation heading says it outright:
Database transaction node Enterprise Edition +
For comparison, delay, parallel, SQL, manual, aggregate and JavaScript carry no edition marker (JavaScript is explicitly Community Edition). Of the everyday nodes I checked, transaction is the one behind Enterprise. That is not to say it is the only paid node overall — subflow is Professional Edition.
Even with it, this shape does not fit inside it
Suppose you do have Enterprise. You still cannot apply it to what broke here. From the constraints:
The Run branch does not support asynchronous nodes that suspend the workflow, such as manual handling and delay nodes. The transaction must be committed or rolled back during the current execution. If the Run branch enters a waiting state, the system rolls back the transaction and marks the workflow as failed.
Suspension disqualifies you from the transaction. And suspension is precisely what caused the lost update. read → delay → write cannot go inside a transaction node; try it and the run fails.
What the transaction node is for is making several writes succeed or fail together, without suspension in between — the "step 2 failed and step 1 is still applied" problem. Useful, but a different problem from this one.
(Whether a JavaScript node trips the same constraint, I did not test. It suspends and resumes like a delay node does, but I am not going to assert it.)
And the default isolation level will not save you either
One more thing that is easy to miss, from the same page:
Set the transaction isolation level. The default value is READ UNCOMMITTED. If your business requires stricter data consistency, choose another isolation level based on database capabilities and concurrency requirements.
READ UNCOMMITTED does not prevent lost updates. Wrapping work in a transaction and protecting it from concurrent writers are two different things.
On PostgreSQL — the database I tested on — READ UNCOMMITTED is silently treated as READ COMMITTED (PostgreSQL does not implement dirty reads). And READ COMMITTED does not prevent lost updates either. One step up is not enough.
You need REPEATABLE READ or SERIALIZABLE, or explicit row locks. Go that far and you start getting serialization failures back, so you are now designing retries.
What actually worked on Community edition
Drop the read-then-write shape and the problem goes away. Increment in a SQL node:
UPDATE counters SET value = value + 1 WHERE id = 1
Concurrent runs
Expected
Actual
2
2
2
5
5
5
10
10
10
20
20
20
Twenty at once, nothing lost. The value never lives in application memory, so there is nothing to race over. The database settles it in a single statement.
One caveat, which I measured in an earlier post: updates from a SQL node do not fire collection events. If another workflow is supposed to trigger off this update, it will not run.
If you are designing on this
Look for suspension between your read and your write. Delay nodes, manual handling, JavaScript nodes, external requests. If one sits in the middle, that workflow can collide with any other workflow touching the same row.
Then look for rows that more than one workflow updates. Inventory, balances, sequence numbers, running totals. Anything read-compute-write is a candidate.
Approach
Edition
Notes
SQL node, direct update (the one to reach for)
Community
Measured: 20 concurrent, zero lost. But collection events do not fire
Keep suspension out from between read and write
Community
Compute first, keep the read and write adjacent. Does not work if the computation itself is the JavaScript node
Transaction node
Enterprise
Does not apply to this shape. It covers atomicity across writes with no suspension
None of this is specific to PostgreSQL, by the way. The lost update comes from the application doing read-modify-write, so MySQL behaves the same.
"No-code, so I don't have to think about concurrency" does not hold. A workflow you assembled in a browser still runs on a database.
One more thing I ran into
Off-topic, but worth recording: you cannot reconfigure the nodes of a workflow that has already executed.
Nodes in executed workflow could not be reconfigured
Once a workflow has run even once, its node configuration is frozen; changing it means creating a new version. If you are iterating on a test setup, this bites.
Takeaways
Lost updates happen. read → delay → write, ten concurrent runs: expected 10, got 1
The condition is suspension between the read and the write. Without it, executions serialize — nine trials, zero lost, with reads going 0 → 1 → 2 → 3 → 4
A JavaScript node opens the same window. The log says resume instruction [script]; the docs say Worker threads
The transaction node is Enterprise Edition — absent from Community however you count the plugins
And it does not fit this shape anyway: suspension is not allowed in the Run branch, and suspension is the cause
The default isolation level is READ UNCOMMITTED, which on PostgreSQL means READ COMMITTED — neither prevents lost updates
On Community, increment in a SQL node. Twenty concurrent, nothing lost — at the cost of collection events not firing
The staff answer was correct: they may affect each other. What it left out is which shapes break, and how badly. If you are moving money or stock, that difference is worth knowing.
(Measured on NocoBase 2.2.5 + PostgreSQL 16 on 2026-09-01. Behavior can change between versions — verify against your own environment.)
References
Forum: do workflows interleave? — where this started (Chinese)
Database transaction node — Enterprise Edition, READ UNCOMMITTED by default, no suspension in Run
JavaScript node — Community Edition, runs in Worker threads
Previously: Are NocoBase 2.x workflows really "sequential"?
Read original: https://dev.to/stale_orbit/ten-nocobase-workflows-one-increment-measuring-lost-updates-89k
Related
Building a Vector Similarity Detector: How One SQL Query Over 2.9M Charity Pairs Reveals the Gap Between Meaning and Spelling
Database
3
DEV Community
Where Need Meets Nothing: finding Florida's aid deserts with Snowflake
Database
2
Dev.to (EN Zone)
From a Chocolate Wrapper to Concurrent InnoDB Page Splits
Database
2
Reddit r/programming
Open-source tool: Practical experience in converting large quantities of SQL code syntax : 'PIVOT' function rewrite (Case 1)
Database
4
Dev.to (EN Zone)
Comments0
No comments yet — be the first