Replies: 2 comments
|
Excellent write up. There are atomicity issues all over EmDash for the simple reason D1 does not support transactions (it does support batching though - the all-or-nothing behavior). |
|
Hey - bumped into this same issue. I want to propose two concrete APIs, since we need them for our ecommerce plugin too: the atomic inventory decrement ("sell one iff in stock") is same as your issue, and right now it's the one blocking thing for our plugin as well. Your three findings all match what I found (ctx.kv.set unconditional, uniqueIndexes declared-but-not-created, no conditional write on plugin storage). ProposalTwo additive surface, each one statement for insert and update respectively - (INSERT … ON CONFLICT / UPDATE … WHERE … RETURNING), staying inside what D1 allows (no interactive txn, no FOR UPDATE): The API can look like:for insert for update
It can be used like so If the tokens are set to 100 at the started, then 3× applied will be true, next will be false and will lands on 10 No _plugin_storage migration needed:
Portability: you've already proven the guarded UPDATE on D1 in this thread (write serialized, guard evaluated inside it → no oversell, immune to read-replica lag since there's no separate read). Native on Postgres too (ON CONFLICT/RETURNING; row-level MVCC for real per-row concurrency). @MA2153's D1 batch() (all-or-nothing) is the tool for the one thing single statements can't do multi-row atomic apply (reserve N cart lines), which could be a follow-up, not v1. Happy to help land it: you offered the uniqueIndexes wiring, if you are still willing, and I can pair on updateIf + concurrency contract tests (no-oversell on D1 and Postgres). A alone unblocks a lock/idempotency primitive for both plugins; B kills the read-modify-write race outright. I will start on a PR assuming maintainer appetite on this. |
Uh oh!
There was an error while loading. Please reload this page.
Hey, loving the plugin model, thanks for the work.
Been porting a giveaway token app to emdash for the last few days and I keep running into the same wall. Putting it here as a discussion first because I'm not sure if I'm missing something obvious or if this is actually a gap.
What I'm trying to do
Basic stuff. Users have a token balance. They hit "start draw", we subtract N tokens if they have enough. If they don't, we reject. If they double-click the button we must not double-charge. That's it.
What doesn't work
Read-modify-write on
ctx.storage.userBalances. Four concurrent -30 debits on a 100-token user:get()see{ tokens: 100 }>= 30checkput({ tokens: 70 })End state: balance 70, four successful ledger entries, 120 tokens out the door for 100 available. Reproduced it in a 20-line harness, it's not flaky, it's every time.
Same problem on the "grant welcome bonus once" path. Two parallel first-visits to the balance endpoint both grant the bonus.
What I tried
KV-based mutex. Set a lock key, do the work, delete. But
ctx.kv.setis an unconditional upsert — nosetIfAbsent. Get-then-set is the textbook TOCTOU. Two callers both seenull, both write, both think they hold the lock.uniqueIndexes. This looked like the answer — declare
uniqueIndexes: ["userId"]onwelcomeBonusGrants,put()a grant row, catch the UNIQUE constraint error on the loser. Two beers later, after the reproducer still failed, I started poking around andsqlite_mastershows no such index exists._plugin_indexesis empty.createStorageIndexesinsrc/plugins/storage-indexes.tslooks like the thing that would do this but I can't find where it's called from. So the descriptor acceptsuniqueIndexesbut nothing actually enforces them. Could be I'm reading the source wrong, happy to be corrected.Optimistic concurrency. No
putIf(id, data, { rev }). No_revon storage rows (it's on content, yes, not plugin storage as far as I can tell). So no way to say "only write if nobody else wrote since I read".That leaves read-modify-write with no atomicity story, which has the race above.
What does work (but feels wrong)
Astro.locals.emdash.dbis the Kysely instance. From a plain Astro API route on the host site I can run:One statement, SQLite handles atomicity, D1 serializes writes, done. Four concurrent -30 debits now give three successes and one "insufficient tokens" rejection, balance lands on 10 which is correct.
Problem: this can't live inside the plugin. Plugin routes don't see
locals.emdash.db, they seectx. So the atomic path lives insrc/pages/api/...on the host site, and anyone installing my plugin has to copy three route files into their own tree. That's not really a plugin anymore, that's a plugin plus a manual setup step.So the question
Is there an atomic primitive somewhere in
ctxthat I've just missed? If so please point me to it, I'll delete this and buy someone a coffee.If not, I think there's basically two options that would unblock this kind of plugin:
Actually create
uniqueIndexes. The internals look like they're already there, just not wired into the plugin activation path. With real UNIQUE constraints you can build a mutex out ofput() + catch. This alone would be enough for me.Add
ctx.kv.setIfAbsent(key, value)or equivalent. SingleINSERT ... ON CONFLICT DO NOTHINGreturning a bool. That's the smallest possible API that gives plugin authors a lock primitive they can build everything else on top of.Either/both would let the plugin stay self-contained. Right now you basically can't write any plugin that touches a balance, a counter, a "once per user" flag, or a redemption quota without punching out to the host's raw DB.
Happy to open a more focused issue and take a swing at a PR for the first one if there's appetite. Didn't want to just open an issue labeled "add this API" when maybe the answer is "use X which already exists" and I missed it.
Repro is trivial if anyone wants it — I can throw the 20 lines in a gist.
Setup if it matters:
Thanks.
All reactions