Transactions.
A transaction is how you make several writes land together or not at all. Reach for one when a single logical change touches more than one row — moving stock between two SKUs, debiting one balance to credit another, writing a record plus its audit trail. If your change is one row, you do not need a transaction: a single row write is already atomic.
OriginChain ships two transaction surfaces with different guarantees. Picking the wrong one is a correctness bug, so the difference is the first thing on this page.
Every /tx response carries "_preview": true. The wire shape can still change between releases — pin your client and read the changelog before upgrading. No OriginChain SDK wraps transactions yet, in any language, so every example below drives the endpoints over plain HTTP.
Which surface to use.
| /tx endpoints | SQL session transaction | |
|---|---|---|
| Isolation | Snapshot isolation by default; serializable opt-in. | Read committed. Not selectable. |
| Conflict detection | Yes — read set revalidated at commit, 409 on loss. | None. Concurrent writers do not abort each other. |
| Work you can do | Row put / get / delete, plus scan-based read plans. | Any SQL write statement, including data-definition statements. |
| Handle | tx_id in the URL path. | X-OC-Session-Id header. |
| Reach for it when | The change is read-modify-write and a lost update would be a bug. | You want several SQL statements to land in one frame and there are no concurrent writers to the same rows. |
Sections 3 to 6 cover the /tx endpoints. Section 7 covers SQL session transactions.
What the isolation actually guarantees.
The default level on /tx is snapshot isolation, implemented as optimistic, commit-time validation. The mechanics are worth understanding, because one detail surprises people:
- Reads hit live data, not a frozen snapshot. There are no version chains. Each read returns whatever the store holds at that instant and records the value it saw in a read set.
- The snapshot is enforced at commit, not at read. Commit re-reads every key in the read set and compares. If anything changed, the commit aborts with
409 tx_conflictand nothing is written. - So a committed transaction saw a consistent view — but a re-read mid-transaction is not repeatable. Read the same key twice and the second read can return a newer value. The commit will then fail, so you cannot act on the inconsistency, but do not write code that assumes two reads agree.
- Writes are buffered. Nothing is durable until commit, at which point the whole buffer lands in a single write-ahead-log frame — the storage layer's all-or-nothing unit.
- You see your own writes. A read through the transaction returns its own buffered value; a buffered delete reads as absent.
At the default level, phantoms and write skew are possible. A scan that matched three rows at the start can match four at commit if another writer inserted one, and the conflict check will not catch it — it validates the keys you observed, not the keys that would now match. Two transactions that read a shared invariant and each write a different row can both commit and break it. If your correctness argument depends on either of those not happening, use serializable.
Which reads count toward that check depends on how you read. This table is the most important thing on the page:
| How you read | Snapshot isolation | Serializable |
|---|---|---|
| GET /tx/:id/rows/:schema/:pk | Recorded. A concurrent change to that row aborts your commit with tx_conflict. | Recorded, plus a read predicate for the cycle check. |
| POST /tx/:id/query | NOT recorded. The scan gives you read-your-writes but no conflict protection at all. | Recorded as read predicates — this is what catches phantoms and write skew. |
| POST /sql or GET /rows (outside the transaction) | Not recorded. Invisible to the transaction. | Not recorded. Invisible to the transaction. |
At the default level, a transaction that decides what to write based on a /tx/:id/query scan gets no conflict protection for that decision. The scan reflects your own buffered writes, but it records nothing in the read set, so the commit has nothing to revalidate and will succeed even if the scanned rows changed underneath you. Either read the specific keys you care about with GET /tx/:id/rows/… so they enter the read set, or open the transaction as serializable.
No range or gap locking — scans do not lock what they scanned. No deadlock or starvation defence: a long transaction contesting a hot key can keep losing the race, so bound your retries. No savepoints and no nested transactions. No data-definition statements inside a /tx transaction — schema migrations run through their own cutover path. Conflicts abort at commit and never block, so there are no lock-wait deadlocks by construction.
Before you start.
Transactions add no fields to your schema — they wrap writes against tables you have already registered. Every example below uses the shop.orders table from the quickstart:
namespace = "shop"
table = "orders"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "customer"
ty = "str"
[[columns]]
name = "amount_cents"
ty = "i64"
[[columns]]
name = "status"
ty = "str"
[[columns]]
name = "notes"
ty = "str"
[[columns]]
name = "placed_ms"
ty = "u64"
# Enforced INSIDE a transaction too - a violation aborts the whole
# transaction at COMMIT, not just the offending write.
[[check_constraints]]
name = "amount_positive"
expression = "amount_cents > 0"
Constraints declared on the table — foreign keys, back-references, CHECK, uniqueness — are enforced at commit, on the whole buffer. One bad row aborts the entire transaction, not just that write.
The shell examples assume $OC_HOST, $OC_TENANT and $OC_TOKEN are set — see Authentication.
The lifecycle, end to end.
Five endpoints, all under /v1/tenants/:tenant/tx. Open one, buffer work against its id, then commit or roll back.
Open a transaction
POST /tx/begin takes no body. It returns a ULID tx_id, the logical read tick the transaction is anchored to, and the isolation level you actually got — always check that field rather than assuming.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/begin" \
-H "Authorization: Bearer $OC_TOKEN"
# {
# "tx_id": "01JTS0Q4M9V3K7X2N8R1P6C0DE",
# "read_ts": 184213,
# "isolation": "snapshot_isolation",
# "_preview": true
# }import os, requests
BASE = f"https://{os.environ['OC_HOST']}/v1/tenants/{os.environ['OC_TENANT']}"
H = {"Authorization": f"Bearer {os.environ['OC_TOKEN']}"}
# No SDK method wraps /tx yet - plain HTTP.
tx = requests.post(f"{BASE}/tx/begin", headers=H).json()
tx_id = tx["tx_id"]
print(tx["isolation"]) # -> snapshot_isolationconst BASE = `https://${process.env.OC_HOST}/v1/tenants/${process.env.OC_TENANT}`;
const H = { Authorization: `Bearer ${process.env.OC_TOKEN}` };
// No SDK method wraps /tx yet - plain fetch.
const res = await fetch(`${BASE}/tx/begin`, { method: "POST", headers: H });
const tx = await res.json();
const txId: string = tx.tx_id;
console.log(tx.isolation); // -> snapshot_isolationbase := "https://" + os.Getenv("OC_HOST") + "/v1/tenants/" + os.Getenv("OC_TENANT")
// No SDK method wraps /tx yet - plain net/http.
req, _ := http.NewRequestWithContext(ctx, "POST", base+"/tx/begin", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil { /* handle */ }
defer resp.Body.Close()
var tx struct {
TxID string `json:"tx_id"`
ReadTS uint64 `json:"read_ts"`
Isolation string `json:"isolation"`
}
json.NewDecoder(resp.Body).Decode(&tx)
fmt.Println(tx.Isolation) // -> snapshot_isolation Buffer writes
POST /tx/:tx_id/rows/:schema buffers an upsert. The row body is flat — the same shape the non-transactional row endpoint takes. It is validated against the manifest immediately, so a type or shape error comes back as a 400 right here rather than at commit. Referential and CHECK constraints are the ones deferred to commit.
# Two writes, one transaction. Neither is durable yet.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/$TX_ID/rows/shop.orders" \
-H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
-d '{ "id": "ord-1001", "customer": "cus-77", "amount_cents": 4200,
"status": "pending", "notes": "gift wrap", "placed_ms": 1714478049000 }'
# { "tx_id": "01JTS0Q4M9V3K7X2N8R1P6C0DE", "op": "put",
# "buffered": true, "_preview": true }
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/$TX_ID/rows/shop.orders" \
-H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
-d '{ "id": "ord-1002", "customer": "cus-77", "amount_cents": 1500,
"status": "pending", "notes": "", "placed_ms": 1714478051000 }'rows = [
{"id": "ord-1001", "customer": "cus-77", "amount_cents": 4200,
"status": "pending", "notes": "gift wrap", "placed_ms": 1714478049000},
{"id": "ord-1002", "customer": "cus-77", "amount_cents": 1500,
"status": "pending", "notes": "", "placed_ms": 1714478051000},
]
for row in rows:
r = requests.post(f"{BASE}/tx/{tx_id}/rows/shop.orders", headers=H, json=row)
r.raise_for_status() # 400 here = the row failed manifest validation
assert r.json()["buffered"] is Trueconst rows = [
{ id: "ord-1001", customer: "cus-77", amount_cents: 4200,
status: "pending", notes: "gift wrap", placed_ms: 1714478049000 },
{ id: "ord-1002", customer: "cus-77", amount_cents: 1500,
status: "pending", notes: "", placed_ms: 1714478051000 },
];
for (const row of rows) {
const r = await fetch(`${BASE}/tx/${txId}/rows/shop.orders`, {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify(row),
});
if (!r.ok) throw new Error(await r.text()); // 400 = manifest validation
}rows := []map[string]any{
{"id": "ord-1001", "customer": "cus-77", "amount_cents": 4200,
"status": "pending", "notes": "gift wrap", "placed_ms": uint64(1714478049000)},
{"id": "ord-1002", "customer": "cus-77", "amount_cents": 1500,
"status": "pending", "notes": "", "placed_ms": uint64(1714478051000)},
}
for _, row := range rows {
body, _ := json.Marshal(row)
req, _ := http.NewRequestWithContext(ctx, "POST",
base+"/tx/"+txID+"/rows/shop.orders", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { /* handle */ }
resp.Body.Close()
} Read through the transaction
GET /tx/:tx_id/rows/:schema/:pk answers from the transaction's own buffer first, then from committed state. The read is recorded in the read set — which is exactly what makes a concurrent change to that row abort this transaction at commit.
# Reads the tx's own buffered write first, then committed state.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/$TX_ID/rows/shop.orders/ord-1001" \
-H "Authorization: Bearer $OC_TOKEN"
# The row is nested under "row" - unlike the non-transactional
# /rows/:schema/:pk endpoint, which returns the row object bare.
# {
# "row": { "id": "ord-1001", "customer": "cus-77", "amount_cents": 4200,
# "status": "pending", "notes": "gift wrap",
# "placed_ms": 1714478049000, "_oc_row_version": 1 },
# "tx_id": "01JTS0Q4M9V3K7X2N8R1P6C0DE",
# "_preview": true
# }
#
# The same GET on /rows/shop.orders/ord-1001 (outside the tx) 404s -
# nothing has been committed yet.body = requests.get(
f"{BASE}/tx/{tx_id}/rows/shop.orders/ord-1001", headers=H
).json()
row = body["row"] # note the "row" envelope
print(row["amount_cents"]) # -> 4200, from this tx's own bufferconst body = await (
await fetch(`${BASE}/tx/${txId}/rows/shop.orders/ord-1001`, { headers: H })
).json();
const row = body.row; // note the "row" envelope
console.log(row.amount_cents); // -> 4200, from this tx's own bufferreq, _ = http.NewRequestWithContext(ctx, "GET",
base+"/tx/"+txID+"/rows/shop.orders/ord-1001", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, _ = http.DefaultClient.Do(req)
defer resp.Body.Close()
var body struct {
Row map[string]any `json:"row"` // note the "row" envelope
}
json.NewDecoder(resp.Body).Decode(&body)
fmt.Println(body.Row["amount_cents"]) // -> 4200, from this tx's own buffer Buffer a delete
DELETE /tx/:tx_id/rows/:schema/:pk buffers a removal. Note the commit-time cost: any transaction with a buffered delete makes the engine inspect every table on the instance for references back to the deleted rows, where a write-only transaction inspects just the tables it touched.
curl -X DELETE \
"https://$OC_HOST/v1/tenants/$OC_TENANT/tx/$TX_ID/rows/shop.orders/ord-0900" \
-H "Authorization: Bearer $OC_TOKEN"
# { "tx_id": "01JTS0Q4M9V3K7X2N8R1P6C0DE", "op": "delete",
# "buffered": true, "_preview": true }
#
# A later GET through the SAME tx reads ord-0900 as absent (404),
# even though it is still present in committed state until COMMIT.
#
# Deleting a row that does not exist is NOT an error - it buffers
# zero operations and the commit succeeds having written nothing.requests.delete(
f"{BASE}/tx/{tx_id}/rows/shop.orders/ord-0900", headers=H
).raise_for_status()await fetch(`${BASE}/tx/${txId}/rows/shop.orders/ord-0900`, {
method: "DELETE", headers: H,
});req, _ = http.NewRequestWithContext(ctx, "DELETE",
base+"/tx/"+txID+"/rows/shop.orders/ord-0900", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, _ = http.DefaultClient.Do(req)
resp.Body.Close() Commit
POST /tx/:tx_id/commit revalidates the read set, runs the constraint checks, and writes the buffer as one frame. On success the body carries the log position the frame landed at. On failure nothing was written, and the error field tells you whether retrying can help.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/$TX_ID/commit" \
-H "Authorization: Bearer $OC_TOKEN"
# 200 - everything landed in one write-ahead-log frame:
# { "committed": true, "tx_id": "01JTS0Q4M9V3K7X2N8R1P6C0DE",
# "lsn": { "segment": 12, "offset": 884210 }, "_preview": true }
#
# 409 - somebody else changed a row this tx had read:
# { "error": "tx_conflict", "tx_id": "01JTS0...",
# "read_lsn": 184213, "current_lsn": 184298, "_preview": true }r = requests.post(f"{BASE}/tx/{tx_id}/commit", headers=H)
if r.status_code == 200:
print("committed at", r.json()["lsn"])
elif r.status_code == 409:
err = r.json()
# "tx_conflict" | "serialization_failure" -> retry the whole tx.
# "constraint_violation" -> fix the data first.
print("aborted:", err.get("error"))const r = await fetch(`${BASE}/tx/${txId}/commit`, { method: "POST", headers: H });
if (r.status === 200) {
console.log("committed at", (await r.json()).lsn);
} else if (r.status === 409) {
const err = await r.json();
// "tx_conflict" | "serialization_failure" -> retry the whole tx.
// "constraint_violation" -> fix the data first.
console.log("aborted:", err.error);
}req, _ = http.NewRequestWithContext(ctx, "POST", base+"/tx/"+txID+"/commit", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, _ = http.DefaultClient.Do(req)
defer resp.Body.Close()
switch resp.StatusCode {
case 200:
var ok struct{ Committed bool }
json.NewDecoder(resp.Body).Decode(&ok)
case 409:
var e struct{ Error string }
json.NewDecoder(resp.Body).Decode(&e)
// "tx_conflict" | "serialization_failure" -> retry the whole tx.
// "constraint_violation" -> fix the data first.
}
On a single-writer instance lsn is an object — { segment, offset }. On an instance running consensus replication the position is stamped after the round, and the field comes back null; the response header X-OC-Replication-Path tells you which path served you. A commit that buffered nothing also succeeds, and reports the sentinel { segment: 0, offset: 0 } — nothing was appended. Treat committed: true as the durability signal, not the shape of lsn.
A /tx commit flushes its log frame to stable storage before it answers, regardless of how the instance's general write-flush policy is tuned. A crash immediately after a 200 cannot lose the transaction. That also means a commit is more expensive than an ordinary row write — batch your work into fewer, larger transactions rather than many tiny ones.
Roll back, and check state
POST /tx/:tx_id/rollback discards the buffer. GET /tx/:tx_id reports the current state and survives the terminal transition, so you can always ask what happened to an id.
# Discard everything the transaction buffered.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/$TX_ID/rollback" \
-H "Authorization: Bearer $OC_TOKEN"
# { "rolled_back": true, "tx_id": "01JTS0...", "_preview": true }
# Ask what happened to a transaction at any time.
curl "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/$TX_ID" \
-H "Authorization: Bearer $OC_TOKEN"
# { "tx_id": "01JTS0...", "read_ts": 184213, "state": "committed",
# "isolation": "snapshot_isolation", "_preview": true }requests.post(f"{BASE}/tx/{tx_id}/rollback", headers=H)
state = requests.get(f"{BASE}/tx/{tx_id}", headers=H).json()["state"]
# active | committing | committed | rolled_back | conflict | expiredawait fetch(`${BASE}/tx/${txId}/rollback`, { method: "POST", headers: H });
const { state } = await (await fetch(`${BASE}/tx/${txId}`, { headers: H })).json();
// active | committing | committed | rolled_back | conflict | expiredreq, _ = http.NewRequestWithContext(ctx, "POST", base+"/tx/"+txID+"/rollback", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, _ = http.DefaultClient.Do(req)
resp.Body.Close()
req, _ = http.NewRequestWithContext(ctx, "GET", base+"/tx/"+txID, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
resp, _ = http.DefaultClient.Do(req)
defer resp.Body.Close()
var st struct{ State string }
json.NewDecoder(resp.Body).Decode(&st)
// active | committing | committed | rolled_back | conflict | expired | state | Meaning |
|---|---|
active | Open. Accepts row ops, queries, commit and rollback. |
committing | A commit is in flight. Transient. |
committed | Terminal. Every buffered op landed in one write-ahead-log frame. |
rolled_back | Terminal. Buffer discarded — by an explicit rollback, or by a commit that failed a constraint check or an internal error. |
conflict | Terminal. Commit lost the race: a row in the read set changed, or (serializable only) the commit-time cycle check fired. |
expired | Terminal. The idle sweeper aborted it after no client activity for the idle window. Buffer discarded, exactly as on rollback. |
Rolling back a transaction that is already terminal returns 409, not a silent success. An unknown or other-instance tx_id returns 404 — deliberately indistinguishable, so an id cannot be probed across instances. Terminal handles are kept for about an hour so a late status poll still gets a truthful answer; after that the same poll returns 404.
Serializable isolation.
Pass ?isolation=serializable to /tx/begin and the engine layers serializable snapshot isolation over the machinery above: reads register predicates, writes register predicates for both the before and after image, and commit runs a dangerous-structure check that aborts the pivot of a read-write dependency cycle. Write skew and phantoms are rejected, not merely observed, and the failure arrives as 409 serialization_failure.
The only other accepted value is snapshot_isolation (the default). Anything else is a 400.
# Opt in at begin. The response echoes what you actually got.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/begin?isolation=serializable" \
-H "Authorization: Bearer $OC_TOKEN"
# { "tx_id": "01JTS1...", "read_ts": 184301,
# "isolation": "serializable", "_preview": true }
# On a sharded instance the request is REFUSED - never silently downgraded:
# HTTP 501
# { "error": "serializable_sharded_unsupported",
# "isolation": "serializable",
# "available_isolation": ["snapshot_isolation"] }
The dependency graph that makes serializable work is held in memory inside a single serving process. On a sharded instance it cannot see a peer's dependencies, so begin refuses the request with 501 serializable_sharded_unsupported and lists available_isolation. This is deliberate: you learn the level is unavailable before you build a transaction on a guarantee you would not have got. A transaction opened at single-shard whose instance is resharded mid-flight is refused at its next query or at commit for the same reason.
Serializable only sees the reads you make through the transaction — GET /tx/:id/rows/… and POST /tx/:id/query. A read issued on the ordinary /sql or /rows endpoints registers nothing, so basing a serializable write on it gives you no guarantee at all.
The same in-memory constraint applies to consensus replication. On an instance running quorum replication a serializable commit is refused with 501 serializable_raft_unsupported — peer replicas cannot reproduce the dependency graph. Serializable is available on single-shard instances with a single active writer. Snapshot isolation works everywhere.
Retrying a conflict.
Optimistic concurrency means conflicts are normal, not exceptional. Any client that writes contended rows needs a retry loop. Two rules:
- Retry the whole transaction, from a fresh begin. A conflicted transaction is terminal — you cannot resume it, and reusing the id returns
409. Re-read your inputs too; the values you decided on are the ones that just went stale. - Bound the loop and back off. There is no starvation defence in the engine, so an unbounded retry against a hot key is an infinite loop you wrote yourself.
Retry on tx_conflict and serialization_failure. Do not retry constraint_violation — the same data will fail again.
import time, requests
RETRYABLE = {"tx_conflict", "serialization_failure"}
def run_tx(apply, attempts=5):
"""apply(tx_id) buffers the work. Returns the commit body."""
for n in range(attempts):
tx_id = requests.post(f"{BASE}/tx/begin", headers=H).json()["tx_id"]
try:
apply(tx_id)
except Exception:
requests.post(f"{BASE}/tx/{tx_id}/rollback", headers=H)
raise
r = requests.post(f"{BASE}/tx/{tx_id}/commit", headers=H)
if r.status_code == 200:
return r.json()
if r.status_code == 409 and r.json().get("error") in RETRYABLE:
time.sleep(0.05 * (2 ** n)) # back off, then rebuild from scratch
continue
r.raise_for_status()
raise RuntimeError("transaction did not converge after 5 attempts") SQL session transactions.
BEGIN, COMMIT and ROLLBACK are accepted as statements on POST /sql. Write statements issued between them buffer instead of executing, and the whole buffer lands in one frame at COMMIT. This is how you make several SQL statements atomic.
The buffer is keyed by (instance, session id). Send an explicit X-OC-Session-Id header on every statement in the flow. Without it the engine falls back to keying the buffer by your bearer token, which means two concurrent flows sharing a token share a transaction buffer — and neither gets the fence that detects a lost transaction.
# Every statement in the flow MUST carry the same X-OC-Session-Id.
SID=$(uuidgen)
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" -H "X-OC-Session-Id: $SID" \
-H "Content-Type: application/json" -d '{"sql":"BEGIN"}'
# { "kind": "tx", "op": "begin", "ops_committed": 0, "session_id": "..." }
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" -H "X-OC-Session-Id: $SID" \
-H "Content-Type: application/json" \
-d '{"sql":"UPDATE shop.orders SET status = $1 WHERE id = $2",
"params":["shipped","ord-1001"]}'
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" -H "X-OC-Session-Id: $SID" \
-H "Content-Type: application/json" -d '{"sql":"COMMIT"}'
# { "kind": "tx", "op": "commit", "ops_committed": 2, "session_id": "..." }import uuid
SID = str(uuid.uuid4())
TXH = {**H, "X-OC-Session-Id": SID}
def sql(stmt, params=None):
body = {"sql": stmt}
if params is not None:
body["params"] = params # positional: $1, $2, ...
r = requests.post(f"{BASE}/sql", headers=TXH, json=body)
r.raise_for_status()
return r.json()
sql("BEGIN")
sql("UPDATE shop.orders SET status = $1 WHERE id = $2", ["shipped", "ord-1001"])
sql("UPDATE shop.orders SET status = $1 WHERE id = $2", ["shipped", "ord-1002"])
print(sql("COMMIT")["ops_committed"])const SID = crypto.randomUUID();
const TXH = { ...H, "X-OC-Session-Id": SID, "Content-Type": "application/json" };
async function sql(stmt: string, params?: unknown[]) {
const r = await fetch(`${BASE}/sql`, {
method: "POST", headers: TXH,
body: JSON.stringify(params ? { sql: stmt, params } : { sql: stmt }),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
}
await sql("BEGIN");
await sql("UPDATE shop.orders SET status = $1 WHERE id = $2", ["shipped", "ord-1001"]);
await sql("UPDATE shop.orders SET status = $1 WHERE id = $2", ["shipped", "ord-1002"]);
console.log((await sql("COMMIT")).ops_committed);sid := uuid.NewString()
sqlStmt := func(stmt string, params []any) (map[string]any, error) {
payload := map[string]any{"sql": stmt}
if params != nil {
payload["params"] = params // positional: $1, $2, ...
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, "POST", base+"/sql", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OC_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-OC-Session-Id", sid) // same id for the whole flow
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
var out map[string]any
return out, json.NewDecoder(resp.Body).Decode(&out)
}
sqlStmt("BEGIN", nil)
sqlStmt("UPDATE shop.orders SET status = $1 WHERE id = $2", []any{"shipped", "ord-1001"})
sqlStmt("COMMIT", nil)
A SQL session transaction gives you atomicity, not isolation. There is no read set and no snapshot, so two concurrent sessions doing read-modify-write on the same row will both commit and one update is lost — no error, no 409. If a lost update would be a bug, use the /tx endpoints instead. The level is not selectable here: a BEGIN carrying an ISOLATION LEVEL clause is refused rather than quietly downgraded.
This surface does not read its own writes — a deliberate divergence from PostgreSQL. A SELECT after a buffered INSERT in the same flow will not see the inserted row, and an UPDATE … WHERE matches against committed state, not against what you buffered. One consequence worth spelling out: two INSERTs of the same primary key inside one flow collapse into a single row at commit — last one wins, with no duplicate-key error. Sequence read-then-write work so the reads happen before BEGIN.
It lives in the serving process. If the engine restarts, or the instance fails over to its standby, the buffer is gone — the next statement in that flow returns 409 with either "transaction not found — it may have been lost to an engine restart" or "transaction lost on failover". Nothing was written. The recovery is the same in both cases: send ROLLBACK, then retry the whole transaction. The engine deliberately refuses to forward the statement to the new primary, because there it would execute as a durable standalone write that your later COMMIT or ROLLBACK could not undo.
The buffer is swept once it is five minutes old, measured from BEGIN — total age, not idle time. That bound is deliberate: without it, one forgotten BEGIN would make every later write on that session silently buffer forever. After the sweep the session reads as not-in-a-transaction, so subsequent statements execute normally and a late COMMIT honestly reports that there is nothing open.
Reading inside a transaction.
Point reads by primary key use GET /tx/:tx_id/rows/:schema/:pk, shown above. For anything broader there is POST /tx/:tx_id/query — with two constraints worth knowing before you plan around it.
- It takes a plan document, not a SQL string. There is no way to run a SQL
SELECTinside a/txtransaction today. The body is the same internal plan format the/v1/queryendpoint accepts. - Full-table scans only. Read-your-writes is wired into the scan leaf and nowhere else, so a plan that would read the store through an index, a column scan, a row-count fast path, or a graph traversal is refused with 400 rather than answered with a stale result. Scans wrapped in filter, project, sort, limit, distinct, aggregate, join or set operators are fine — the operators compose over correct inputs.
# /tx/:id/query takes a PLAN document, not a SQL string. Every node
# carries an "op" tag; the leaf here is a full-table scan.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/tx/$TX_ID/query" \
-H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
-d '{
"op": "filter",
"child": { "op": "scan", "schema": "shop.orders" },
"predicate": { "op": "eq", "path": "customer", "value": "cus-77" }
}'
# { "rows": [ ... ], "tx_id": "01JTS0...", "_preview": true }
#
# Rows reflect this transaction's own buffered writes. A plan whose
# reads bottom out anywhere other than a scan leaf is refused with 400.
A scan-only read is O(table). If your transaction needs an indexed lookup, do the lookup before begin and pass the primary keys in — then read those keys through the transaction so they enter the read set.
Every error you can get.
| HTTP | error | When | What to do |
|---|---|---|---|
| 409 | tx_conflict | A key this transaction READ changed between begin and commit. | Retry the whole transaction from begin. Nothing was written. |
| 409 | serialization_failure | Serializable only. The commit-time check found a read-write dependency cycle (write skew or a phantom). | Retry the whole transaction from begin. Nothing was written. |
| 409 | constraint_violation | A foreign key, back-reference, CHECK or UNIQUE constraint failed at commit. | Not retryable as-is. Fix the data, then run a fresh transaction. The handle goes to rolled_back. |
| 409 | (no code) transaction not found | SQL session transactions only. The buffer was lost to an engine restart or a failover. | Send ROLLBACK, then retry the whole transaction. |
| 413 | tx_buffer_cap_exceeded | More than 4,096 buffered write operations in one transaction. | Commit and open a new transaction, or use the batch row endpoint instead. |
| 429 | tx_open_cap_exceeded | More than 64 concurrently active transactions for the instance. | Commit or roll back what is open. Carries Retry-After: 1. |
| 501 | serializable_sharded_unsupported | ?isolation=serializable requested on a sharded instance. | Use snapshot isolation, or run on a single-shard instance. |
| 501 | serializable_raft_unsupported | A serializable transaction reached commit on an instance running quorum replication. | Use snapshot isolation on that instance. |
| 501 | (no code) cross-shard table | A transaction touched a table owned by a different shard. | Keep each transaction inside one shard's tables. |
| 400 | (no code) scan-only plan | A /tx/:id/query plan reads the store outside a scan leaf. | Re-express as a scan-based plan, or run the read outside the transaction. |
| 400 | (no code) params on BEGIN | A params array was sent with BEGIN, COMMIT or ROLLBACK on /sql. | Transaction-control statements take no bind parameters. Send sql only. |
| 500 | (no code) tx commit: … | Validation, encoding or storage failure during commit. | Nothing was written; the handle goes to rolled_back. Retry from a fresh begin. |
A 404 from any /tx route means the id is unknown or belongs to another instance — the two are intentionally identical. A 409 from a row op means the transaction is no longer active; check GET /tx/:tx_id for which terminal state it reached.
Limits & gotchas.
| Limit | Value | Why it exists |
|---|---|---|
| Buffered operations per transaction | 4,096 | The buffer is held whole in memory. Breach returns 413. For bulk loading use the batch row endpoint, which is a different path with different limits. |
| Concurrently active transactions | 64 | Per instance. Each open transaction pins its read and write sets. Breach returns 429 with Retry-After: 1. |
| Reads per transaction | unbounded | The 4,096 cap counts write operations only. The read set has no cap and is held in memory, so a transaction that reads a very large number of keys is a memory risk you own. |
| Buffered statements (SQL session) | unbounded | The SQL session buffer has no operation cap at all. Nothing stops you buffering more than fits in memory — keep these transactions small and let the five-minute age limit be your backstop. |
| Request body | 8 MiB | Per request, on every transaction route. A row larger than this cannot be buffered; breach returns 413. |
Idle timeout (/tx) | 5 min | A sweeper aborts transactions with no client activity for this long and marks them expired. Every operation refreshes the clock, so an active transaction is never swept. |
| Total age (SQL session) | 5 min | Measured from BEGIN, not idle time. Activity does not extend it — a SQL session transaction cannot run longer than this, full stop. |
On a sharded instance every table a /tx transaction touches — including the targets of any foreign key on a written row — must be owned by the same shard. Anything else is refused with 501. The check runs before the table is looked up, so you get the real reason rather than a misleading 404.
Data-definition statements are not part of the /tx surface at all, and schema migrations run through their own online-cutover path. Register or migrate a table before the transaction that uses it.
The /tx surface covers rows — there is no /tx/:id/vector, /tx/:id/fts or /tx/:id/cypher route. Embeddings written through the vector endpoint and documents indexed through the full-text endpoint are separate write paths and do not join the transaction: they apply immediately and a rollback does not undo them. Sequence the row write inside the transaction and the derived write after a successful commit.
Deleting a row inside a transaction does not maintain the spatial index for a geospatial column on that table. If you delete rows this way, run the table's geo reindex endpoint afterwards. Non-transactional deletes are unaffected.
A transaction that only read still walks its whole read set at commit. If you are only reading and do not need a consistency check across the reads, skip the transaction and query normally — it is strictly cheaper.
Transactions are flat. There is no SAVEPOINT, no partial rollback, and opening a transaction inside another is not a thing — you get two independent transactions that can conflict with each other.
Related.
- Row CRUD — the non-transactional row endpoints these buffer against, and the batch path for bulk writes.
- SQL — the full statement surface, including the write statements a SQL session transaction buffers.
- Error reference — the shared error envelope every endpoint returns.
- Multi-node — what sharding and failover change about the guarantees on this page.