Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/docs/content/docs/dev/content-engine/caching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,23 @@ Two things worth stating out loud:
free.
- **A no-op touches nothing.** Publishing something already published
transitioned nothing, so a double-clicked button costs one 200 and no cache.
The same holds for an unpublish, and for a restore that changed nothing -
each of those routes answers with `changed`, and the Server Action reads it.

Nothing global is ever expired, and one content type's mutation never touches
another's tags.

<Callout title="One redundant case, stated rather than hidden">
An **update** that changed nothing is the exception: the generated `PUT` answers
with the row, not with a `changed` flag, so the Server Action cannot tell a
saved-but-identical edit from a real one and treats it as
`update published, same slug`. That expires three tags
stale-while-revalidate - the responses stay served while they refresh, so the
cost is a refresh nobody needed rather than a cache miss. Widening a public
response contract to save it would be the wrong trade; correctness first, hit
rate second.
</Callout>

[Restore](/docs/dev/content-engine/revisions#restore) has no rules of its own -
it lands on the update rows above, because as far as a visitor is concerned a
restore *is* an update. It cannot appear on the publish or unpublish rows at
Expand Down
136 changes: 136 additions & 0 deletions apps/docs/content/docs/dev/content-engine/concurrency.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
title: Concurrency
description: Who wins when two writers race, what the loser is told, and why a no-op is never a conflict.
icon: GitFork
---

Two editors open the same article. One fixes a typo, the other rewrites the
introduction, and both press save within the same second. Exactly one of them
should win, and the other should be told - clearly enough that the AdminCP can
reload the newer record and offer to overwrite it.

That is the whole of this page, applied to every mutation the engine has.

## The mechanism

Two primitives, and which one applies depends on whether the content type has an
editorial workflow.

**Optimistic locking**, on an editorial content type. Every write carries the
version the caller read, and the write is a single guarded statement:

```sql
UPDATE "example_articles"
SET "title" = $1, "version" = "version" + 1
WHERE "id" = $2 AND "version" = $3
```

There is no read-then-write window, because there is no read. Two racing writers
produce one statement that matches and one that does not; the one that does not
gets a [`CONTENT_VERSION_CONFLICT`](/docs/dev/content-engine/editorial) carrying
both versions.

**A row lock**, on a content type without one. There is no version column to
guard, so `SELECT ... FOR UPDATE` takes the source row before the collection is
read - which makes two concurrent `add` calls run one after the other, and the
second sees the first's result rather than the state they both started from.

The lock is the database's, not the process's. A second API instance is
serialised by exactly the same primitive.

## The matrix

Every row below is a test on two separate PostgreSQL connections.

| Race | Outcome |
| ---- | ------- |
| update vs update | One winner, one `CONTENT_VERSION_CONFLICT`. Version moves once, one revision. |
| update vs delete | Delete first: the update answers `null` (a 404). Update first: the delete conflicts. Never a resurrection. |
| update vs publish (same version) | One winner. The loser conflicts. |
| update vs publish (no version) | Both may land. A publish writes `status` only, so no field value is reverted. |
| restore vs update | One winner. A restore never silently overwrites a newer edit. |
| restore vs delete | Delete first: the restore answers `null`. It cannot recreate a removed record. |
| scheduled vs manual publish | The second finds nothing to do and is skipped. One revision, one announcement. |
| PL update vs PL update | One winner, one `CONTENT_TRANSLATION_VERSION_CONFLICT`. |
| PL update vs EN update | **Both win.** Separate version domains, separate rows. |
| PL update vs shared update | **Both win.** The translation and the base row are two rows with two versions. |
| PL delete vs stale PL update | No resurrection, either way round. |
| relation add vs add | One winner. Exactly one junction row, at position 0. |
| relation remove vs add | One winner. Positions stay contiguous from zero. |
| reorder vs add / remove | One real mutation. Positions stay contiguous, targets stay unique. |
| repeatable create vs create | One winner (editorial) or both merged (plain service). |
| child update vs reorder | One winner. Child identity survives either way. |
| child delete vs update | One real mutation, and never a resurrected child. |
| collection vs scalar | One winner. Never one writer's categories under another's version. |
| two different records | **Both win.** The lock is per row. |

## Two rules that surprise people

### A no-op is not a conflict

An editorial write that changes nothing succeeds, bumps no version and leaves no
revision - and it does **not** check `expectedVersion`. There is nothing to
overwrite, so there is nothing to conflict about. An editor who pressed save
twice has not created two versions of anything.

This reaches further than it looks. `repeatable.update` for a child a concurrent
writer already deleted computes a list identical to the stored one, so it is a
successful no-op rather than a conflict. The invariant worth holding is therefore
**one race, one version increment** - not "one race, one rejected promise".

### A reorder on an unordered relation does nothing

`field.relation({ multiple: true })` without `ordered: true` is a *set*. The
engine stores it in ascending target order, so `set([9, 2])` and `set([2, 9])`
are the same state - and `reorder` computes the list that is already stored.

If a sequence matters, say so:

```ts
relatedArticles: field.relation({
multiple: true,
ordered: true, // Now `UNIQUE (itemId, position)` makes the order a fact.
self: true,
}),
```

## Why positions never collide

Rewriting an ordered collection cannot be done in place: moving row A from slot 0
to slot 1 while row B still sits in slot 1 violates `UNIQUE (itemId, position)`
*during* the statement, even though the final state is fine.

So every surviving row is first parked at a negative slot - a space no settled
row ever occupies - and one final `UPDATE` maps the whole set back to `0..n-1` at
once. No deferrable constraint, no delete-and-recreate, and every child keeps its
identifier through a reorder.

## Scheduled transitions

A booked publication is claimed with `SELECT ... FOR UPDATE`, and the lock is
held from the claim through the transition, its revision, the settlement *and*
the queue row that will announce it. Four conditions are re-read from the
database under that lock rather than trusted from the queue payload: the row
exists, it is still `pending`, its generation matches, and its time has come.

That is what makes a cancel honest. It either wins outright - before the claim -
or waits, then finds the schedule already `completed` and answers a truthful 404.
There is no window in which an administrator is told the cancel worked and then
watches the article go live anyway.

A stale booking cannot override newer manual state either, because the transition
guards on the *state* it is leaving: a scheduled publish of an already-published
record changes nothing, writes no revision, and announces nothing.

## Writing concurrent code against the engine

Three things worth knowing if you call the services directly:

1. **Pass `expectedVersion`.** Every editorial write requires it, including a
collection mutation, and that is deliberate - defaulting it would make that the
one write which silently overwrites whatever it finds.
2. **Join the transaction, do not open a second one.** Every service method takes
`{ tx }`. Two transactions on two connections is a deadlock waiting for a
quiet afternoon.
3. **Call the effects after the write returns.** Never inside the transaction
callback - see [failure and retries](/docs/dev/content-engine/failure-and-retries).
Loading
Loading