Atomic bilingual publishing means treating the Chinese and English editions as one release unit. Editors may work on either draft independently, but public routes read only an immutable published snapshot. MongoDB switches both rendered editions in one conditional document update, while Redis remains a disposable cache. This boundary prevents half-published language pairs, silent overwrites, and unsafe retries after an ambiguous timeout. The design discussed here validates state transitions and failure semantics; it does not claim a production throughput or latency benchmark.

Why are two ordinary PUT requests not enough?

A naive bilingual workflow saves Chinese, saves English, and then marks each page as published. It works until one request succeeds and the next one fails. Search engines can then discover one language while its counterpart is missing or stale.

Concurrency creates a second failure mode. Two editors can read the same draft, make different changes, and save minutes apart. Without a precondition, the last request silently erases the first. A third problem appears when a publish request times out: the client cannot tell whether the server did nothing or committed the change and lost the response.

These are not merely editorial inconveniences. A multilingual site uses reciprocal hreflang, paired Sitemap entries, and stable canonical URLs. The pair should therefore share one publication boundary.

A safer model is:

  • one stable article slug maps to one MongoDB document;
  • that document contains both locale drafts;
  • publication renders and sanitizes both editions;
  • one conditional update swaps the complete public snapshot;
  • public queries never read the mutable draft.

For the discovery layer around this model, see Multilingual SEO with Dynamic SSR. For the broader AI-search rationale, read GEO and AEO in 2026.

What state does a publishable document need?

The following is a reduced version of the project model. It is an implementation pattern, not a universal CMS schema.

{
  "_id": "fastapi-mongodb-bilingual-publishing",
  "recordType": "article",
  "state": "draft",
  "draftRevision": 1,
  "publishedRevision": 0,
  "stateRevision": 0,
  "draft": {
    "topic": {
      "slug": "ai-search-engineering",
      "zhName": "AI 搜索工程",
      "enName": "AI Search Engineering"
    },
    "zh": { "title": "...", "markdown": "...", "sources": [] },
    "en": { "title": "...", "markdown": "...", "sources": [] }
  },
  "published": null
}

The three revisions answer different questions:

Field Question it answers When it changes
draftRevision Which draft did an editor read before saving or publishing? Every successful draft save
publishedRevision Which draft produced the public snapshot? A successful publication
stateRevision How many real publish-state transitions occurred? Every publish or unpublish

Separating draft from published is essential. Otherwise, a half-written headline, an incomplete translation, or unsanitized Markdown can leak into a public read. Once generated, the published snapshot remains immutable until another complete release succeeds.

Internal timestamps can still support ordering and audits. Whether to expose them is a separate product decision. This implementation deliberately omits author, publication date, modification date, and Sitemap <lastmod> from public output.

How does optimistic concurrency prevent lost edits?

Every draft save carries the expectedDraftRevision that the client most recently read. A new article starts at revision zero. The database filter includes both the identity and that expected revision:

document = collection.find_one_and_update(
    {
        "_id": slug,
        "recordType": "article",
        "draftRevision": expected_draft_revision,
    },
    {
        "$set": {"draft": complete_bilingual_draft},
        "$inc": {"draftRevision": 1},
    },
    upsert=expected_draft_revision == 0,
    return_document=ReturnDocument.AFTER,
)

Suppose two editors read revision 7. Editor A saves first, moving the document to 8. Editor B then submits expectedDraftRevision=7; the filter no longer matches. The API returns 409 Conflict so B can reload and merge instead of silently winning by arrival time.

The important idea is not the field name. It is putting “the version on which I based this change” into the database condition. MongoDB’s atomicity guidance explicitly recommends including the expected current value in the update filter to prevent concurrent writes from overwriting one another unnoticed.

When should an API use an idempotency key instead of a revision tuple?

Optimistic revisions, HTTP preconditions, and idempotency keys are often grouped together, but they communicate different kinds of intent.

A revision or If-Match is a natural fit for editing an existing resource. The client says, “Apply this replacement only if the server still holds the version I read.” A mismatch should remain visible as a conflict because a person or merge tool may need to reconcile two legitimate edits. Silently replaying an old update would defeat the purpose.

