Skip to content

fix(submissions): atomic Polylang group save to eliminate lost-update race#203

Merged
JohnRDOrazio merged 1 commit into
mainfrom
feat/atomic-polylang-link-on-submission-publish
Jun 11, 2026
Merged

fix(submissions): atomic Polylang group save to eliminate lost-update race#203
JohnRDOrazio merged 1 commit into
mainfrom
feat/atomic-polylang-link-on-submission-publish

Conversation

@JohnRDOrazio

@JohnRDOrazio JohnRDOrazio commented Jun 8, 2026

Copy link
Copy Markdown
Member

Summary

Refactors cdcf_enqueue_translations_for_submission from N partial pll_save_post_translations calls (one per sibling iteration) to one atomic save with the complete 6-language map after every sibling has been created — mirroring the shape /translate-all adopted in #156 for the meta-box Translate-All fan-out.

Production incident (2026-06-08)

Publishing the "Interior Castle App" community_project referral:

  • All 5 sibling drafts (1503-1507) created ✓
  • Each sibling correctly language-assigned via pll_set_post_language
  • All 5 successfully translated by the worker ✓
  • All 5 auto-published when the EN source went live ✓
  • Polylang post translation group: {en: 1502} only — IT/ES/FR/PT/DE orphaned

Cascade:

Manual production backfill applied via Python client (link-translations for the Polylang group, update-relationship for 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_translations 5 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:

  1. Iter 1 saves {en, it} → group term updated
  2. Worker picks up IT translation job, completes translation, auto-publishes the IT sibling
  3. The IT sibling's transition-to-publish fires Polylang's own post-update plumbing, which re-evaluates the group at a point when only the partial {en, it} map is committed
  4. Subsequent loop iterations save progressively-larger maps but lose against (or get lost against) the worker-side state — final group ends up partial or empty

Same race shape as the bug that #156's /translate-all endpoint 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:

  1. Phase 1 — create all sibling drafts + per-post language assignment (per-post language is independent of group-term state, so this can't race against itself)
  2. Phase 2 — one atomic pll_save_post_translations() call with the full final map
  3. Phase 3 — enqueue translation jobs for the newly-created siblings only

Plus 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. Asserts pll_save_post_translations is 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 — when pll_save_post_translations returns false, all just-created drafts get wp_delete_post($id, force: true) and no jobs are enqueued.

composer test --working-dir=wordpress/themes/cdcf-headless547/547 pass.

Deploy

Backend (theme) change — ship via gh workflow run deploy.yml -f environment=production. A bare deploy.yml run defaults to staging and skips theme deploy steps.

Related

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Strengthened Polylang translation synchronization with atomic, all-or-nothing updates across multiple languages
    • Enhanced error recovery to automatically clean up failed translation attempts
  • Tests

    • Added test cases validating translation synchronization atomicity and failure handling

… 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>
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 128be5f0-c29e-4c87-a21f-b6f20c4f7595

📥 Commits

Reviewing files that changed from the base of the PR and between cf7e2bb and 388526e.

📒 Files selected for processing (2)
  • wordpress/themes/cdcf-headless/includes/admin/submission-lifecycle.php
  • wordpress/themes/cdcf-headless/tests/SubmissionLifecycleTest.php

📝 Walkthrough

Walkthrough

Refactors cdcf_enqueue_translations_for_submission() to atomically save Polylang translation groups by pre-seeding existing links, creating all missing-language draft siblings upfront, performing a single group save, rolling back newly created drafts on failure, and deferring translation job enqueuing to only new siblings. Three test cases validate single-save behavior, the no-op case, and rollback-on-failure.

Changes

Atomic translation-group save and rollback

Layer / File(s) Summary
Atomic save implementation with pre-seeding and rollback
wordpress/themes/cdcf-headless/includes/admin/submission-lifecycle.php
Documentation updated to explain the new two/three-phase flow; translation map is pre-seeded from existing Polylang groups; missing-language draft siblings are created upfront; a single atomic pll_save_post_translations() call registers all language links; newly created drafts are force-deleted if the atomic save fails; translation jobs are enqueued only for newly created siblings.
Test suite for atomic save, no-op, and rollback
wordpress/themes/cdcf-headless/tests/SubmissionLifecycleTest.php
Three test cases: one validates that pll_save_post_translations is called exactly once with a complete 6-language map; another asserts the function skips draft creation, save, and enqueue when all target languages are already linked; the third asserts newly created drafts are force-deleted and no enqueue occurs when the atomic save fails.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • CatholicOS/cdcf-website#37: Introduced the original cdcf_enqueue_translations_for_submission() function that this PR refactors to atomic group-save behavior.
  • CatholicOS/cdcf-website#150: Addresses Polylang translation-group linking by serializing pll_save_post_translations() usage; both PRs restructure how and when the translation-group save is called.

Poem

🐰 A rabbit hops through translations fair,
Atomic saves without a care,
If one should fail, we roll right back,
No orphaned drafts left on the track!
All siblings synced in one bold leap,
Translation peace, both broad and deep.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: replacing multiple partial Polylang saves with a single atomic save to eliminate lost-update race conditions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/atomic-polylang-link-on-submission-publish

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 4 complexity

Metric Results
Complexity 4

View in Codacy

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants