Skip to content

Insert-once and concurrency

insertonce permits one insert, ever. Four mechanisms make that hold under concurrency. Each is necessary, and none of them is exercised by a single-session test.


The check

Before an insert, the extension asks whether the relation contains any row visible to the current command. If it does, the insert is refused.

Rows written by the current command are invisible to it: an MVCC snapshot taken at command start does not see its own command's insertions. A first INSERT of many rows is therefore a single insert, not one success followed by failures.

Transaction-awareness follows from using a snapshot rather than a flag. A rolled-back first insert leaves nothing visible, so the next attempt succeeds. The state is the table's contents; nothing is marked on first attempt.


Serialisation on the catalogue object

Concurrent first-inserters serialise on a lock taken over the relation as a catalogue object, not over the relation itself.

A relation-level lock strong enough to conflict with itself would have to be acquired after the RowExclusiveLock that INSERT already holds. Two transactions upgrading in that order deadlock against each other. The object lock has no upgrade path, and nothing else contends for it.


A fresh snapshot, taken after the lock

The emptiness check takes a new snapshot once the lock is granted, and registers it.

The lock serialises the check without, on its own, making it correct. A statement's own snapshot predates the wait: the loser of a race blocks as intended, wakes, and — reading through that older snapshot — still sees an empty table. Only a snapshot taken after the lock is granted sees the winner's committed row.

Registration is a separate requirement. GetLatestSnapshot() does not register, and a snapshot used for a scan must be registered.


The per-command cache

A multi-row first insert would otherwise rescan the table once per row. A cache keyed on relation, transaction id and command id avoids that.

The transaction id comes from GetTopTransactionId(), which assigns one, rather than GetTopTransactionIdIfAny(), which does not.

That distinction carries the correctness of the cache. At the point the check runs, the insert has written nothing, so a transaction whose first write is this insert holds no transaction id yet. IfAny() returns InvalidTransactionId for every such transaction, making their cache keys identical — relation, invalid id, command zero. One successful check would then satisfy every later first-write insert into that table in the same backend, skipping enforcement entirely.

Assigning the id costs nothing, since the insert about to be delegated assigns one anyway.


What the tests cover

  • Two concurrent transactions racing for the first insert: exactly one wins, deterministically.
  • A rolled-back first insert does not consume the permitted insert.
  • A sequential second attempt fails against the committed row.
  • A single-row first insert followed immediately by a second in the same session, which is the shape that depends on the cache key being distinct.