An idempotency key is usually better for commands that create one business result without a convenient resource version: placing an order, starting a billable job, or submitting a payment. The service associates the key with the operation’s parameters and result. A safe implementation needs a scope, retention policy, and a rule for rejecting the same key with different intent. Caching the first successful HTTP response is not sufficient by itself.

Bilingual publication has both resource and command characteristics. This implementation uses a tuple matched to the target operation:

publish:   (published, expectedDraftRevision, expectedStateRevision)
unpublish: (unpublished, expectedPublishedRevision, expectedStateRevision)

The publish tuple identifies the reviewed draft and observed state generation. The unpublish tuple identifies the public snapshot being removed and its observed state generation. A payment API might still prefer a client-generated idempotency token. If an editing API represents its draft precondition with If-Match, it must issue a separate strong ETag for that resource. The weak HTML ETag used later with If-None-Match is only a cache validator and cannot satisfy If-Match strong comparison.

Start by separating three questions: can a network retry duplicate a side effect, can a concurrent writer erase another edit, and can a delayed command resurrect an obsolete state? One generic “request ID” rarely answers all three.

How are both languages published in one database operation?

The publish endpoint first reads the target draft, validates it, and builds a server-owned snapshot:

  • topic slug and both topic names are present;
  • both locales have a title, description, Markdown body, and visible sources;
  • Markdown is rendered and sanitized;
  • body-level H1 elements are demoted and heading IDs normalized;
  • reading time is calculated independently per locale;
  • the snapshot retains reviewed Markdown and rendered HTML, while the public database projection and serializer expose only the title, description, sanitized HTML, sources, and other required fields.

It then performs a conditional update against the same MongoDB document:

updated = collection.find_one_and_update(
    {
        "_id": slug,
        "draftRevision": expected_draft_revision,
        "publishedRevision": current_published_revision,
        "stateRevision": expected_state_revision,
    },
    {
        "$set": {
            "state": "published",
            "published": {
                "topic": topic,
                "zh": rendered_zh,
                "en": rendered_en,
            },
            "publishedRevision": expected_draft_revision,
        },
        "$inc": {"stateRevision": 1},
    },
    return_document=ReturnDocument.AFTER,
)

MongoDB guarantees atomicity for a write to a single document. A reader cannot observe a published object with only one locale replaced. This is why the two editions are modeled as one aggregate. Splitting them into separate documents would require a transaction, a compensating workflow, or an additional release pointer; multi-document transactions also require a replica set or sharded deployment.

Atomic storage does not make the entire workflow correct by itself. Validation, preconditions, failure responses, and retry rules remain application responsibilities.

Why does draftRevision alone fail against the ABA problem?

Consider this sequence:

Step Operation publishedRevision stateRevision
S0 Initial draft 0 0
S1 Publish draft 1 1 1
S2 Unpublish it 1 2
S3 A delayed “publish draft 1” request arrives 1 ?

If the condition checks only publishedRevision=1, S3 sees the same content revision as S1 and may put an intentionally removed article online again. This is an ABA problem: the visible value looks like A again even though a meaningful A-to-B-to-A history occurred.

A monotonically increasing stateRevision identifies the state generation. Publish and unpublish requests carry the expectedStateRevision they observed. The delayed S3 request still expects 0 while the database contains 2, so it must conflict rather than resurrect an old state.

Retry handling then distinguishes two cases:

  • The target transition has not committed, so the conditional update may run.
  • The exact transition committed but its response—or the cache invalidation result—was lost, so the server recognizes the immediately following generation and treats the retry as the same operation.

This follows the central lesson in the AWS Builders’ Library guidance on idempotent APIs: a caller must express intent, and the service must determine whether a repeated request represents the same business operation. Re-executing all identical-looking requests is not idempotency.

What if MongoDB commits but Redis invalidation fails?

Redis is not the source of truth. The release path commits MongoDB first, then increments a global content version used in page-cache keys:

seo_articles:page:<bundle-version>:<content-version>:detail:en:<digest>

After a successful INCR, new requests use a new cache namespace. Old detail pages, listings, topic pages, and Sitemaps expire by TTL without an expensive key scan.

The difficult case is a successful MongoDB commit followed by a Redis outage. The API must not pretend the entire operation rolled back—the durable public state already changed. It should not return a normal success either, because an old cached representation may still be served.

A useful retryable response is:

{
  "stateCommitted": true,
  "articleState": "published",
  "committedRevision": 1,
  "committedStateRevision": 1,
  "cacheInvalidated": false
}

The management client retries with the original draft and state revisions. The service recognizes that this exact transition already committed and retries cache invalidation without advancing the state again. Substituting the latest revisions would be dangerous: an attempt to repair the cache could accidentally publish newer, unreviewed edits.

How do ETags reduce transfer without preserving stale state?

Server-side rendering does not require sending the complete HTML on every request. The origin can hash the final representation and issue a weak ETag:

etag = f'W/"{sha256(body.encode("utf-8")).hexdigest()}"'

When a browser or CDN later sends If-None-Match, the origin returns 304 Not Modified if the semantic representation is unchanged. RFC 9110 defines ETags, weak comparison, and conditional request semantics.

Each locale remains a separate representation:

  • the Chinese URL returns Chinese HTML with Content-Language: zh-CN;
  • the English URL returns English HTML with Content-Language: en;
  • each page has its own self-canonical URL;
  • the two pages reference one another with hreflang;
  • a publish or unpublish increments the Redis content version, forcing a fresh snapshot read.

ETags answer “did this response representation change?” Publication revisions answer “is this state transition still valid?” An ETag should not replace write-side optimistic concurrency, and a database revision should not be treated as a general HTTP validator.

Which failure modes need explicit semantics?

Failure Recommended response Public state changed?
A locale or source is missing 422 Unprocessable Content No
expectedDraftRevision is stale 409 Conflict No
expectedPublishedRevision is stale 409 Conflict No
expectedStateRevision is stale 409 Conflict No
The HTML sanitizer is unavailable Fail closed with 503 No
MongoDB update fails 503 Service Unavailable No or uncertain; inspect by request identity
MongoDB committed, Redis invalidation failed 503 with stateCommitted=true Yes
Redis fails during a public read Fall through to MongoDB No
MongoDB also fails and no cache exists Locale-matched HTML 503 with noindex No

“An exception was logged” is not an API contract. The caller needs to know whether retrying is safe, which revisions to reuse, and whether the durable state has already changed. Ambiguity invites operators to publish the wrong version while trying to recover.

What implementation checklist keeps the boundary small?

  1. Store both language editions under one stable article slug.
  2. Separate mutable drafts from immutable public snapshots.
  3. Require expectedDraftRevision on every save.
  4. Validate both locales and render sanitized HTML before changing state.
  5. Match draft, published, and state revisions in the release filter.
  6. Increment stateRevision on both publish and unpublish to prevent ABA.
  7. Specify the partial-success response for cache invalidation failures.
  8. Treat Redis pages as rebuildable and fall back to MongoDB.
  9. Emit ETags for HTML and Sitemap responses and support both GET and HEAD.
  10. Test concurrent edits, duplicate publication, publish-then-unpublish, delayed retries, and Redis failure.

The goal is not to maximize the number of revision fields. It is to make every transition answer three questions: what version was it based on, did it commit, and can it be retried safely?

Frequently asked questions

Why not use a MongoDB multi-document transaction?

When the two editions are naturally one aggregate, a single document gives a smaller and clearer atomic boundary. Use a multi-document transaction only when independent aggregates truly require transactional consistency, and remember that MongoDB transactions require a replica set or sharded cluster.

POST is not idempotent by default. Can a publish endpoint still be retry-safe?

Yes. HTTP’s default method semantics and an application’s operation identity are different layers. A server can require a revision tuple or idempotency key and guarantee that repeating the same intent does not create a second state transition.

Why return 503 when the database commit succeeded?

Because durable state succeeded while public cache invalidation remains unconfirmed. The 503 tells a controlled client to retry; stateCommitted=true tells it not to assume that the database failed.

Can draftRevision and ETag be the same field?

They should remain separate. draftRevision protects concurrent writes; ETag validates a particular HTTP representation. Their lifetimes, visibility, and comparison semantics differ.

Must the Chinese and English editions be sentence-for-sentence translations?

No. Each edition should be written for its audience and search intent. Atomic publication guarantees that two complete counterparts become available together; it does not require mechanical translation.