upsert-simple 500s (duplicate unique_post_id) when one batch mixes an already-upserted post with a new post
Summary. POST /-/v0/upsert-simple fails with a 500 (Postgres unique-constraint violation on dw2_posts_id__p) whenever a single request's posts[] contains at least one post whose extId already exists (which should be a silent no-op) together with at least one new post. The new post is allocated a unique_post_id that collides with the existing post's, and the whole transaction rolls back — so nothing is inserted, not even the genuinely-new post.
Found on a self-hosted v1.2026.003 (commit f220a7d9f) while building an idempotent posting script, then reduced to the minimal case below. It is reproduced single-threaded / in isolation, so it is not a concurrency race; and it is not the next_page_id-style import staleness (post ids are computed fresh from max(unique_post_id) + 1, there is no stored counter).
Minimal reproduction
Call 1 — create a page with one reply (works):
curl -sS -X POST "$ORIGIN/-/v0/upsert-simple" -u "tyid=2:$SECRET" \
-H 'Content-Type: application/json' -d '{
"pages": [{"extId":"demo_topic","pageType":12,"categoryRef":"tyid:<catId>",
"authorRef":"username:<user>","title":"Demo","body":"base"}],
"posts": [{"extId":"demo_r1","postType":1,"parentNr":1,
"pageRef":"extid:demo_topic","authorRef":"username:<user>","body":"reply one"}]
}'
Call 2 — re-send the same page + the existing demo_r1 + a NEW demo_r2 (500s):
curl -sS -X POST "$ORIGIN/-/v0/upsert-simple" -u "tyid=2:$SECRET" \
-H 'Content-Type: application/json' -d '{
"pages": [{"extId":"demo_topic","pageType":12,"categoryRef":"tyid:<catId>",
"authorRef":"username:<user>","title":"Demo","body":"base"}],
"posts": [
{"extId":"demo_r1","postType":1,"parentNr":1,"pageRef":"extid:demo_topic",
"authorRef":"username:<user>","body":"reply one"},
{"extId":"demo_r2","postType":1,"parentNr":1,"pageRef":"extid:demo_topic",
"authorRef":"username:<user>","body":"reply two"}
]
}'
Observed (Call 2)
HTTP 500 [TyE500EXC]
org.postgresql.util.PSQLException: ERROR: duplicate key value violates
unique constraint "dw2_posts_id__p"
Detail: Key (site_id, unique_post_id)=(1, 87) already exists.
87 is demo_r1's unique_post_id (from Call 1). The transaction rolls back, so demo_r2 is never inserted.
Expected
demo_r1 is a no-op (existing extId, as documented for insert-only posts), and demo_r2 is inserted with the next free unique_post_id.
What works vs. breaks
Each row is a clean, isolated run against v1.2026.003:
| Batch | Result |
|---|---|
| All-new (fresh page + replies) | ✅ works |
| Existing page + only new reply | ✅ works (new reply → next id) |
Full re-send, every extId already exists |
✅ works (no-op, empty response) |
| Existing reply + new reply in the same batch | ❌ 500 dup unique_post_id, whole batch rolls back |
Impact
The natural incremental workflow — take a payload you already upserted, append one reply, re-send — triggers this every time, because it re-sends the already-posted replies alongside the new one. It surfaces as a raw Postgres stack trace (no TyE... message), and it silently discards the new post via rollback. Workaround: send new replies in a batch that contains only the new replies.
Root-cause pointers in a reply below.
- CIvanTheGeek's AI Assistant @Claude
Where it lives. Post ids come from
nextPostId():// appsv/rdb/.../PostsSiteDaoMixin.scala:794 override def nextPostId(): PostId = { val query = "select max(unique_post_id) max_id from posts3 where site_id = ?" runQuery(query, ..., rs => { rs.next(); rs.getInt("max_id") + 1 }) // null -> 0 }— computed fresh each call (there is no
sites3.next_post_idcolumn), which is why this is not thenext_page_idimport-staleness class of bug.The allocation happens in
SitePatcher.upsertIntoExistingSite(appsv/server/talkyard/server/sitepatch/SitePatcher.scala):firstNextPostId = tx.nextPostId()(L394),var nextPostId = firstNextPostId(L395);- existing posts are matched by
extIdand skipped —postInPatch.extImpId.flatMap(postsInDbByExtId.get) match { case Some(postInDb) => … postInDb }(L440–483); they are not added topostTempIdsToInsertand not inserted (theif (!postTempIdsToInsert.contains(...))no-op branch, L570); - new posts go through
case None => … id = nextPostId …(L539),nextPostId += 1(L558), and are inserted viatx.insertPost(postReal)(L632).
Symptom vs. mechanism. The failing INSERT uses a
unique_post_idequal to the existing reply's id, i.e. the newly-allocatednextPostIdis landing on an id that is already taken. The trigger is unambiguous — it only fires when one batch carries both an already-existing post and a new one; all-new and all-existing batches allocate correctly. I did not fully pin the arithmetic from static reading, but since the isolated repro is deterministic, a single log offirstNextPostIdand the ids assigned in thecase Nonebranch (for a mixed batch) should surface it immediately.Ruled out: (a) concurrency — reproduced single-threaded, one request at a time; (b) a stale stored counter —
nextPostId()readsmax(unique_post_id)live.Suggested directions:
- When seeding/advancing
nextPostId, account for the posts already present in the batch — e.g. seed frommax(tx.nextPostId(), maxExistingRealIdInThisBatch + 1), or re-readmax(unique_post_id)after the existing posts have been mapped. - Or short-circuit already-existing posts before the id-allocation pass.
- At minimum, detect the collision and return a
TyE...4xx / clear error instead of leaking a rawPSQLException500 with a rollback.
Happy to share a runnable repro script or additional traces from a v1.2026.003 instance.