fix(submissions): atomic Polylang group save to eliminate lost-update race#203
Conversation
… race
Observed on production 2026-06-08 when publishing the "Interior Castle App"
community_project referral (post 1502): all 5 sibling drafts (1503-1507)
were correctly created and individually language-assigned via
pll_set_post_language, but the Polylang post translation group came out
as {en: 1502} only — orphaning IT/ES/FR/PT/DE. Cascade: PR #200's auto-
link-on-publish hook bailed on the orphans (because cdcf_get_source_post_id
couldn't walk back to EN), PR #201's tag-propagation hook bailed for the
same reason, and the non-EN Projects pages ended up missing the new
siblings.
Root cause: cdcf_enqueue_translations_for_submission called
pll_save_post_translations 5 times — once per iteration, with a
progressively-larger map. Polylang stores translation groups as a single
term in a custom taxonomy, edited in-place. While the loop was still
running, the Redis worker auto-published the first sibling, which fired
Polylang's own post-update plumbing that re-evaluated the group at a
point when only a partial map was committed — and the in-flight loop's
subsequent saves either lost-updated or were lost-updated against that.
Same shape as the lost-update race that PR #156 (issue #155) introduced
the /translate-all endpoint to fix for the meta-box Translate-All
fan-out.
The fix mirrors /translate-all's atomic shape:
Phase 1: create all sibling drafts + pll_set_post_language each
(per-post language is independent of group state)
Phase 2: ONE atomic pll_save_post_translations() with the full map
Phase 3: enqueue translation jobs for the newly-created siblings only
If the atomic save returns false, every just-created draft is
force-deleted so a failed call leaves no orphan rows.
3 new tests guard the new shape:
- pll_save_post_translations called exactly once with the full
6-language map (regression guard against re-introducing per-iter saves)
- No save and no enqueue when all langs are already linked from a
prior partial run (idempotency)
- Rollback: drafts force-deleted and no enqueues fire when the atomic
save returns false
All 6 existing tests for this function continue to pass unchanged.
547/547 theme suite green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughRefactors ChangesAtomic translation-group save and rollback
Sequence DiagramsequenceDiagram
participant Caller as Submission Enqueue
participant Function as cdcf_enqueue_translations_for_submission()
participant PolyLang as pll_get_post_translations<br/>pll_save_post_translations
participant Draft as wp_insert_post<br/>wp_delete_post
participant Queue as cdcf_enqueue_translation
Caller->>Function: call with post_id, target_langs
Function->>PolyLang: pre-seed from existing Polylang group (en + linked)
Function->>Draft: insert draft siblings for missing target languages
Function->>Function: track newly_created post IDs
alt newly_created not empty
Function->>PolyLang: atomic pll_save_post_translations(full map)
alt save succeeds
Function->>Queue: enqueue translation jobs for newly_created siblings
else save fails (returns false)
Function->>Draft: force-delete all newly_created drafts
Function->>Function: return without enqueue
end
else all languages already linked (newly_created empty)
Function->>Function: early exit, skip save and enqueue
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 4 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Summary
Refactors
cdcf_enqueue_translations_for_submissionfrom N partialpll_save_post_translationscalls (one per sibling iteration) to one atomic save with the complete 6-language map after every sibling has been created — mirroring the shape/translate-alladopted in #156 for the meta-box Translate-All fan-out.Production incident (2026-06-08)
Publishing the "Interior Castle App" community_project referral:
pll_set_post_language✓{en: 1502}only — IT/ES/FR/PT/DE orphaned ✗Cascade:
cdcf_link_referral_on_publishbailed for translations (cdcf_get_source_post_idreturnspost_iditself when the group is broken →source_id === post->IDguard trips → translations treated as if they're their own EN source →cdcf_is_public_submissioncheck on translation post (no submitter meta) → bail). Result: 5 non-EN Projects pages missing the new sibling.cdcf_propagate_project_tags_on_publishsame cascade — same source-resolution gate. Result: all 5 non-EN translations rendered with zero tags.Manual production backfill applied via Python client (
link-translationsfor the Polylang group,update-relationshipfor the 5 non-EN Projects pages, status-toggle on each translation to re-fire the tag-propagation hook). End state verified: 6-language group intact, all Projects pages linked, all translations carry OpenAI-translated tags (Kontemplation/Gebet/Weisheit der Heiligen,contemplazione/preghiera/saggezza dei santi, etc.).Root cause
The old shape called
pll_save_post_translations5 times — once per iteration, with a progressively-larger{lang => post_id}map. Polylang stores the group as a single term in a custom taxonomy (post_translations), edited in-place. While the publish-hook loop was still running:{en, it}→ group term updated{en, it}map is committedSame race shape as the bug that #156's
/translate-allendpoint was specifically designed to fix for the meta-box's parallel-call Translate-All fan-out. The publish-flow just never adopted the same atomic pattern.The fix
Three explicit phases, same as
/translate-all:pll_save_post_translations()call with the full final mapPlus rollback: if the atomic save returns
false, every just-created draft is force-deleted to avoid orphan rows.Tests
3 new regression tests guard the new shape (in addition to all 6 existing tests for this function continuing to pass unchanged):
test_enqueue_translations_calls_pll_save_exactly_once_with_full_map— regression guard against re-introducing per-iter saves. Assertspll_save_post_translationsis invoked exactly once and the single call carries the full 6-language map.test_enqueue_translations_skips_save_and_enqueue_when_all_langs_already_linked— idempotency: pre-seed already covers every target lang → no new drafts, no save call, no enqueue.test_enqueue_translations_rolls_back_drafts_when_atomic_save_fails— whenpll_save_post_translationsreturnsfalse, all just-created drafts getwp_delete_post($id, force: true)and no jobs are enqueued.composer test --working-dir=wordpress/themes/cdcf-headless— 547/547 pass.Deploy
Backend (theme) change — ship via
gh workflow run deploy.yml -f environment=production. A baredeploy.ymlrun defaults to staging and skips theme deploy steps.Related
/translate-allendpoint with the same atomic pattern (meta-box flow)🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests