diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index fceec3e062..5d7fbcc1c1 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -87,6 +87,8 @@ #include "cluster/cluster_tt_status.h" /* spec-3.14 D2b writer wait bridge */ #include "cluster/cluster_tx_enqueue.h" /* spec-5.2 D4 cross-node TX enqueue wait */ #include "cluster/cluster_visibility_resolve.h" /* spec-3.14 D5b surely-dead guard */ +#include "cluster/cluster_writer_chain.h" /* spec-7.1a D0 terminal-writer chaining */ +#include "cluster/cluster_xid_stripe.h" /* spec-7.1a D5 foreign-class chain floor */ #include "cluster/cluster_itl_touch.h" /* xact-local touch list */ #include "cluster/cluster_scn.h" /* cluster_scn_advance / SCN */ /* PGRAC (spec-3.4b D5): real UBA encode + xact-local TT binding. */ @@ -2962,6 +2964,94 @@ xmax_infomask_changed(uint16 new_infomask, uint16 old_infomask) * generated by another transaction). */ #ifdef USE_PGRAC_CLUSTER +/* + * cluster_writer_chain_probe_new_version -- spec-7.1a D0 (Q9 single hop). + * + * Prove the committed remote UPDATE's new version is reachable and + * chain-valid before TM_Updated is surfaced to EvalPlanQual: the tuple at + * new_tid must exist and its xmin must be exactly the updater's xid (a + * recycled line pointer / broken chain probes false -> the caller keeps + * the fail-closed floor). Reads the new version's page through the + * ordinary cluster-coherent buffer path; EvalPlanQual re-fetches and + * re-judges the version itself, so only a probe descriptor (t_self / + * t_tableOid; t_data stays NULL) is returned. + * + * Called with the OLD page's content lock held EXCLUSIVE. A same-page + * new version is probed under that lock; a different page is probed by + * dropping the old lock first (never two content locks at once -- lock + * order) and reacquiring it EXCLUSIVE after, the caller's post-return + * re-validation covering the window (L350). + */ +static bool +cluster_writer_chain_probe_new_version(Relation relation, Buffer oldbuf, + ItemPointer new_tid, TransactionId update_xid, + HeapTupleData *out_tup) +{ + BlockNumber newblk = ItemPointerGetBlockNumber(new_tid); + OffsetNumber newoff = ItemPointerGetOffsetNumber(new_tid); + Page newpage; + bool ok = false; + + /* + * PGRAC: spec-7.1a hardening -- refuse a forward pointer that names no + * probeable block in THIS relation. The moved-partitions sentinel + * block is InvalidBlockNumber (== P_NEW: ReadBuffer below would + * silently EXTEND the relation by one empty page), and any other + * out-of-range block (a corrupt or foreign-partition ctid) must never + * reach ReadBuffer either. Returning false keeps the caller's + * CWO_UNRESOLVABLE fail-closed floor. + */ + if (!BlockNumberIsValid(newblk) || newblk >= RelationGetNumberOfBlocks(relation)) + return false; + + if (newblk == BufferGetBlockNumber(oldbuf)) + { + newpage = BufferGetPage(oldbuf); + if (newoff <= PageGetMaxOffsetNumber(newpage)) + { + ItemId lp = PageGetItemId(newpage, newoff); + + if (ItemIdIsNormal(lp)) + { + HeapTupleHeader htup = (HeapTupleHeader) PageGetItem(newpage, lp); + + ok = TransactionIdEquals(HeapTupleHeaderGetXmin(htup), update_xid); + } + } + } + else + { + Buffer newbuf; + + LockBuffer(oldbuf, BUFFER_LOCK_UNLOCK); + newbuf = ReadBuffer(relation, newblk); + LockBuffer(newbuf, BUFFER_LOCK_SHARE); + newpage = BufferGetPage(newbuf); + if (newoff <= PageGetMaxOffsetNumber(newpage)) + { + ItemId lp = PageGetItemId(newpage, newoff); + + if (ItemIdIsNormal(lp)) + { + HeapTupleHeader htup = (HeapTupleHeader) PageGetItem(newpage, lp); + + ok = TransactionIdEquals(HeapTupleHeaderGetXmin(htup), update_xid); + } + } + UnlockReleaseBuffer(newbuf); + LockBuffer(oldbuf, BUFFER_LOCK_EXCLUSIVE); + } + + if (ok) + { + out_tup->t_self = *new_tid; + out_tup->t_tableOid = RelationGetRelid(relation); + out_tup->t_data = NULL; + out_tup->t_len = 0; + } + return ok; +} + /* * spec-3.14 D2b + spec-5.2 §3.2 (C4/D11): cross-node writer/locker wait. * @@ -2973,21 +3063,32 @@ xmax_infomask_changed(uint16 new_infomask, uint16 old_infomask) * * spec-5.2 replaces the spec-3.14 unconditional 53R9H with a real * cross-node completion wait (cluster_tx_enqueue_wait) and a typed - * outcome. Return value: + * outcome; spec-7.1a D0 adds terminal-WRITER chaining. Return value: * * false -- NOT a cross-node conflict (no peer / no ITL ref / local * locker). The caller runs PG's native locker resolution * unchanged (it is correct for a local xid). - * true -- a REMOTE LOCK-ONLY holder is terminal (committed/aborted); - * the row lock is released and the row was not modified, so - * the caller MUST skip native locker resolution and may - * continue (can_continue). Skipping is mandatory: native - * XactLockTableWait on the remote raw xid hangs (BLOCKER C). - * - * A remote WRITER holder (committed/aborted UPDATE/DELETE), a remote - * MultiXact, a wait timeout, or cluster.tx_enqueue_wait=off all raise - * (ereport) inside -- they never return. Cross-node writer-writer - * chaining (TM_Updated against the remote new version) is forwarded. + * true -- the cluster path handled a REMOTE holder; *res says how: + * TM_Ok a LOCK-ONLY holder is terminal (lock released), + * or the remote WRITER ABORTED: the row was not + * (or no longer) modified. The caller MUST skip + * native locker resolution (XactLockTableWait on + * the remote raw xid hangs, BLOCKER C), + * re-validate against the pre-wait snapshot, + * clear the released xmax and continue. + * TM_Updated the remote writer's UPDATE committed and the + * single-hop new version was probed (Q9); + * TM_Deleted its DELETE committed. The caller re-validates, + * then routes result into its native failure + * exit, which fills tmfd from the on-page old + * tuple exactly as for a local conflict + * (spec-7.1a D0; only with + * cluster.crossnode_write_write=on). + * + * A remote MultiXact, a wait timeout, cluster.tx_enqueue_wait=off, a + * terminal remote WRITER under cluster.crossnode_write_write=off (the + * pre-7.1a floor), or an unprovable writer outcome all raise (ereport) + * inside -- they never return. * * Called with the buffer content lock held. On the false (native) * return the lock is restored. On the true return the lock is @@ -2997,7 +3098,8 @@ xmax_infomask_changed(uint16 new_infomask, uint16 old_infomask) */ static bool cluster_heap_writer_wait_failclosed(Relation relation, Buffer buffer, HeapTuple tup, - TransactionId xwait, uint16 saved_infomask) + TransactionId xwait, uint16 saved_infomask, + LockWaitPolicy wait_policy, TM_Result *res) { Page page = BufferGetPage(buffer); ClusterUndoTTSlotRef cref; @@ -3082,6 +3184,23 @@ cluster_heap_writer_wait_failclosed(Relation relation, Buffer buffer, HeapTuple || cres.status == CLUSTER_TT_STATUS_CLEANED_OUT); if (!tt_terminal) { + /* + * PGRAC: spec-7.1a D0 -- only heap_lock_tuple passes a non-Block + * policy; never Block-wait under LockWaitSkip / LockWaitError + * (mirrors the native switch semantics for a live local holder). + */ + if (wait_policy == LockWaitSkip) + { + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + *res = TM_WouldBlock; + return true; + } + if (wait_policy == LockWaitError) + ereport(ERROR, + (errcode(ERRCODE_LOCK_NOT_AVAILABLE), + errmsg("could not obtain lock on row in relation \"%s\"", + RelationGetRelationName(relation)))); + if (cluster_tx_enqueue_wait_enabled) { ClusterTxwResult txw; @@ -3136,20 +3255,112 @@ cluster_heap_writer_wait_failclosed(Relation relation, Buffer buffer, HeapTuple * (its TransactionIdIsInProgress loop never breaks). * * WRITER holder (committed/aborted UPDATE/DELETE): the row version may - * have changed remotely; cross-node writer-writer chaining (surfacing - * TM_Updated / EvalPlanQual against the remote new version) is forwarded. - * Fail closed retryably (53R9H) instead of running the unsafe native path. + * have changed remotely. With cluster.crossnode_write_write=on the + * outcome is resolved from cluster authority and chained onto the + * native TM_Result contract (spec-7.1a D0, below); off -- or when the + * outcome / new version is unprovable -- fail closed retryably (53R9H) + * instead of running the unsafe native path. */ LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); if (is_lock_only) + { + *res = TM_Ok; return true; + } + + /* + * PGRAC: spec-7.1a D0 -- terminal remote WRITER chaining. + * + * Resolve the writer's outcome from cluster authority (page ITL ref + + * TT overlay + origin live-IC verdict, all inside + * cluster_visibility_resolve_tuple; never native CLOG/ProcArray on the + * foreign xid, AD-012 例外 9) and map it onto the native TM_Result + * contract via cluster_writer_chain_decide: + * + * ABORTED -> TM_Ok (row unchanged; the caller re-validates, + * clears the released xmax and continues) + * COMMITTED, DELETE -> TM_Deleted (t_ctid self-points) + * COMMITTED, UPDATE -> TM_Updated after the single-hop new version + * is probed reachable + chain-valid (Q9); the + * caller's native failure exit fills tmfd from + * the on-page tuple + * anything unprovable-> fall through to the pre-7.1a 53R9H floor + * + * The resolve and the DELETE/UPDATE split read the tuple under the + * content lock reacquired above; the caller's post-return re-validation + * (xmax_infomask_changed) discards the outcome if the tuple moved. + */ + if (cluster_crossnode_write_write) + { + ClusterVisResolve xr; + ClusterWriterOutcome wo; + HeapTupleData probe_tup; + ItemPointerData old_t_ctid; + + memset(&wo, 0, sizeof(wo)); + wo.kind = CWO_UNRESOLVABLE; + old_t_ctid = tup->t_data->t_ctid; + + cluster_visibility_resolve_tuple(buffer, tup->t_data, xwait, + CLUSTER_VIS_XMAX_UPDATE, &xr); + + if (xr.evidence == CLUSTER_VIS_EVIDENCE_REMOTE) + { + if (xr.status == CLUSTER_TT_STATUS_ABORTED) + wo.kind = CWO_ABORTED; + else if (xr.status == CLUSTER_TT_STATUS_COMMITTED + || xr.status == CLUSTER_TT_STATUS_CLEANED_OUT) + { + if (ItemPointerEquals(&tup->t_self, &old_t_ctid)) + wo.kind = CWO_DELETED; + else if (ItemPointerIndicatesMovedPartitions(&old_t_ctid)) + { + /* + * PGRAC: spec-7.1a hardening -- the committed remote + * UPDATE moved the row to ANOTHER partition: the + * forward pointer is the moved-partitions sentinel + * (block == InvalidBlockNumber == P_NEW), which names + * no tuple in THIS relation, and probing it would + * ReadBuffer-extend the old partition (same judgement + * the native paths make first, e.g. the SatisfiesUpdate + * cluster fork's is_delete). Keep CWO_UNRESOLVABLE: + * the retryable 53R9H floor below is the fail-closed + * disposition; cross-partition chaining is not a + * single-hop probe. + */ + } + else if (cluster_writer_chain_probe_new_version(relation, buffer, + &old_t_ctid, xwait, + &probe_tup)) + { + wo.kind = CWO_UPDATED; + wo.new_ctid = old_t_ctid; + wo.new_tuple = &probe_tup; + } + /* probe failure keeps CWO_UNRESOLVABLE -> floor below */ + } + /* IN_PROGRESS / UNKNOWN after a completed wait: floor below */ + } + if (cluster_writer_chain_decide(&wo, &old_t_ctid, xwait, res, NULL)) + { + cluster_vis_bump_writer_chain_resolved_count(); + return true; + } + /* fall through: unprovable -> the pre-7.1a fail-closed floor */ + } + + cluster_vis_bump_writer_chain_failclosed_count(); cluster_vis_bump_vis_conflict_failclosed_count(); ereport(ERROR, (errcode(ERRCODE_CLUSTER_CROSS_NODE_WRITE_CONFLICT), errmsg("cross-node write conflict: remote writer %u on node %u is terminal", xwait, cref.origin_node_id), - errhint("Cross-node writer-writer update chaining is not yet supported; " - "retry the transaction."))); + cluster_crossnode_write_write + ? errhint("Peer authority or the new row version is not yet " + "resolvable; retry the transaction.") + : errhint("Cross-node writer-writer update chaining is disabled; " + "enable cluster.crossnode_write_write or retry the " + "transaction."))); } #endif /* USE_PGRAC_CLUSTER */ @@ -3180,6 +3391,7 @@ heap_delete(Relation relation, ItemPointer tid, uint8 cluster_itl_slot = CLUSTER_ITL_SLOT_UNALLOCATED; bool cluster_itl_active = false; UBA cluster_itl_uba = InvalidUba_init; /* spec-3.4b D5 real UBA */ + TM_Result cluster_writer_res = TM_Ok; /* spec-7.1a D0 chained result */ #endif Assert(ItemPointerIsValid(tid)); @@ -3261,10 +3473,12 @@ heap_delete(Relation relation, ItemPointer tid, * below then yields TM_Ok. A false return means a local locker; the * native path is correct. A remote writer / timeout / wait-off raises. */ - if (cluster_heap_writer_wait_failclosed(relation, buffer, &tp, xwait, infomask)) + if (cluster_heap_writer_wait_failclosed(relation, buffer, &tp, xwait, infomask, + LockWaitBlock, &cluster_writer_res)) { /* - * Remote LOCK-ONLY holder terminal -> its row lock is released. + * Remote holder handled by the cluster path (lock released, + * writer aborted, or terminal writer chained -- spec-7.1a D0). * * The helper dropped and reacquired the buffer content lock across * the wait, so re-validate against the pre-wait snapshot (as the @@ -3273,19 +3487,35 @@ heap_delete(Relation relation, ItemPointer tid, * re-run HeapTupleSatisfiesUpdate (else: lost update / delete of an * unvalidated version — Rule 8.A). */ - if (xmax_infomask_changed(tp.t_data->t_infomask, infomask) || + if ((vmbuffer == InvalidBuffer && PageIsAllVisible(page)) || + xmax_infomask_changed(tp.t_data->t_infomask, infomask) || !TransactionIdEquals(HeapTupleHeaderGetRawXmax(tp.t_data), xwait)) goto l1; + if (cluster_writer_res != TM_Ok) + { + /* + * PGRAC: spec-7.1a D0 -- the remote writer's UPDATE/DELETE + * committed. Its xmax is a COMMITTED foreign deleter: never + * stamp HEAP_XMAX_INVALID over it (a false hint on a shared + * page poisons every node -- 缺陷 1 / Rule 8.A). Route the + * chained result into the native failure exit, which fills + * tmfd from the on-page tuple exactly as for a local + * conflict. + */ + result = cluster_writer_res; + goto cluster_writer_terminal; + } + /* - * Unchanged: mark the released xmax invalid so the TM_Ok + * TM_Ok: released lock-only holder, or an authoritatively ABORTED + * remote writer. Mark the released xmax invalid so the TM_Ok * determination below sees an unlocked tuple and the delete's new * xmax is a single xid, not a node-local MultiXact a peer cannot * read (AD-012). Then fall through to the LOCKED_ONLY / * XMAX_INVALID -> TM_Ok path below. */ - HeapTupleSetHintBits(tp.t_data, buffer, HEAP_XMAX_INVALID, - InvalidTransactionId); + cluster_heap_stamp_released_xmax_invalid(tp.t_data, buffer); } else #endif @@ -3395,6 +3625,9 @@ heap_delete(Relation relation, ItemPointer tid, } /* sanity check the result HeapTupleSatisfiesUpdate() and the logic above */ +#ifdef USE_PGRAC_CLUSTER +cluster_writer_terminal: /* PGRAC: spec-7.1a D0 chained result */ +#endif if (result != TM_Ok) { Assert(result == TM_SelfModified || @@ -3859,6 +4092,7 @@ heap_update(Relation relation, ItemPointer otid, HeapTuple newtup, bool cluster_itl_new_active = false; /* spec-3.4b D5: single binding shared across old + new stamps (F11). */ UBA cluster_itl_uba = InvalidUba_init; + TM_Result cluster_writer_res = TM_Ok; /* spec-7.1a D0 chained result */ #endif Assert(ItemPointerIsValid(otid)); @@ -4087,10 +4321,12 @@ heap_update(Relation relation, ItemPointer otid, HeapTuple newtup, * false return means a local locker; the native path below is correct. * A remote writer / timeout / wait-off raises inside. */ - if (cluster_heap_writer_wait_failclosed(relation, buffer, &oldtup, xwait, infomask)) + if (cluster_heap_writer_wait_failclosed(relation, buffer, &oldtup, xwait, infomask, + LockWaitBlock, &cluster_writer_res)) { /* - * Remote LOCK-ONLY holder is terminal -> its row lock is released. + * Remote holder handled by the cluster path (lock released, + * writer aborted, or terminal writer chained -- spec-7.1a D0). * * cluster_heap_writer_wait_failclosed dropped and reacquired the * buffer content lock across a multi-second wait, so the tuple may @@ -4107,16 +4343,32 @@ heap_update(Relation relation, ItemPointer otid, HeapTuple newtup, xwait)) goto l2; + if (cluster_writer_res != TM_Ok) + { + /* + * PGRAC: spec-7.1a D0 -- the remote writer's UPDATE/DELETE + * committed. Its xmax is a COMMITTED foreign deleter: never + * stamp HEAP_XMAX_INVALID over it (a false hint on a shared + * page poisons every node -- 缺陷 1 / Rule 8.A). Route the + * chained result into the native failure exit, which fills + * tmfd from the on-page tuple exactly as for a local + * conflict. + */ + result = cluster_writer_res; + goto cluster_writer_terminal; + } + /* - * Unchanged: the released remote lock is gone. Mark the released - * xmax invalid (mirrors native UpdateXmaxHintBits for a gone - * lock-only locker) so compute_new_xmax_infomask below treats the - * tuple as unlocked and does NOT fold the released remote lock into - * a node-local MultiXact that a peer cannot read (MultiXact ids - * alias across instances, like raw xids — AD-012). + * TM_Ok: released lock-only holder, or an authoritatively ABORTED + * remote writer. The released remote lock (or aborted update) is + * gone. Mark the released xmax invalid (mirrors native + * UpdateXmaxHintBits for a gone lock-only locker) so + * compute_new_xmax_infomask below treats the tuple as unlocked and + * does NOT fold the released remote lock into a node-local + * MultiXact that a peer cannot read (MultiXact ids alias across + * instances, like raw xids — AD-012). */ - HeapTupleSetHintBits(oldtup.t_data, buffer, HEAP_XMAX_INVALID, - InvalidTransactionId); + cluster_heap_stamp_released_xmax_invalid(oldtup.t_data, buffer); checked_lockers = true; locker_remains = false; can_continue = true; @@ -4278,6 +4530,9 @@ heap_update(Relation relation, ItemPointer otid, HeapTuple newtup, } /* Sanity check the result HeapTupleSatisfiesUpdate() and the logic above */ +#ifdef USE_PGRAC_CLUSTER +cluster_writer_terminal: /* PGRAC: spec-7.1a D0 chained result */ +#endif if (result != TM_Ok) { Assert(result == TM_SelfModified || @@ -5579,6 +5834,41 @@ heap_lock_tuple(Relation relation, HeapTuple tuple, { TM_Result res; +#ifdef USE_PGRAC_CLUSTER + /* + * PGRAC: spec-7.1a D5 -- a remote-marker multixact + * updater must not seed the chain walk: the entry decode + * (MultiXactIdGetUpdateXid) reads the LOCAL pg_multixact, + * which aliases for a foreign multi (AD-012 / L349). The + * walk entry floors the non-multi foreign root itself; + * the multi marker needs the page, so check here. + */ + if (cluster_peer_mode_enabled() && (infomask & HEAP_XMAX_IS_MULTI)) + { + uint16 marker_origin = 0; + bool remote_multi; + + LockBuffer(*buffer, BUFFER_LOCK_EXCLUSIVE); + if (xmax_infomask_changed(tuple->t_data->t_infomask, infomask) || + !TransactionIdEquals(HeapTupleHeaderGetRawXmax(tuple->t_data), + xwait)) + goto l3; + remote_multi = PageHasItl(page) + && cluster_itl_find_multixact_origin_by_xmax(page, + (MultiXactId) xwait, + &marker_origin) + && (int32) marker_origin != cluster_node_id; + if (remote_multi) + ereport(ERROR, + (errcode(ERRCODE_CLUSTER_CROSS_NODE_WRITE_CONFLICT), + errmsg("cross-node write conflict: remote multixact updater %u on node %u", + xwait, marker_origin), + errhint("Cross-node update-chain locking is not yet " + "supported; retry the transaction."))); + LockBuffer(*buffer, BUFFER_LOCK_UNLOCK); + } +#endif + res = heap_lock_updated_tuple(relation, infomask, xwait, &t_ctid, GetCurrentTransactionId(), @@ -5767,6 +6057,42 @@ heap_lock_tuple(Relation relation, HeapTuple tuple, if (status >= MultiXactStatusNoKeyUpdate) elog(ERROR, "invalid lock mode in heap_lock_tuple"); +#ifdef USE_PGRAC_CLUSTER + /* + * PGRAC: spec-7.1a D5 -- a remote-marker multixact must not + * reach the native MultiXactIdWait below: multixact ids are + * node-local, so decoding a foreign one against the local + * pg_multixact aliases (AD-012 / L349; heap_update and + * heap_delete floor this in the writer-wait bridge, this is + * the heap_lock_tuple sibling, L352). Reacquire the content + * lock for the marker scan; re-judge from scratch if the + * tuple moved meanwhile. + */ + if (cluster_peer_mode_enabled()) + { + uint16 marker_origin = 0; + bool remote_multi; + + LockBuffer(*buffer, BUFFER_LOCK_EXCLUSIVE); + if (xmax_infomask_changed(tuple->t_data->t_infomask, infomask) || + !TransactionIdEquals(HeapTupleHeaderGetRawXmax(tuple->t_data), + xwait)) + goto l3; + remote_multi = PageHasItl(page) + && cluster_itl_find_multixact_origin_by_xmax(page, (MultiXactId) xwait, + &marker_origin) + && (int32) marker_origin != cluster_node_id; + if (remote_multi) + ereport(ERROR, + (errcode(ERRCODE_CLUSTER_CROSS_NODE_WRITE_CONFLICT), + errmsg("cross-node write conflict: remote multixact holder %u on node %u", + xwait, marker_origin), + errhint("Cross-node multixact wait is not yet supported; " + "retry the transaction."))); + LockBuffer(*buffer, BUFFER_LOCK_UNLOCK); + } +#endif + /* wait for multixact to end, or die trying */ switch (wait_policy) { @@ -6003,6 +6329,84 @@ heap_lock_tuple(Relation relation, HeapTuple tuple, cluster_remote_lockonly_terminal = true; } } +#endif +#ifdef USE_PGRAC_CLUSTER + /* + * PGRAC: spec-7.1a D0 -- remote WRITER holder in + * heap_lock_tuple (SELECT FOR UPDATE / the EvalPlanQual chase + * behind a chained TM_Updated). The pre-7.1a code fell + * through to the native wait below, whose XactLockTableWait + * on a remote raw xid aliases/hangs (Blocker C family, L349) + * -- the same hole the writer-wait bridge closes for + * heap_update / heap_delete. Reuse that bridge: it waits + * (cluster_tx_enqueue_wait, honoring wait_policy), resolves + * the terminal outcome from cluster authority and either + * chains it (cluster.crossnode_write_write=on) or keeps the + * fail-closed floor (53R9H). Every remote-holder path exits + * via goto/ereport; only a LOCAL holder falls through to the + * native wait. + */ + if (!HEAP_XMAX_IS_LOCKED_ONLY(infomask) + && !(infomask & HEAP_XMAX_IS_MULTI) + && cluster_peer_mode_enabled()) + { + TM_Result cwres = TM_Ok; + + /* + * F10: PG released the buffer lock before sleeping; + * reacquire + revalidate before the bridge scans the ITL + * (it expects the content lock held), exactly like the + * lock-only block above. + */ + LockBuffer(*buffer, BUFFER_LOCK_EXCLUSIVE); + if (xmax_infomask_changed(tuple->t_data->t_infomask, infomask) || + !TransactionIdEquals(HeapTupleHeaderGetRawXmax(tuple->t_data), + xwait)) + goto l3; + + if (cluster_heap_writer_wait_failclosed(relation, *buffer, tuple, + xwait, infomask, + wait_policy, &cwres)) + { + /* + * The bridge dropped the content lock across its + * wait/lookup; re-validate against the pre-wait + * snapshot before consuming the outcome (L350). + */ + if (xmax_infomask_changed(tuple->t_data->t_infomask, infomask) || + !TransactionIdEquals(HeapTupleHeaderGetRawXmax(tuple->t_data), + xwait)) + goto l3; + + if (cwres == TM_Ok) + { + /* + * Authoritatively ABORTED remote writer: mark the + * dead xmax invalid (mirrors native + * UpdateXmaxHintBits for an aborted writer; the + * verdict came from cluster authority, never the + * local CLOG view of the foreign xid) and re-run + * the tuple-state machine -- it now sees + * HEAP_XMAX_INVALID and locks the tuple natively. + */ + cluster_heap_stamp_released_xmax_invalid(tuple->t_data, + *buffer); + goto l3; + } + + /* + * TM_WouldBlock (LockWaitSkip on a live remote + * writer) or a chained TM_Updated / TM_Deleted: route + * into the native failure exit, which fills tmfd from + * the on-page tuple. Content lock is held. + */ + result = cwres; + goto failed; + } + + /* LOCAL writer: PG's native wait below is correct. */ + LockBuffer(*buffer, BUFFER_LOCK_UNLOCK); + } #endif /* wait for regular transaction to end, or die trying */ #ifdef USE_PGRAC_CLUSTER @@ -6081,8 +6485,7 @@ heap_lock_tuple(Relation relation, HeapTuple tuple, * meaningless for a foreign xid (AD-012). The result-determination * below then sees HEAP_XMAX_INVALID / LOCKED_ONLY and yields TM_Ok. */ - HeapTupleSetHintBits(tuple->t_data, *buffer, HEAP_XMAX_INVALID, - InvalidTransactionId); + cluster_heap_stamp_released_xmax_invalid(tuple->t_data, *buffer); } else #endif @@ -7105,6 +7508,26 @@ heap_lock_updated_tuple_rec(Relation rel, TransactionId priorXmax, * (sub)transaction, then we already locked the last live one in the * chain, thus we're done, so return success. */ +#ifdef USE_PGRAC_CLUSTER + /* + * PGRAC: spec-7.1a D5 -- a chain version created by ANOTHER node's + * xid cannot be judged by the native TransactionIdDidAbort below + * (raw xids alias across instances, AD-012 例外 9). Cross-node + * update-chain locking is the Q9 multi-hop forward: fail closed + * retryably instead of mis-walking the chain. + */ + if (cluster_peer_mode_enabled() + && cluster_xid_provably_foreign(HeapTupleHeaderGetXmin(mytup.t_data))) + { + UnlockReleaseBuffer(buf); + ereport(ERROR, (errcode(ERRCODE_CLUSTER_CROSS_NODE_WRITE_CONFLICT), + errmsg("cross-node write conflict: update chain crosses onto " + "node-foreign xid %u", + HeapTupleHeaderGetXmin(mytup.t_data)), + errhint("Cross-node update-chain locking is not yet supported; " + "retry the transaction."))); + } +#endif if (TransactionIdDidAbort(HeapTupleHeaderGetXmin(mytup.t_data))) { result = TM_Ok; @@ -7115,6 +7538,38 @@ heap_lock_updated_tuple_rec(Relation rel, TransactionId priorXmax, old_infomask2 = mytup.t_data->t_infomask2; xmax = HeapTupleHeaderGetRawXmax(mytup.t_data); +#ifdef USE_PGRAC_CLUSTER + /* + * PGRAC: spec-7.1a D5 -- the same foreign-class floor for the chain + * member's xmax: the branches below judge it with native + * DidCommit/IsInProgress/MultiXact machinery, all void for a + * node-foreign xid or a remote-marker multixact (L349/L352). + */ + if (cluster_peer_mode_enabled() && !(old_infomask & HEAP_XMAX_INVALID)) + { + bool foreign_xmax = false; + uint16 marker_origin = 0; + + if (old_infomask & HEAP_XMAX_IS_MULTI) + foreign_xmax = cluster_itl_find_multixact_origin_by_xmax( + BufferGetPage(buf), (MultiXactId) xmax, &marker_origin) + && (int32) marker_origin != cluster_node_id; + else + foreign_xmax = TransactionIdIsValid(xmax) + && cluster_xid_provably_foreign(xmax); + if (foreign_xmax) + { + UnlockReleaseBuffer(buf); + ereport(ERROR, (errcode(ERRCODE_CLUSTER_CROSS_NODE_WRITE_CONFLICT), + errmsg("cross-node write conflict: update chain member " + "locked/updated by another node (xmax %u)", + xmax), + errhint("Cross-node update-chain locking is not yet " + "supported; retry the transaction."))); + } + } +#endif + /* * If this tuple version has been updated or locked by some concurrent * transaction(s), what we do depends on whether our lock mode @@ -7509,6 +7964,24 @@ heap_lock_updated_tuple(Relation rel, */ MultiXactIdSetOldestMember(); +#ifdef USE_PGRAC_CLUSTER + /* + * PGRAC: spec-7.1a D5 -- never seed the chain walk from a + * node-foreign updater: the walk (and a foreign MULTI decode against + * the local pg_multixact) judges raw xids natively (AD-012 alias). + * The heap_lock_tuple call sites floor remote holders before calling + * here; this is defense in depth for the non-multi root. + */ + if (cluster_peer_mode_enabled() && !(prior_infomask & HEAP_XMAX_IS_MULTI) + && cluster_xid_provably_foreign(prior_raw_xmax)) + ereport(ERROR, (errcode(ERRCODE_CLUSTER_CROSS_NODE_WRITE_CONFLICT), + errmsg("cross-node write conflict: update chain rooted at " + "node-foreign updater %u", + prior_raw_xmax), + errhint("Cross-node update-chain locking is not yet supported; " + "retry the transaction."))); +#endif + prior_xmax = (prior_infomask & HEAP_XMAX_IS_MULTI) ? MultiXactIdGetUpdateXid(prior_raw_xmax, prior_infomask) : prior_raw_xmax; return heap_lock_updated_tuple_rec(rel, prior_xmax, prior_ctid, xid, mode); diff --git a/src/backend/access/heap/heapam_visibility.c b/src/backend/access/heap/heapam_visibility.c index 01f832f601..c46343197f 100644 --- a/src/backend/access/heap/heapam_visibility.c +++ b/src/backend/access/heap/heapam_visibility.c @@ -254,6 +254,31 @@ SetHintBits(HeapTupleHeader tuple, Buffer buffer, uint16 infomask, TransactionId MarkBufferDirtyHint(buffer, true); } +#ifdef USE_PGRAC_CLUSTER +/* + * cluster_heap_stamp_released_xmax_invalid -- authority-backed XMAX_INVALID. + * + * spec-7.1a D0/D2: SetHintBits above deliberately suppresses xmax hints + * whose xid is another node's class (spec-6.15 D7) because ordinary hint + * stamping is backed by native CLOG/ProcArray answers that are void for a + * foreign xid. The cluster writer/locker paths however must still + * physically clear a RELEASED (lock-only terminal) or ABORTED remote xmax + * once CLUSTER authority (TT / live-IC verdict) proved it gone -- leaving + * it in place makes compute_new_xmax_infomask treat the dead remote xid as + * a live locker and fold it into a node-local MultiXact that aliases on + * every peer (L349). This is the one sanctioned bypass: the fact comes + * from cluster authority, never from a native reading of the foreign xid, + * and marking released/aborted needs no commit-LSN interlock (see the + * SetHintBits header: aborted hints are always safe). + */ +void +cluster_heap_stamp_released_xmax_invalid(HeapTupleHeader tuple, Buffer buffer) +{ + tuple->t_infomask |= HEAP_XMAX_INVALID; + MarkBufferDirtyHint(buffer, true); +} +#endif + /* * HeapTupleSetHintBits --- exported version of SetHintBits() * @@ -852,12 +877,15 @@ cluster_satisfies_update_fork(HeapTuple htup, Buffer buffer, TM_Result *res) if (r.evidence == CLUSTER_VIS_EVIDENCE_REMOTE) { switch (cluster_vis_update_xmax_verdict(r.status, is_delete)) { case CVV_VISIBLE: + cluster_vis_bump_xmax_resolved_count(); /* spec-7.1a D6 */ *res = TM_Ok; return true; case CVV_GONE_UPDATED: + cluster_vis_bump_xmax_resolved_count(); /* spec-7.1a D6 */ *res = TM_Updated; return true; case CVV_GONE_DELETED: + cluster_vis_bump_xmax_resolved_count(); /* spec-7.1a D6 */ *res = TM_Deleted; return true; case CVV_BEING_MODIFIED: @@ -1566,8 +1594,10 @@ cluster_remote_live_xmax_keeps_visible(Buffer buffer, HeapTupleHeader tuple, Sna switch (cluster_vis_cr_xmax_verdict(xr.status, scn_decision)) { case CVV_VISIBLE: + cluster_vis_bump_xmax_resolved_count(); /* spec-7.1a D6 */ return 1; case CVV_INVISIBLE: + cluster_vis_bump_xmax_resolved_count(); /* spec-7.1a D6 */ return 0; default: return -1; /* unknown / unresolved -> fail closed */ diff --git a/src/backend/cluster/Makefile b/src/backend/cluster/Makefile index 409f537050..9ed67e8dcb 100644 --- a/src/backend/cluster/Makefile +++ b/src/backend/cluster/Makefile @@ -184,6 +184,7 @@ OBJS = \ cluster_undo_cleaner.o \ cluster_visibility_resolve.o \ cluster_visibility_verdict.o \ + cluster_writer_chain.o \ cluster_undo_record.o \ cluster_undo_retention.o \ cluster_undo_srf.o \ diff --git a/src/backend/cluster/cluster_cr_server.c b/src/backend/cluster/cluster_cr_server.c index eb431793dc..a798919e2b 100644 --- a/src/backend/cluster/cluster_cr_server.c +++ b/src/backend/cluster/cluster_cr_server.c @@ -333,6 +333,10 @@ lms_undo_fetch_serve(ClusterLmsCrSlot *slot) slot->undo_auth.origin_epoch = cluster_epoch_get_current(); slot->undo_auth.live_hwm_lsn = GetFlushRecPtr(NULL); slot->undo_auth.tt_generation = cluster_undo_tt_retention_rollover_count(); + /* PGRAC: spec-7.1a D3 -- co-sample the origin SCN clock with the same + * pre-content-read ordering (a stamp landing after the sample only makes + * the content newer than claimed; additive and safe). */ + slot->undo_auth.authority_scn = cluster_scn_current(); /* Serve only SELF-owned undo: the owner derives from this node's own * id, never from the wire (a forged request cannot redirect the read). */ @@ -409,6 +413,10 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) slot->undo_auth.origin_epoch = cluster_epoch_get_current(); slot->undo_auth.live_hwm_lsn = GetFlushRecPtr(NULL); slot->undo_auth.tt_generation = cluster_undo_tt_retention_rollover_count(); + /* PGRAC: spec-7.1a D3 -- co-sample the origin SCN clock with the same + * pre-content-read ordering (a stamp landing after the sample only makes + * the content newer than claimed; additive and safe). */ + slot->undo_auth.authority_scn = cluster_scn_current(); memset(slot->result_page, 0, BLCKSZ); v->magic = CLUSTER_GCS_UNDO_VERDICT_MAGIC; @@ -422,11 +430,41 @@ lms_undo_verdict_serve(ClusterLmsCrSlot *slot) case CLUSTER_TT_DURABLE_RESOLVED_SCN: if (!TransactionIdDidCommit(xid)) return false; /* C1b: stamped-then-crashed is in-doubt */ - if (!cluster_cr_accept_resolved_scn(scn)) - return false; /* wrap-suspect -> refuse */ - v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT; - v->commit_scn = scn; - v->wrap = wrap; + if (cluster_cr_accept_resolved_scn(scn)) { + v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_EXACT; + v->commit_scn = scn; + v->wrap = wrap; + return true; + } + + /* + * PGRAC: spec-7.1a hardening -- wrap-suspect stamped scn (below the + * retention horizon), reached routinely now that the requester + * finalizes EVERY shipped COMMITTED stamp here instead of concluding + * on the fetch fast leg. The EXACT value cannot be shipped (a + * same-valued xid recurrence across TT wrap could own a different + * scn), but "committed at/below a FROZEN bound" still can: + * - a LIVE recurrence would be a second by-value match -> + * AMBIGUOUS_WRAP (refused below), so the single live match has + * no live rival; + * - a RECYCLED recurrence's lost scn is at/below the max gated- + * recycle horizon (the zero-match arm's own bound); + * - this slot's own stamped scn bounds itself. + * Ship BELOW_HORIZON over the max of both candidates -- the same + * frozen, non-clock-chasing consumer contract as the zero-match arm + * (never cached; judged against the requester's read_scn, leg (e)). + * No gated recycle this incarnation -> the recycled-recurrence + * candidate is unboundable -> refuse, exactly like zero-match. + */ + if (!cluster_cr_retention_proof_origin_legs(&horizon)) + return false; + horizon = cluster_tt_slot_max_recycle_horizon(); + if (!SCN_VALID(horizon)) + return false; + if (scn_time_cmp(scn, horizon) > 0) + horizon = scn; + v->verdict = (uint8)CLUSTER_GCS_UNDO_VERDICT_COMMITTED_BELOW_HORIZON; + v->horizon_scn = horizon; return true; case CLUSTER_TT_DURABLE_RECYCLED_ZERO_MATCH: @@ -660,6 +698,8 @@ cluster_lms_cr_ship_ready(void) hdr->page_lsn = slot->undo_auth.live_hwm_lsn; memcpy(buf + header_len, slot->result_page, BLCKSZ); ClusterGcsUndoAuthTrailerSetTtGeneration(trailer, slot->undo_auth.tt_generation); + ClusterGcsUndoAuthTrailerSetAuthorityScn(trailer, + (uint64)slot->undo_auth.authority_scn); } hdr->checksum = cluster_gcs_block_compute_checksum(buf + header_len); diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index b2d1c08469..3fce82cd02 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -2411,6 +2411,17 @@ dump_visibility(ReturnSetInfo *rsinfo) fmt_int64((int64)cluster_vis_get_prune_remote_keep_count())); emit_row(rsinfo, "visibility", "vis_variant_unknown_failclosed_count", fmt_int64((int64)cluster_vis_get_vis_variant_unknown_failclosed_count())); + /* PGRAC: spec-7.1a D6 -- write-write chaining counters. */ + emit_row(rsinfo, "visibility", "writer_chain_resolved_count", + fmt_int64((int64)cluster_vis_get_writer_chain_resolved_count())); + emit_row(rsinfo, "visibility", "writer_chain_failclosed_count", + fmt_int64((int64)cluster_vis_get_writer_chain_failclosed_count())); + emit_row(rsinfo, "visibility", "xmax_resolved_count", + fmt_int64((int64)cluster_vis_get_xmax_resolved_count())); + emit_row(rsinfo, "visibility", "overlay_refresh_count", + fmt_int64((int64)cluster_vis_get_overlay_refresh_count())); + emit_row(rsinfo, "visibility", "covers_scn_refuse_count", + fmt_int64((int64)cluster_vis_get_covers_scn_refuse_count())); } diff --git a/src/backend/cluster/cluster_gcs_block.c b/src/backend/cluster/cluster_gcs_block.c index 0fc41b7d18..7f03b4b10d 100644 --- a/src/backend/cluster/cluster_gcs_block.c +++ b/src/backend/cluster/cluster_gcs_block.c @@ -121,6 +121,7 @@ typedef struct ClusterGcsBlockOutstandingSlot { * UNDO_TT_FETCH_RESULT reply (epoch / live_hwm ride the header). */ bool reply_undo_trailer_valid; uint64 reply_undo_tt_generation; + uint64 reply_undo_authority_scn; /* PGRAC: spec-7.1a D3 (trailer SCN) */ ConditionVariable reply_cv; /* PGRAC: spec-2.34 D3/D4 — HC100 stale-reply defense + epoch invalidation. * request_epoch: snapshot of cluster_epoch at the time the @@ -2511,6 +2512,7 @@ cluster_gcs_block_undo_tt_fetch_and_wait(int32 origin_node, uint32 segment_id, u cluster_sf_dep_vec_reset(&slot->reply_sf_dep_vec); slot->reply_undo_trailer_valid = false; slot->reply_undo_tt_generation = 0; + slot->reply_undo_authority_scn = 0; slot->request_epoch = cluster_epoch_get_current(); slot->expected_master_node = cluster_node_id; slot->stale = false; @@ -2570,6 +2572,7 @@ cluster_gcs_block_undo_tt_fetch_and_wait(int32 origin_node, uint32 segment_id, u auth_out->origin_epoch = slot->reply_header.epoch; auth_out->live_hwm_lsn = (XLogRecPtr)slot->reply_header.page_lsn; auth_out->tt_generation = slot->reply_undo_tt_generation; + auth_out->authority_scn = (SCN)slot->reply_undo_authority_scn; /* spec-5.14 D2: the authority is the origin's volatile * co-sample — depend on it for fail-stop (D-i3). */ gcs_block_stamp_touched(origin_node, GCS_BLOCK_REPLY_NO_FORWARDING_MASTER); @@ -2646,6 +2649,7 @@ cluster_gcs_block_undo_verdict_fetch_and_wait(int32 origin_node, uint32 segment_ cluster_sf_dep_vec_reset(&slot->reply_sf_dep_vec); slot->reply_undo_trailer_valid = false; slot->reply_undo_tt_generation = 0; + slot->reply_undo_authority_scn = 0; slot->request_epoch = cluster_epoch_get_current(); slot->expected_master_node = cluster_node_id; slot->stale = false; @@ -2711,6 +2715,7 @@ cluster_gcs_block_undo_verdict_fetch_and_wait(int32 origin_node, uint32 segment_ auth_out->origin_epoch = slot->reply_header.epoch; auth_out->live_hwm_lsn = (XLogRecPtr)slot->reply_header.page_lsn; auth_out->tt_generation = slot->reply_undo_tt_generation; + auth_out->authority_scn = (SCN)slot->reply_undo_authority_scn; /* spec-5.14 D2: the verdict is the origin's volatile * co-sample — depend on it for fail-stop (D-i3). */ gcs_block_stamp_touched(origin_node, GCS_BLOCK_REPLY_NO_FORWARDING_MASTER); @@ -5017,6 +5022,9 @@ cluster_gcs_handle_block_reply_envelope(const ClusterICEnvelope *env, const void slot->reply_undo_tt_generation = (undo_trailer != NULL) ? ClusterGcsUndoAuthTrailerGetTtGeneration(undo_trailer) : 0; + slot->reply_undo_authority_scn + = (undo_trailer != NULL) ? ClusterGcsUndoAuthTrailerGetAuthorityScn(undo_trailer) + : 0; slot->reply_received = true; ConditionVariableSignal(&slot->reply_cv); LWLockRelease(&blk->lock.lock); diff --git a/src/backend/cluster/cluster_guc.c b/src/backend/cluster/cluster_guc.c index 85e03bf6b8..0846fcbcdd 100644 --- a/src/backend/cluster/cluster_guc.c +++ b/src/backend/cluster/cluster_guc.c @@ -724,6 +724,11 @@ bool cluster_gcs_block_local_cache = true; * the spec-3.4d fail-closed (53R98) honest degradation. */ bool cluster_tx_enqueue_wait_enabled = true; bool cluster_ic_duty_lazy = true; /* spec-7.2 D1 duty-chain on-demand gating */ + +/* PGRAC: spec-7.1a D0 -- cross-node write-write chaining (default off). + * Off keeps the pre-7.1a floor: a TERMINAL remote writer holder fails + * closed (SQLSTATE 53R9H) instead of chaining to a sound TM_Result. */ +bool cluster_crossnode_write_write = false; int cluster_gcs_block_dedup_max_entries = 1024; /* @@ -3895,6 +3900,17 @@ cluster_init_guc(void) "iteration behavior (escape hatch). spec-7.2 D1. PGC_SIGHUP."), &cluster_ic_duty_lazy, true, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomBoolVariable( + "cluster.crossnode_write_write", + gettext_noop("Chain a local write past a terminal remote writer."), + gettext_noop("When on, a write that conflicts with a remote writer " + "that already committed or aborted maps the outcome onto " + "the native TM_Result contract (remote UPDATE -> chase the " + "new version via EvalPlanQual; remote DELETE -> deleted; " + "aborted -> proceed). Off (default) keeps the fail-closed " + "floor: SQLSTATE 53R9H, retry. Unprovable outcomes fail " + "closed either way. spec-7.1a D0. PGC_SUSET."), + &cluster_crossnode_write_write, false, PGC_SUSET, 0, NULL, NULL, NULL); DefineCustomIntVariable( "cluster.gcs_block_retransmit_initial_backoff_ms", gettext_noop("Initial backoff before retry 1 (subsequent retries double)."), diff --git a/src/backend/cluster/cluster_runtime_visibility.c b/src/backend/cluster/cluster_runtime_visibility.c index 289a063130..a95c43a417 100644 --- a/src/backend/cluster/cluster_runtime_visibility.c +++ b/src/backend/cluster/cluster_runtime_visibility.c @@ -61,9 +61,9 @@ * recycled resolution degrades to the unchanged 53R97 fail-closed. */ bool -cluster_vis_live_authority_covers(XLogRecPtr anchor_lsn, ClusterLiveAuthority auth) +cluster_vis_live_authority_covers(SCN demand_scn, ClusterLiveAuthority auth) { - return cluster_vis_live_authority_covers_policy(anchor_lsn, auth, cluster_epoch_get_current()); + return cluster_vis_live_authority_covers_policy(demand_scn, auth, cluster_epoch_get_current()); } @@ -203,7 +203,9 @@ cluster_undo_block_fetch_for_visibility(int origin_node, UBA uba, char *out_page * rtvis_try_origin_verdict — CP5 (D-i4 / spec-6.15 D4) verdict fallback leg. * * Reached when the single-block positive proof came back NONE (0-match / - * ambiguity): ask the ORIGIN for a complete own-TT by-xid verdict (the + * ambiguity) or COMMITTED (spec-7.1a hardening: a COMMITTED stamp is + * pre-commit evidence and only the origin can run the C1b CLOG cross- + * check on it): ask the ORIGIN for a complete own-TT by-xid verdict (the * spec-3.22 retention theorem served cross-instance; see the header and * cluster_cr_server.c lms_undo_verdict_serve for the origin-side legs). * @@ -219,8 +221,8 @@ cluster_undo_block_fetch_for_visibility(int origin_node, UBA uba, char *out_page */ static bool rtvis_try_origin_verdict(int origin_node, uint32 undo_segment_id, TransactionId raw_xid, - XLogRecPtr anchor_lsn, SCN read_scn, bool *out_committed, - SCN *out_commit_scn, bool *out_commit_scn_is_bound) + SCN demand_scn, SCN read_scn, bool *out_committed, SCN *out_commit_scn, + bool *out_commit_scn_is_bound) { ClusterGcsUndoVerdictPage verdict; ClusterLiveAuthority auth; @@ -236,8 +238,10 @@ rtvis_try_origin_verdict(int origin_node, uint32 undo_segment_id, TransactionId * gate can refuse — the observe is what makes a refusal self-heal. */ cluster_scn_observe((SCN)verdict.horizon_scn); cluster_scn_observe((SCN)verdict.commit_scn); + cluster_scn_observe(auth.authority_scn); - if (!cluster_vis_live_authority_covers(anchor_lsn, auth)) { + if (!cluster_vis_live_authority_covers(demand_scn, auth)) { + cluster_vis_bump_covers_scn_refuse_count(); /* spec-7.1a D6 */ cluster_rtvis_verdict_note_failclosed(); return false; } @@ -297,8 +301,11 @@ rtvis_try_origin_verdict(int origin_node, uint32 undo_segment_id, TransactionId * Active-runtime terminal resolution of a RECYCLED remote ITL ref: * fetch (D-i1, block 0 of the ref's segment) -> covers gate (D-i2, * co-sampled authority vs this tuple's page LSN) -> positive proof - * (exact xid+wrap slot match on the SHIPPED bytes) -> on a proof NONE, - * the CP5 origin-verdict fallback (complete scan at the origin). + * (exact xid+wrap slot match on the SHIPPED bytes) -> on a proof NONE + * or a proof COMMITTED (evidence only -- the stamp lands at pre-commit + * and needs the origin's C1b CLOG cross-check, see the fast-leg banner + * below), the CP5 origin-verdict leg (complete scan at the origin); + * only a proof ABORTED short-circuits. * * The proof scans the very bytes the authority was co-sampled with (also * on a cache hit — the pool/memo only ever serve the pair as installed), @@ -312,14 +319,13 @@ rtvis_try_origin_verdict(int origin_node, uint32 undo_segment_id, TransactionId */ bool cluster_runtime_visibility_try_resolve_remote(int origin_node, uint32 undo_segment_id, - TransactionId raw_xid, XLogRecPtr anchor_lsn, - SCN read_scn, bool *out_committed, - SCN *out_commit_scn, bool *out_commit_scn_is_bound) + TransactionId raw_xid, SCN read_scn, + bool *out_committed, SCN *out_commit_scn, + bool *out_commit_scn_is_bound) { PGAlignedBlock page; ClusterLiveAuthority auth; - SCN scn = InvalidScn; - uint16 wrap = TT_WRAP_INVALID; + SCN demand_scn; if (out_committed != NULL) *out_committed = false; @@ -342,30 +348,59 @@ cluster_runtime_visibility_try_resolve_remote(int origin_node, uint32 undo_segme if (undo_segment_id == 0 || undo_segment_id > UINT16_MAX) return false; + /* + * PGRAC: spec-7.1a D3 -- the covers demand. Snapshot legs demand + * conclusiveness for their read_scn; no-snapshot legs (writer terminal + * resolution, read_scn = InvalidScn) demand the local clock AS OF NOW. + * Sampled BEFORE any fetch: the reply's SCNs are Lamport-observed below, + * so sampling after would trivially satisfy the gate and void it. + */ + demand_scn = SCN_VALID(read_scn) ? read_scn : cluster_scn_current(); + /* * Single-block fast leg (CP3), entirely opportunistic since CP5: the * ref's segment id names the CURRENT slot owner's segment, which under * the spec-6.15 D4 xid-derived origin may not even exist on the node we * are asking — a fetch miss there is a routing artifact, not evidence. - * A successful exact xid+wrap proof still short-circuits the wire; a - * fetch miss or a proof NONE falls to the origin-verdict leg. + * Only an exact ABORTED proof still short-circuits the wire (an abort + * stamp is terminal and irreversible); a COMMITTED proof is EVIDENCE + * that must be finalized by the origin's C1b CLOG cross-check on the + * verdict leg, and a fetch miss or a proof NONE prove nothing — all + * three fall to the origin-verdict leg. */ if (cluster_undo_block_fetch_for_visibility(origin_node, uba_encode(undo_segment_id, 0, 0, 0), - page.data, &auth) - && cluster_vis_live_authority_covers(anchor_lsn, auth)) { - switch (cluster_vis_tt_block_positive_proof( - page.data, undo_segment_id, (uint8)(origin_node + 1), raw_xid, &scn, &wrap)) { - case CLUSTER_VIS_TT_PROOF_COMMITTED: - *out_committed = true; - *out_commit_scn = scn; - cluster_rtvis_resolve_note_committed(); - return true; - case CLUSTER_VIS_TT_PROOF_ABORTED: - cluster_rtvis_resolve_note_aborted(); - return true; - case CLUSTER_VIS_TT_PROOF_NONE: - default: - break; /* 0-match / ambiguity: fall to the verdict leg */ + page.data, &auth)) { + /* Lamport-observe the co-sampled clock BEFORE the gate can refuse + * (AD-008) -- the observe is what makes a refusal self-heal. */ + cluster_scn_observe(auth.authority_scn); + if (!cluster_vis_live_authority_covers(demand_scn, auth)) + cluster_vis_bump_covers_scn_refuse_count(); /* spec-7.1a D6 */ + else { + switch (cluster_vis_tt_block_positive_proof( + page.data, undo_segment_id, (uint8)(origin_node + 1), raw_xid, NULL, NULL)) { + case CLUSTER_VIS_TT_PROOF_COMMITTED: + + /* + * PGRAC: spec-7.1a hardening (C1b) -- a shipped COMMITTED + * stamp is evidence, NOT a verdict. The durable slot is + * stamped at pre-commit (2PC COMMIT PREPARED stamps before + * the commit record -- cluster_tt_durable.h), so an owner + * crash in that window leaves a COMMITTED stamp on an + * in-doubt xid; concluding committed here is a + * false-committed (Rule 8.A). Only the origin can run the + * C1b CLOG cross-check (the foreign xid has no meaning in + * the LOCAL clog -- AD-012 例外 9), and it does so on the + * verdict leg (cluster_cr_server.c lms_undo_verdict_serve + * refuses !TransactionIdDidCommit): fall through to it. + */ + break; + case CLUSTER_VIS_TT_PROOF_ABORTED: + cluster_rtvis_resolve_note_aborted(); + return true; + case CLUSTER_VIS_TT_PROOF_NONE: + default: + break; /* 0-match / ambiguity: fall to the verdict leg */ + } } } @@ -375,7 +410,7 @@ cluster_runtime_visibility_try_resolve_remote(int origin_node, uint32 undo_segme * fetch/covers miss proves nothing either — ask the origin for the * complete own-TT verdict instead of failing closed outright. */ - if (rtvis_try_origin_verdict(origin_node, undo_segment_id, raw_xid, anchor_lsn, read_scn, + if (rtvis_try_origin_verdict(origin_node, undo_segment_id, raw_xid, demand_scn, read_scn, out_committed, out_commit_scn, out_commit_scn_is_bound)) { if (*out_committed) cluster_rtvis_resolve_note_committed(); diff --git a/src/backend/cluster/cluster_runtime_visibility_policy.c b/src/backend/cluster/cluster_runtime_visibility_policy.c index 711b5068cd..90013dfb13 100644 --- a/src/backend/cluster/cluster_runtime_visibility_policy.c +++ b/src/backend/cluster/cluster_runtime_visibility_policy.c @@ -31,6 +31,7 @@ #include "cluster/cluster_gcs_block.h" /* ClusterGcsUndoVerdictPage (CP5) */ #include "cluster/cluster_runtime_visibility.h" +#include "cluster/cluster_scn.h" /* scn_time_cmp / SCN_VALID (spec-7.1a D3) */ #include "cluster/cluster_undo_segment.h" /* UndoSegmentHeaderData, TTSlot */ /* @@ -42,7 +43,7 @@ * widens "resolve when provable", never widens "resolve when unprovable"). */ bool -cluster_vis_live_authority_covers_policy(XLogRecPtr anchor_lsn, ClusterLiveAuthority auth, +cluster_vis_live_authority_covers_policy(SCN demand_scn, ClusterLiveAuthority auth, uint64 local_epoch) { /* @@ -56,20 +57,32 @@ cluster_vis_live_authority_covers_policy(XLogRecPtr anchor_lsn, ClusterLiveAutho /* * (2) Authority actually present. An undo-block reply that did not carry * a live authority (older peer / off path) leaves live_hwm_lsn invalid -> - * fail closed, never guess. + * fail closed, never guess. The SCN co-sample must be present too: an + * older peer ships zero trailer bytes = InvalidScn -> refuse. */ if (XLogRecPtrIsInvalid(auth.live_hwm_lsn)) return false; + if (!SCN_VALID(auth.authority_scn)) + return false; /* - * (3) Durable coverage of THIS page version. live_hwm_lsn is the origin's - * durable-and-TT-applied high-water; only if it is at or beyond the - * tuple's page LSN has the origin's durable TT reconciled this version. - * Semantically equivalent to the recovery-side recovered_through >= - * anchor_lsn gate, but sourced from a live durable watermark rather than a - * merge-complete marker (spec-6.12 §2.11 torn-history equivalence). + * (3) SCN-total-order conclusiveness (spec-7.1a D3, replacing the former + * `live_hwm_lsn < anchor_lsn` compare that was unsound across per-thread + * WAL streams: a page last written by another thread carries an LSN from + * a different stream, so the raw compare false-refused live resolutions + * and could false-pass -- both measured/analyzed in gaps §C.2). The + * caller demands conclusiveness for demand_scn (its snapshot read_scn, + * or its own clock sampled BEFORE the fetch): the origin allocates its + * commit SCNs from the clock this authority co-sampled and durably + * stamps the TT BEFORE the commit record, so authority_scn at/after the + * demand proves every commit the demand could have observed is already + * in the shipped content (AD-008 Lamport total order across threads and + * nodes). A refusal self-heals: the shipped SCNs are Lamport-observed + * by the consumer, so the next demand is at/after this authority. */ - if (auth.live_hwm_lsn < anchor_lsn) + if (!SCN_VALID(demand_scn)) + return false; + if (scn_time_cmp(auth.authority_scn, demand_scn) < 0) return false; return true; @@ -83,6 +96,13 @@ cluster_vis_live_authority_covers_policy(XLogRecPtr anchor_lsn, ClusterLiveAutho * / multi-match / non-terminal / malformed all refuse). The block bytes are * the origin's own shipped copy, so every refusal is a clean NONE — never an * elog — and the caller keeps the 53R97 fail-closed boundary. + * + * PROOF_COMMITTED is EVIDENCE, not a verdict (spec-7.1a hardening): durable + * COMMITTED stamps land at pre-commit (2PC COMMIT PREPARED stamps before the + * commit record — cluster_tt_durable.h), so a stamped-then-crashed xid is + * in-doubt. Consumers must finalize a COMMITTED proof through the origin's + * C1b CLOG cross-check (the verdict leg); only PROOF_ABORTED may be consumed + * directly (an abort stamp is terminal and irreversible). */ ClusterVisTtProof cluster_vis_tt_block_positive_proof(const char *block, uint32 expected_segment_id, diff --git a/src/backend/cluster/cluster_tt_status.c b/src/backend/cluster/cluster_tt_status.c index db45272ef6..8e32d235ef 100644 --- a/src/backend/cluster/cluster_tt_status.c +++ b/src/backend/cluster/cluster_tt_status.c @@ -123,6 +123,13 @@ typedef struct ClusterTTStatusShmem { pg_atomic_uint64 prune_remote_keep_count; pg_atomic_uint64 vis_variant_unknown_failclosed_count; + /* spec-7.1a D6: cross-node write-write chaining observability. */ + pg_atomic_uint64 writer_chain_resolved_count; + pg_atomic_uint64 writer_chain_failclosed_count; + pg_atomic_uint64 xmax_resolved_count; + pg_atomic_uint64 overlay_refresh_count; + pg_atomic_uint64 covers_scn_refuse_count; + /* spec-3.15 D9: two-phase commit observability. */ pg_atomic_uint64 twopc_prepare_records; pg_atomic_uint64 twopc_prepare_undo_flushes; @@ -203,6 +210,12 @@ cluster_tt_status_shmem_init(void) pg_atomic_init_u64(&ClusterTTStatusState->vis_conflict_failclosed_count, 0); pg_atomic_init_u64(&ClusterTTStatusState->prune_remote_keep_count, 0); pg_atomic_init_u64(&ClusterTTStatusState->vis_variant_unknown_failclosed_count, 0); + /* PGRAC (spec-7.1a D6) */ + pg_atomic_init_u64(&ClusterTTStatusState->writer_chain_resolved_count, 0); + pg_atomic_init_u64(&ClusterTTStatusState->writer_chain_failclosed_count, 0); + pg_atomic_init_u64(&ClusterTTStatusState->xmax_resolved_count, 0); + pg_atomic_init_u64(&ClusterTTStatusState->overlay_refresh_count, 0); + pg_atomic_init_u64(&ClusterTTStatusState->covers_scn_refuse_count, 0); /* PGRAC (spec-3.15 D9) */ pg_atomic_init_u64(&ClusterTTStatusState->twopc_prepare_records, 0); pg_atomic_init_u64(&ClusterTTStatusState->twopc_prepare_undo_flushes, 0); @@ -1000,6 +1013,13 @@ CLUSTER_VIS_BUMP(vis_conflict_failclosed_count) CLUSTER_VIS_BUMP(prune_remote_keep_count) CLUSTER_VIS_BUMP(vis_variant_unknown_failclosed_count) +/* spec-7.1a D6: write-write chaining counters (same bump/read shape). */ +CLUSTER_VIS_BUMP(writer_chain_resolved_count) +CLUSTER_VIS_BUMP(writer_chain_failclosed_count) +CLUSTER_VIS_BUMP(xmax_resolved_count) +CLUSTER_VIS_BUMP(overlay_refresh_count) +CLUSTER_VIS_BUMP(covers_scn_refuse_count) + /* spec-3.15 D9: 2PC counters (same single-writer bump/read shape). */ CLUSTER_VIS_BUMP(twopc_prepare_records) CLUSTER_VIS_BUMP(twopc_prepare_undo_flushes) diff --git a/src/backend/cluster/cluster_visibility_resolve.c b/src/backend/cluster/cluster_visibility_resolve.c index 7c1db01671..762707715f 100644 --- a/src/backend/cluster/cluster_visibility_resolve.c +++ b/src/backend/cluster/cluster_visibility_resolve.c @@ -121,8 +121,58 @@ cluster_vis_resolve_abort_reset(void) * caller fail-closes; the evidence stays REMOTE so there is no PG-native * fallback, C-V2). */ +/* + * resolve_live_overlay_miss_via_origin -- spec-7.1a D4 (live overlay pull). + * + * A LIVE remote ITL ref (slot still bound to raw_xid) whose overlay lookup + * missed used to stay UNKNOWN forever when the origin's tt_status_hint + * propagation was lost (HC181: fail-closed but never self-healing; gaps + * §C.3). Pull the origin's complete own-TT verdict on demand instead -- + * the same shipped live-IC verdict machinery the recycled-slot path uses + * (no shared-undo data plane involved; the origin answers only for its own + * xids and only with terminal outcomes). A genuinely in-progress holder + * still resolves UNKNOWN here (no verdict is served) and the caller keeps + * the fail-closed retry, which converges once the holder terminates. + * + * Fills *out and installs the exact-key memo only for exact (non-bound) + * terminal outcomes; a below-horizon bound is snapshot-relative and is + * returned with commit_scn_is_bound so the consumer never stamps it. + */ +static void +resolve_live_overlay_miss_via_origin(TransactionId raw_xid, const ClusterUndoTTSlotRef *ref, + SCN read_scn, const ClusterTTStatusKey *key, + ClusterVisResolve *out) +{ + bool committed = false; + SCN scn = InvalidScn; + bool is_bound = false; + + if (!cluster_crossnode_write_write) + return; /* keep the pre-7.1a UNKNOWN floor byte-identical */ + if ((int32)ref->origin_node_id == cluster_node_id) + return; + + if (!cluster_runtime_visibility_try_resolve_remote((int)ref->origin_node_id, + (uint32)ref->undo_segment_id, raw_xid, + read_scn, &committed, &scn, &is_bound)) + return; /* stay UNKNOWN -> caller 53R97 fail-closed */ + + cluster_vis_bump_overlay_refresh_count(); /* spec-7.1a D6 */ + if (committed) { + out->status = CLUSTER_TT_STATUS_COMMITTED; + out->commit_scn = scn; + out->commit_scn_is_bound = is_bound; + if (!is_bound) + cluster_vis_memo_install(key, (uint8)CLUSTER_TT_STATUS_COMMITTED, scn); + } else { + out->status = CLUSTER_TT_STATUS_ABORTED; + out->commit_scn = InvalidScn; + cluster_vis_memo_install(key, (uint8)CLUSTER_TT_STATUS_ABORTED, InvalidScn); + } +} + static void -resolve_from_remote_ref(TransactionId raw_xid, const ClusterUndoTTSlotRef *ref, +resolve_from_remote_ref(TransactionId raw_xid, const ClusterUndoTTSlotRef *ref, SCN read_scn, ClusterVisResolve *out) { ClusterTTStatusKey key; @@ -191,6 +241,9 @@ resolve_from_remote_ref(TransactionId raw_xid, const ClusterUndoTTSlotRef *ref, if (!cluster_tt_status_lookup_exact(&key, &result) || !result.authoritative) { /* PGRAC: spec-6.12c D0 -- lookup performed; no terminal verdict. */ cluster_lever_c_note_tt_lookup(ref->has_cached_status, false); + /* PGRAC: spec-7.1a D4 -- overlay miss on a LIVE remote ref: pull the + * origin verdict instead of staying UNKNOWN forever (HC181). */ + resolve_live_overlay_miss_via_origin(raw_xid, ref, read_scn, &key, out); cluster_xp_end(&xp_scope); /* PGRAC: spec-5.59 D3 profiling */ return; /* UNKNOWN -> caller 53R97 (C-V2: no PG-native fallback) */ } @@ -207,6 +260,8 @@ resolve_from_remote_ref(TransactionId raw_xid, const ClusterUndoTTSlotRef *ref, out->status = CLUSTER_TT_STATUS_UNKNOWN; /* PGRAC: spec-6.12c D0 -- lookup performed; no terminal verdict. */ cluster_lever_c_note_tt_lookup(ref->has_cached_status, false); + /* PGRAC: spec-7.1a D4 -- subtrans-chain miss: same origin pull. */ + resolve_live_overlay_miss_via_origin(raw_xid, ref, read_scn, &key, out); cluster_xp_end(&xp_scope); /* PGRAC: spec-5.59 D3 profiling */ return; } @@ -445,8 +500,8 @@ classify_ref_guts(TransactionId raw_xid, const ClusterUndoTTSlotRef *ref, XLogRe bool is_bound = false; if (cluster_runtime_visibility_try_resolve_remote( - derived_origin, (uint32)ref->undo_segment_id, raw_xid, anchor_lsn, read_scn, - &committed, &scn, &is_bound)) { + derived_origin, (uint32)ref->undo_segment_id, raw_xid, read_scn, &committed, + &scn, &is_bound)) { out->evidence = CLUSTER_VIS_EVIDENCE_REMOTE; out->status = committed ? CLUSTER_TT_STATUS_COMMITTED : CLUSTER_TT_STATUS_ABORTED; @@ -464,7 +519,7 @@ classify_ref_guts(TransactionId raw_xid, const ClusterUndoTTSlotRef *ref, XLogRe return; } - resolve_from_remote_ref(raw_xid, ref, out); + resolve_from_remote_ref(raw_xid, ref, read_scn, out); } /* diff --git a/src/backend/cluster/cluster_writer_chain.c b/src/backend/cluster/cluster_writer_chain.c new file mode 100644 index 0000000000..a6b1812e96 --- /dev/null +++ b/src/backend/cluster/cluster_writer_chain.c @@ -0,0 +1,114 @@ +/*------------------------------------------------------------------------- + * + * cluster_writer_chain.c + * Cross-node terminal-writer chaining: pure outcome -> TM_Result mapping. + * + * Split out as a pure policy file (mirroring cluster_visibility_verdict.c) + * so the spec-7.1a D0 truth table carries ZERO PG-backend dependency and + * is unit-enumerable. The heapam.c writer-wait bridge is the only + * production caller; it resolves the remote writer's terminal outcome + * from cluster TT / live-IC verdict authority and then maps it here. + * + * Fail-closed discipline: this function can only ever LOOSEN nothing. + * Any outcome that is not affirmatively provable maps to "not mappable" + * (false), and the caller raises the pre-existing 53R9H floor. It never + * fabricates failure data: on the false path res/tmfd are left unwritten. + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/backend/cluster/cluster_writer_chain.c + * + * NOTES + * This is a pgrac-original file. Compiled only in --enable-cluster + * builds. + * Spec: spec-7.1a-cross-instance-write-write-mvcc-coordination.md. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#ifdef USE_PGRAC_CLUSTER + +#include "access/transam.h" +#include "cluster/cluster_writer_chain.h" + +/* + * cluster_writer_chain_decide -- map a resolved terminal-writer outcome to + * the TM_Result the native caller contract expects. + * + * Inputs: + * wo: the authoritatively-resolved outcome (never trusted beyond + * its own consistency: UPDATED without a probed next version + * is NOT mappable) + * old_t_ctid: the old version's on-page t_ctid, read under the reacquired + * buffer content lock (self tid for DELETE) + * update_xid: the raw update xid of the old version (== the xwait the + * caller waited on; the multixact branch fails closed earlier) + * res: out: TM_Ok / TM_Updated / TM_Deleted + * tmfd: out, optional: failure data filled per the native contract + * (heap_delete / heap_update / heap_lock_tuple fill sites): + * ctid = chase target, xmax = update_xid, + * cmax = InvalidCommandId (native value for any failure that + * is not TM_SelfModified; a remote writer can never be + * self-modified). NULL when the caller reuses the native + * fill block. Never written for TM_Ok. + * + * Returns: + * true -- outcome mapped; res (and tmfd when given and not TM_Ok) valid + * false -- outcome not soundly mappable; caller must fail closed (53R9H) + * and must not consume res/tmfd (left unwritten) + */ +bool +cluster_writer_chain_decide(const ClusterWriterOutcome *wo, const ItemPointerData *old_t_ctid, + TransactionId update_xid, TM_Result *res, TM_FailureData *tmfd) +{ + switch (wo->kind) { + case CWO_ABORTED: + /* Row unchanged; caller rechecks and proceeds. No failure data. */ + *res = TM_Ok; + return true; + + case CWO_DELETED: + if (!TransactionIdIsValid(update_xid)) + return false; + *res = TM_Deleted; + if (tmfd != NULL) { + tmfd->ctid = *old_t_ctid; + tmfd->xmax = update_xid; + tmfd->cmax = InvalidCommandId; + } + return true; + + case CWO_UPDATED: + /* + * TM_Updated is only sound when the single-hop next version was + * actually probed (spec-7.1a Q9): a chase target the local node + * cannot reach or judge must fail closed, never be surfaced to + * EvalPlanQual. + */ + if (!TransactionIdIsValid(update_xid) || wo->new_tuple == NULL + || !ItemPointerIsValid(&wo->new_ctid)) + return false; + *res = TM_Updated; + if (tmfd != NULL) { + tmfd->ctid = wo->new_ctid; + tmfd->xmax = update_xid; + tmfd->cmax = InvalidCommandId; + } + return true; + + case CWO_UNRESOLVABLE: + return false; + } + + /* Unreachable; keep the compiler's enum-coverage check honest. */ + return false; +} + +#endif /* USE_PGRAC_CLUSTER */ diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h index edf8ec69b1..c84ca5ca65 100644 --- a/src/include/access/heapam.h +++ b/src/include/access/heapam.h @@ -321,6 +321,13 @@ extern HTSV_Result HeapTupleSatisfiesVacuumHorizon(HeapTuple htup, Buffer buffer TransactionId *dead_after); extern void HeapTupleSetHintBits(HeapTupleHeader tuple, Buffer buffer, uint16 infomask, TransactionId xid); +#ifdef USE_PGRAC_CLUSTER +/* PGRAC: spec-7.1a D0/D2 -- authority-backed clear of a RELEASED/ABORTED + * remote xmax (bypasses the spec-6.15 D7 foreign-class hint suppression; + * only ever called on a cluster-authority terminal verdict). */ +extern void cluster_heap_stamp_released_xmax_invalid(HeapTupleHeader tuple, + Buffer buffer); +#endif extern bool HeapTupleHeaderIsOnlyLocked(HeapTupleHeader tuple); extern bool HeapTupleIsSurelyDead(HeapTuple htup, struct GlobalVisState *vistest); diff --git a/src/include/cluster/cluster_gcs_block.h b/src/include/cluster/cluster_gcs_block.h index 2f8417a522..e617eb6480 100644 --- a/src/include/cluster/cluster_gcs_block.h +++ b/src/include/cluster/cluster_gcs_block.h @@ -1318,7 +1318,10 @@ GcsBlockUndoFetchTagDecode(BufferTag tag, uint32 *segment_id, uint32 *block_no) * expected_pi_watermark_scn_bytes. */ typedef struct ClusterGcsUndoAuthTrailer { uint8 tt_generation_bytes[8]; /* origin TT retention-rollover generation */ - uint8 reserved_0[8]; /* must be zero */ + uint8 authority_scn_bytes[8]; /* PGRAC: spec-7.1a D3 -- origin SCN clock + * co-sampled with the content (LE); zero + * (InvalidScn) = absent (older peer) -> + * the covers gate refuses fail-closed */ } ClusterGcsUndoAuthTrailer; StaticAssertDecl(sizeof(ClusterGcsUndoAuthTrailer) == 16, @@ -1349,6 +1352,37 @@ ClusterGcsUndoAuthTrailerGetTtGeneration(const ClusterGcsUndoAuthTrailer *t) | (((uint64)t->tt_generation_bytes[7]) << 56); } +/* PGRAC: spec-7.1a D3 -- same little-endian carrier for the co-sampled + * origin SCN clock (rides the former must-be-zero trailer bytes, so the + * wire size is unchanged and an older peer's zero reads as InvalidScn). */ +static inline void +ClusterGcsUndoAuthTrailerSetAuthorityScn(ClusterGcsUndoAuthTrailer *t, uint64 v) +{ + t->authority_scn_bytes[0] = (uint8)(v & 0xff); + t->authority_scn_bytes[1] = (uint8)((v >> 8) & 0xff); + t->authority_scn_bytes[2] = (uint8)((v >> 16) & 0xff); + t->authority_scn_bytes[3] = (uint8)((v >> 24) & 0xff); + t->authority_scn_bytes[4] = (uint8)((v >> 32) & 0xff); + t->authority_scn_bytes[5] = (uint8)((v >> 40) & 0xff); + t->authority_scn_bytes[6] = (uint8)((v >> 48) & 0xff); + t->authority_scn_bytes[7] = (uint8)((v >> 56) & 0xff); +} + +static inline uint64 +ClusterGcsUndoAuthTrailerGetAuthorityScn(const ClusterGcsUndoAuthTrailer *t) +{ + uint64 v = (uint64)t->authority_scn_bytes[0]; + + v |= (uint64)t->authority_scn_bytes[1] << 8; + v |= (uint64)t->authority_scn_bytes[2] << 16; + v |= (uint64)t->authority_scn_bytes[3] << 24; + v |= (uint64)t->authority_scn_bytes[4] << 32; + v |= (uint64)t->authority_scn_bytes[5] << 40; + v |= (uint64)t->authority_scn_bytes[6] << 48; + v |= (uint64)t->authority_scn_bytes[7] << 56; + return v; +} + /* PGRAC: spec-6.12i D-i4 / spec-6.15 D4 — verdict page carried in the BLCKSZ * area of an UNDO_VERDICT_RESULT reply (rest of the page is zero; the reply * checksum covers the whole BLCKSZ area exactly like every other reply). diff --git a/src/include/cluster/cluster_guc.h b/src/include/cluster/cluster_guc.h index c7b6ce082f..9cc9c68984 100644 --- a/src/include/cluster/cluster_guc.h +++ b/src/include/cluster/cluster_guc.h @@ -937,6 +937,7 @@ extern bool cluster_tx_enqueue_wait_enabled; /* spec-5.2 D4 */ * correctness families are unaffected. Off = pre-7.2 behavior. */ extern bool cluster_ic_duty_lazy; +extern bool cluster_crossnode_write_write; /* spec-7.1a D0 */ extern int cluster_gcs_block_dedup_max_entries; /* PGRAC: spec-4.7 D1 — bounded wait (ms) on a RECOVERING block resource diff --git a/src/include/cluster/cluster_runtime_visibility.h b/src/include/cluster/cluster_runtime_visibility.h index 15c325be5e..a10c13c04d 100644 --- a/src/include/cluster/cluster_runtime_visibility.h +++ b/src/include/cluster/cluster_runtime_visibility.h @@ -52,27 +52,45 @@ typedef struct ClusterLiveAuthority { uint64 origin_epoch; /* origin's view of the membership epoch */ XLogRecPtr live_hwm_lsn; /* origin durable AND TT-applied high-water */ uint64 tt_generation; /* origin TT-slot generation (anti-alias) */ + SCN authority_scn; /* PGRAC: spec-7.1a D3 -- origin SCN clock + * co-sampled with the content; the covers + * gate admits only when it is at/after the + * caller's demand (read_scn or its clock at + * request time). InvalidScn = absent -> + * refuse fail-closed */ } ClusterLiveAuthority; /* - * PURE gate (D-i2 window check). Returns true iff the co-sampled authority - * provably covers `anchor_lsn` (the tuple's page LSN) in the caller's current - * reconfig generation. Fail-closed on any doubt: + * PURE gate (D-i2 window check; spec-7.1a D3 SCN total order). Returns true + * iff the co-sampled authority provably covers the caller's demand in the + * caller's current reconfig generation. demand_scn is the SCN the answer + * must be conclusive for: the snapshot read_scn on MVCC legs, or the + * caller's own clock sampled BEFORE the fetch on no-snapshot legs (writer + * terminal resolution) -- by AD-008 Lamport, an origin whose co-sampled + * clock is at/after the demand has already issued (and pre-commit durably + * stamped) every commit the demand could have observed. The former + * `live_hwm_lsn < anchor_lsn` compare was NOT sound across per-thread WAL + * streams (a page last written by another thread carries a numerically + * incomparable LSN: false-refuse measured, false-pass latent) and is + * replaced, never weakened. Fail-closed on any doubt: * - origin_epoch != local_epoch -> authority from a different reconfig gen * - live_hwm_lsn invalid -> no authority sampled - * - live_hwm_lsn < anchor_lsn -> origin durable TT does not yet cover - * this page version + * - authority_scn invalid -> older peer / no SCN co-sample + * - demand_scn invalid -> caller supplied no demand + * - authority_scn before demand -> origin clock not provably conclusive + * for this demand yet (retry self-heals: + * the shipped SCNs are Lamport-observed) * tt_generation is NOT checked here; it is consumed by the downstream by-xid * wrap-qualified resolution (D-i2 condition (a)/(c)). */ -extern bool cluster_vis_live_authority_covers_policy(XLogRecPtr anchor_lsn, - ClusterLiveAuthority auth, uint64 local_epoch); +extern bool cluster_vis_live_authority_covers_policy(SCN demand_scn, ClusterLiveAuthority auth, + uint64 local_epoch); /* * Runtime wrapper: supplies the local membership epoch to the pure gate. * (cluster_runtime_visibility.c; the pure policy above is CP1.) */ -extern bool cluster_vis_live_authority_covers(XLogRecPtr anchor_lsn, ClusterLiveAuthority auth); +extern bool cluster_vis_live_authority_covers(SCN demand_scn, ClusterLiveAuthority auth); /* * D-i1 fetch (spec-6.12i CP2): fetch the TT-bearing undo header block named @@ -123,9 +141,12 @@ extern bool cluster_undo_block_fetch_for_visibility(int origin_node, UBA uba, ch * depth. Pure; no I/O, no shmem, no elog (unit truth table). */ typedef enum ClusterVisTtProof { - CLUSTER_VIS_TT_PROOF_NONE = 0, /* fail-closed: caller keeps 53R97 */ - CLUSTER_VIS_TT_PROOF_COMMITTED = 1, - CLUSTER_VIS_TT_PROOF_ABORTED = 2 + CLUSTER_VIS_TT_PROOF_NONE = 0, /* fail-closed: caller keeps 53R97 */ + CLUSTER_VIS_TT_PROOF_COMMITTED = 1, /* EVIDENCE only: stamps land at 2PC + * pre-commit; consumers finalize via + * the origin's C1b verdict leg, + * never conclude committed here */ + CLUSTER_VIS_TT_PROOF_ABORTED = 2 /* terminal: an abort is irreversible */ } ClusterVisTtProof; extern ClusterVisTtProof cluster_vis_tt_block_positive_proof(const char *block, @@ -177,8 +198,7 @@ extern bool cluster_vis_undo_verdict_page_usable(const struct ClusterGcsUndoVerd * pre-existing STALE_OR_AMBIGUOUS -> 53R97 fail-closed. */ extern bool cluster_runtime_visibility_try_resolve_remote(int origin_node, uint32 undo_segment_id, - TransactionId raw_xid, - XLogRecPtr anchor_lsn, SCN read_scn, + TransactionId raw_xid, SCN read_scn, bool *out_committed, SCN *out_commit_scn, bool *out_commit_scn_is_bound); diff --git a/src/include/cluster/cluster_tt_status.h b/src/include/cluster/cluster_tt_status.h index 8f7648d7bd..3be07aa34f 100644 --- a/src/include/cluster/cluster_tt_status.h +++ b/src/include/cluster/cluster_tt_status.h @@ -290,6 +290,18 @@ extern uint64 cluster_vis_get_prune_remote_keep_count(void); extern void cluster_vis_bump_vis_variant_unknown_failclosed_count(void); extern uint64 cluster_vis_get_vis_variant_unknown_failclosed_count(void); +/* PGRAC: spec-7.1a D6 -- cross-node write-write chaining counters. */ +extern void cluster_vis_bump_writer_chain_resolved_count(void); +extern uint64 cluster_vis_get_writer_chain_resolved_count(void); +extern void cluster_vis_bump_writer_chain_failclosed_count(void); +extern uint64 cluster_vis_get_writer_chain_failclosed_count(void); +extern void cluster_vis_bump_xmax_resolved_count(void); +extern uint64 cluster_vis_get_xmax_resolved_count(void); +extern void cluster_vis_bump_overlay_refresh_count(void); +extern uint64 cluster_vis_get_overlay_refresh_count(void); +extern void cluster_vis_bump_covers_scn_refuse_count(void); +extern uint64 cluster_vis_get_covers_scn_refuse_count(void); + /* spec-3.15 D9: 2PC counters. */ extern void cluster_vis_bump_twopc_prepare_records(void); extern uint64 cluster_vis_get_twopc_prepare_records(void); diff --git a/src/include/cluster/cluster_writer_chain.h b/src/include/cluster/cluster_writer_chain.h new file mode 100644 index 0000000000..87e59d496d --- /dev/null +++ b/src/include/cluster/cluster_writer_chain.h @@ -0,0 +1,81 @@ +/*------------------------------------------------------------------------- + * + * cluster_writer_chain.h + * Public interface for cross-node terminal-writer chaining (write-write). + * + * When a local DML (heap_update / heap_delete / heap_lock_tuple) runs + * into a REMOTE writer holder that is already terminal, the pre-7.1a + * bridge unconditionally failed closed (53R9H). This module maps the + * authoritatively-resolved terminal outcome onto the sound TM_Result + * the native caller contract expects: + * + * remote UPDATE committed -> TM_Updated (chain to the next version) + * remote DELETE committed -> TM_Deleted + * remote writer aborted -> TM_Ok (row unchanged; recheck applies) + * outcome not provable -> not mappable; caller MUST fail closed + * + * The decide function is pure (no buffer, no page, no backend state) + * so the full truth table is unit-enumerable. + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/include/cluster/cluster_writer_chain.h + * + * NOTES + * This is a pgrac-original file. + * Spec: spec-7.1a-cross-instance-write-write-mvcc-coordination.md. + * + *------------------------------------------------------------------------- + */ +#ifndef CLUSTER_WRITER_CHAIN_H +#define CLUSTER_WRITER_CHAIN_H + +#include "access/htup.h" +#include "access/tableam.h" +#include "storage/itemptr.h" + +/* + * Authoritatively-resolved terminal outcome of a remote writer holder. + * The resolver (heapam.c bridge) only produces CWO_UPDATED / CWO_DELETED + * when the outcome is proven from cluster TT / live-IC verdict authority; + * anything unproven is CWO_UNRESOLVABLE (fail-closed, never guessed). + */ +typedef enum ClusterWriterOutcomeKind { + CWO_UPDATED = 0, /* remote UPDATE committed: next version exists */ + CWO_DELETED, /* remote DELETE committed: row is gone */ + CWO_ABORTED, /* remote writer aborted: row unchanged */ + CWO_UNRESOLVABLE /* not provable: caller must fail closed (53R9H) */ +} ClusterWriterOutcomeKind; + +typedef struct ClusterWriterOutcome { + ClusterWriterOutcomeKind kind; + ItemPointerData new_ctid; /* CWO_UPDATED: single-hop next-version tid */ + HeapTuple new_tuple; /* CWO_UPDATED: probe descriptor of the next + * version (t_self / t_tableOid set; t_data + * NULL -- EvalPlanQual re-fetches the + * version itself); NULL = not probed */ +} ClusterWriterOutcome; + +/* + * Pure mapping from a resolved outcome to the caller-visible TM_Result and + * TM_FailureData, mirroring the native failure-exit contract exactly + * (heap_delete / heap_update / heap_lock_tuple fill sites): tmfd->ctid, + * tmfd->xmax = the old version's update xid, tmfd->cmax = InvalidCommandId + * (the native value for any not-self-modified failure; a remote writer can + * never be TM_SelfModified). Returns false when the outcome cannot be + * soundly mapped (CWO_UNRESOLVABLE, missing next version, invalid update + * xid); the caller must then fail closed and MUST NOT consume res/tmfd. + * tmfd may be NULL when the caller fills failure data itself (the heapam + * callers reuse the native fill blocks); on TM_Ok tmfd is never written. + */ +extern bool cluster_writer_chain_decide(const ClusterWriterOutcome *wo, + const ItemPointerData *old_t_ctid, TransactionId update_xid, + TM_Result *res, TM_FailureData *tmfd); + +#endif /* CLUSTER_WRITER_CHAIN_H */ diff --git a/src/test/cluster_tap/t/223_cluster_3_14_visibility_variants.pl b/src/test/cluster_tap/t/223_cluster_3_14_visibility_variants.pl index b9b406aac3..900090eddb 100644 --- a/src/test/cluster_tap/t/223_cluster_3_14_visibility_variants.pl +++ b/src/test/cluster_tap/t/223_cluster_3_14_visibility_variants.pl @@ -137,7 +137,7 @@ # ============================================================ my $vis_rows = $n0->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE category = 'visibility'}); -is($vis_rows, '6', 'L5 visibility category exposes 6 D8 counters'); +is($vis_rows, '11', 'L5 visibility category exposes 6 D8 + 5 spec-7.1a D6 counters'); my $vis_keys = $n0->safe_psql('postgres', q{SELECT string_agg(key, ',' ORDER BY key) FROM pg_cluster_state @@ -145,6 +145,8 @@ like($vis_keys, qr/vis_conflict_failclosed_count/, 'L5 conflict counter present'); like($vis_keys, qr/prune_remote_keep_count/, 'L5 prune-keep counter present'); like($vis_keys, qr/vis_update_fork_count/, 'L5 update-fork counter present'); +like($vis_keys, qr/writer_chain_resolved_count/, 'L5 writer-chain counter present (spec-7.1a D6)'); +like($vis_keys, qr/covers_scn_refuse_count/, 'L5 covers-refuse counter present (spec-7.1a D6)'); # ============================================================ diff --git a/src/test/cluster_tap/t/346_cluster_6_12i_runtime_visibility.pl b/src/test/cluster_tap/t/346_cluster_6_12i_runtime_visibility.pl index 29625605d5..a18646ead3 100644 --- a/src/test/cluster_tap/t/346_cluster_6_12i_runtime_visibility.pl +++ b/src/test/cluster_tap/t/346_cluster_6_12i_runtime_visibility.pl @@ -121,6 +121,13 @@ sub poll_until quorum_voting_disks => 3, shared_data => 1, extra_conf => [ + # spec-7.1a: a LIVE remote ITL ref whose overlay lookup misses is + # resolved by the D4 origin pull, and that pull is (by design) + # armed by cluster.crossnode_write_write -- without it the + # updater-xmax judges on this test's superseded versions stay + # UNKNOWN fail-closed once the wave-i aging shape is reached + # (same conf evolution as the 6.15 striping arming below). + 'cluster.crossnode_write_write = on', 'autovacuum = off', 'cluster.ges_request_timeout_ms = 30000', 'cluster.gcs_reply_timeout_ms = 3000', @@ -295,8 +302,18 @@ sub poll_until # ============================================================ { # Warm a session: resolves + populates its per-backend authority memo. + # Since the spec-7.1a hardening every COMMITTED stamp is finalized on + # the verdict leg, so this fresh session's FIRST read can hit the + # leg-(e) clock-skew refusal (the L4b note: an expected transient, the + # observe self-heals the next attempt) -- retry until actually warm. my $s = $node1->background_psql('postgres', on_error_stop => 0); - my $warm = $s->query('SELECT count(*) FROM i_t'); + my $warm; + for my $try (1 .. 10) + { + $warm = $s->query('SELECT count(*) FROM i_t'); + last if defined $warm && $warm =~ /12/; + usleep(500_000); + } like($warm, qr/12/, 'L5 session warmed (memo + pool populated pre-crash)'); $pair->kill_node9(0); diff --git a/src/test/cluster_tap/t/365_cluster_7_1a_write_write.pl b/src/test/cluster_tap/t/365_cluster_7_1a_write_write.pl new file mode 100644 index 0000000000..eca5f8dbe0 --- /dev/null +++ b/src/test/cluster_tap/t/365_cluster_7_1a_write_write.pl @@ -0,0 +1,420 @@ +#!/usr/bin/env perl +#------------------------------------------------------------------------- +# +# 365_cluster_7_1a_write_write.pl +# spec-7.1a D7 -- cross-instance write-write coordination e2e on a +# 2-node ClusterPair, plus the 2026-07-09 review hardening legs (C1b +# verdict routing / in-doubt boundary / moved-partitions no-bloat). +# +# Legs (every leg tags its substrate with "# layer:"): +# L1 # layer: live -- pair boots; cluster.crossnode_write_write is +# default OFF; seed rows flushed to shared storage. +# L2 # layer: live -- today's write-write boundary, GUC off: a node1 +# write against an IN-FLIGHT remote writer fails closed (53R97 +# family, retryable) at the visibility judge; after the remote +# writer commits, the node1 RETRY applies on top of the committed +# value (no lost update, no silent chain while undecided). +# L3 # layer: live -- 8A leg, GUC ON: arming crossnode_write_write +# must NOT invent an unsound resolution -- the same in-flight +# conflict still fails closed (the D0 chain only ever consumes +# PROVEN terminal outcomes), and the post-commit retry converges +# identically. +# L4 # layer: live -- C1b verdict routing (review finding #1, the +# P0 red->green leg): the recycled-ITL-slot read (t/346 CP3 +# shape: fetch HIT whose shipped TT block carries a COMMITTED +# stamp) must NOT conclude committed from the stamp alone -- a +# durable COMMITTED stamp lands at 2PC pre-commit and is in-doubt +# evidence until the ORIGIN's C1b CLOG cross-check. Assert the +# read still succeeds AND cr.rtvis_verdict_wire_count moved (the +# stamp was finalized via the origin verdict leg, never consumed +# as a verdict by the fetch fast leg). +# L5 # layer: live -- moved-partitions no-bloat invariant (review +# finding #2 face): a remote cross-partition UPDATE (in-flight, +# then committed) never grows the OLD partition on the peer -- +# pg_relation_size(pt_a) is unchanged across conflict + retry +# convergence, and the retry lands on the moved row. +# L6 # layer: live -- 2PC in-doubt hard boundary (the review #1 +# equivalent assertion): while 'd7a' is PREPARED it is in-doubt +# BY DEFINITION -- node1 reads and writes of its touched row must +# fail closed / never serve the uncommitted value; after COMMIT +# PREPARED (the same durable COMMITTED stamp that would be +# in-doubt in the crash window, now finalized by the commit +# record) node1's read heals to the 2PC value through the C1b +# verdict discipline. The peer WRITE on that block stays +# fail-closed behind the deferred writer-transfer image +# (pre-existing availability boundary, asserted as such and +# escalated to the integration owner); the owner closes the +# value chain. +# +# KNOWN-BLOCKED (honest, 规则 18 -- not SKIPed, not faked green): +# - D0 writer-chain gain legs (blocked UPDATE wakes onto TM_Ok / +# TM_Updated / TM_Deleted and chains) and the moved-partitions +# PROBE guard leg: entering the cross-node TX enqueue wait on a +# WRITER xmax requires the peer to judge an IN-FLIGHT remote +# writer as in-progress, and on this branch that resolution is +# fail-closed by design (resolve_live_overlay_miss_via_origin +# pulls only TERMINAL verdicts; prepared/in-progress stay +# fail-closed -- cluster_visibility_resolve.c). In-progress +# interread is the spec-7.1 forward-interread serve lane, not +# landed on this branch; until it lands, the writer-wait path is +# exercised at the unit layer (test_cluster_writer_chain.c) and +# the lock-only wait path by t/280. L2/L3/L5 pin the fail-closed +# boundary that stands in its place. +# - crash-in-window in-doubt e2e (COMMIT PREPARED stamps the durable +# TT slot, owner crashes BEFORE the commit record, xact resolves +# as ROLLBACK PREPARED, survivor must fail closed): needs a fault +# injection point between the 2PC prefinish stamp and +# RecordTransactionCommitPrepared; none exists on this branch and +# adding one modifies the 2PC commit path (integration-owner lane +# call). The deterministic equivalents shipped here are L4 (the +# stamp is never consumed as a verdict) and L6 (a genuinely +# in-doubt xact is never served). +# - below-floor chain-walk leg (review finding #3): the harness +# boots with xid striping active, so pre-striping (below-floor) +# local update chains cannot be manufactured here; the predicate +# divergence is pinned by test_cluster_xid_stripe.c +# (foreign_class_cheap over-reports below-floor xids, +# provably_foreign never does). +# +# Harness: ClusterPair shared_data + 3 voting disks + autovacuum off + +# xid striping (spec-6.15 D4 origin derivation) + 2PC slots. +# +# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group +# Portions Copyright (c) 1994, Regents of the University of California +# Portions Copyright (c) 2026, pgrac contributors +# +# Author: SqlRush +# +# IDENTIFICATION +# src/test/cluster_tap/t/365_cluster_7_1a_write_write.pl +# +# NOTES +# pgrac-original file. +# Spec: spec-7.1a-cross-instance-write-write-mvcc-coordination.md (D7) +# +#------------------------------------------------------------------------- + +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::RealBin/../../perl"; + +use PostgreSQL::Test::ClusterPair; +use Test::More; +use Time::HiRes qw(usleep); + +sub state_val +{ + my ($node, $cat, $key) = @_; + my $v = $node->safe_psql('postgres', + qq{SELECT value FROM pg_cluster_state WHERE category='$cat' AND key='$key'}); + return defined($v) && $v ne '' ? $v + 0 : 0; +} + +sub write_retry +{ + my ($node, $sql) = @_; + for my $i (1 .. 10) + { + my $ok = eval { $node->safe_psql('postgres', $sql); 1 }; + return 1 if $ok; + usleep(500_000); + } + return 0; +} + +# Retry a statement until it succeeds (the cluster fail-closed judges are +# retryable by contract -- t/282 L3 idiom). Returns 1 on success. +sub retry_until_ok +{ + my ($node, $sql, $secs) = @_; + my $deadline = time() + $secs; + while (time() < $deadline) + { + my ($rc, $out, $err) = $node->psql('postgres', $sql); + return 1 if $rc == 0; + usleep(300_000); + } + return 0; +} + +# Poll a scalar SQL result until it equals $want (errors retried). +sub wait_for_val +{ + my ($node, $sql, $want, $secs) = @_; + my $deadline = time() + $secs; + while (time() < $deadline) + { + my $v = eval { $node->safe_psql('postgres', $sql); }; + return 1 if defined $v && $v eq $want; + usleep(200_000); + } + return 0; +} + +# ============================================================ +# L1 # layer: live -- boot, defaults, seed. +# ============================================================ +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'ww71a', + quorum_voting_disks => 3, + shared_data => 1, + extra_conf => [ + 'autovacuum = off', + 'max_prepared_transactions = 10', + 'cluster.ges_request_timeout_ms = 30000', + 'cluster.gcs_reply_timeout_ms = 3000', + # CI shards run in parallel; widen the misscount so a starved + # heartbeat does not falsely kill the lock-holding peer (t/280 note). + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 10', + # spec-6.15 D4: xid-derived origin needs a striped value space; + # striping requires online_join (boot contract FATALs otherwise). + 'cluster.online_join = on', + 'cluster.quorum_poll_interval_ms = 500', + 'cluster.join_convergence_timeout_ms = 30000', + 'cluster.xid_striping = on', + # 6.12i substrate, NOT the GUC under test: under striping the + # stride-16 congruence recycles a remote xact's TT slot almost + # immediately, so every remote xmin/xmax resolution on this pair + # needs the runtime-visibility path from the first read on. + 'cluster.crossnode_runtime_visibility = on', + ]); +$pair->start_pair; +usleep(2_000_000); + +ok($pair->wait_for_peer_state(0, 1, 'connected', 30), 'L1 node0 sees node1 connected'); +ok($pair->wait_for_peer_state(1, 0, 'connected', 30), 'L1 node1 sees node0 connected'); + +my ($node0, $node1) = ($pair->node0, $pair->node1); + +is($node0->safe_psql('postgres', 'SHOW cluster.crossnode_write_write'), 'off', + 'L1 crossnode_write_write default off'); +is($node0->safe_psql('postgres', 'SHOW cluster.crossnode_runtime_visibility'), 'on', + 'L1 crossnode_runtime_visibility armed by conf (striped-read substrate)'); + +for my $n ($node0, $node1) +{ + $n->safe_psql('postgres', 'CREATE TABLE ww_t (id int, ctr int)'); +} +my $p0 = $node0->safe_psql('postgres', q{SELECT pg_relation_filepath('ww_t')}); +my $p1 = $node1->safe_psql('postgres', q{SELECT pg_relation_filepath('ww_t')}); +if ($p0 ne $p1) +{ + $pair->stop_pair; + plan skip_all => "same-DDL relfilepath coincidence does not hold (n0=$p0 n1=$p1)"; +} +ok(write_retry($node0, 'INSERT INTO ww_t VALUES (1, 100), (2, 100)'), 'L1 seed rows inserted'); +ok(write_retry($node0, 'CHECKPOINT'), 'L1 seed flushed to shared storage'); +ok(wait_for_val($node1, 'SELECT ctr FROM ww_t WHERE id = 1', '100', 20), + 'L1 node1 reads the seed row (remote committed xmin resolvable)'); + +# ============================================================ +# L2 # layer: live -- write-write boundary, GUC off: in-flight conflict +# fails closed; the post-commit retry applies on top (no lost update). +# (The blocked-wait chain gain is KNOWN-BLOCKED -- see header.) +# ============================================================ +{ + my $h0 = $node0->background_psql('postgres', on_error_die => 1); + $h0->query_safe('BEGIN'); + $h0->query_safe('UPDATE ww_t SET ctr = 200 WHERE id = 1'); + + # The in-flight remote writer is undecided: node1's conflicting write + # must fail closed (retryable), never wait on a guess, never chain. + my ($rc, $out, $err) = $node1->psql('postgres', 'UPDATE ww_t SET ctr = ctr + 1 WHERE id = 1'); + isnt($rc, 0, 'L2 node1 write vs in-flight remote writer errors (fail-closed)'); + like($err, qr/cluster TT (status unknown|slot recycled)/, + 'L2 the failure is the cluster fail-closed judge, not a native guess'); + + $h0->query_safe('COMMIT'); + $h0->quit; + + ok(retry_until_ok($node1, 'UPDATE ww_t SET ctr = ctr + 1 WHERE id = 1', 30), + 'L2 node1 retry succeeds once the remote writer is terminal'); + is($node0->safe_psql('postgres', 'SELECT ctr FROM ww_t WHERE id = 1'), '201', + 'L2 node1 applied onto the committed 200 (no lost update)'); +} + +# ============================================================ +# L3 # layer: live -- 8A leg: arming crossnode_write_write must not +# invent a resolution for an UNDECIDED writer (D0 consumes only proven +# terminal outcomes); the same conflict still fails closed, the same +# retry converges. +# ============================================================ +for my $n ($node0, $node1) +{ + $n->safe_psql('postgres', 'ALTER SYSTEM SET cluster.crossnode_write_write = on'); + $n->safe_psql('postgres', 'SELECT pg_reload_conf()'); +} +usleep(1_000_000); +is($node1->safe_psql('postgres', 'SHOW cluster.crossnode_write_write'), 'on', + 'L3 crossnode_write_write armed on both nodes'); + +{ + my $h0 = $node0->background_psql('postgres', on_error_die => 1); + $h0->query_safe('BEGIN'); + $h0->query_safe('UPDATE ww_t SET ctr = 300 WHERE id = 1'); + + my ($rc, $out, $err) = $node1->psql('postgres', 'UPDATE ww_t SET ctr = ctr + 1 WHERE id = 1'); + isnt($rc, 0, 'L3 GUC on: in-flight conflict still fails closed (no unsound chain)'); + like($err, qr/cluster TT (status unknown|slot recycled)/, + 'L3 the failure is still the cluster fail-closed judge'); + + $h0->query_safe('COMMIT'); + $h0->quit; + + ok(retry_until_ok($node1, 'UPDATE ww_t SET ctr = ctr + 1 WHERE id = 1', 30), + 'L3 node1 retry succeeds on the terminal writer'); + is($node0->safe_psql('postgres', 'SELECT ctr FROM ww_t WHERE id = 1'), '301', + 'L3 node1 applied onto the committed 300 (no lost update)'); +} + +# ============================================================ +# L4 # layer: live -- C1b verdict routing (review finding #1, P0). +# The t/346 CP3 shape: one INSERT xact seeds a single block; >8 committed +# UPDATE xacts recycle its ITL slot; node1's read then resolves the seed +# xid through the runtime-visibility path with a fetch HIT whose shipped +# TT block carries a COMMITTED stamp. That stamp is EVIDENCE (durable +# stamps land at 2PC pre-commit; stamped-then-crashed is in-doubt), so +# the resolution MUST ride the origin verdict leg (C1b CLOG cross-check) +# -- never conclude from the stamp alone. +# ============================================================ +{ + $node0->safe_psql('postgres', 'CREATE TABLE rv_t (id int, v int)'); + $node1->safe_psql('postgres', 'CREATE TABLE rv_t (id int, v int)'); + ok(write_retry($node0, 'INSERT INTO rv_t SELECT g, g * 10 FROM generate_series(1, 12) g'), + 'L4 seed xact inserted 12 rows'); + for my $id (3 .. 12) + { + ok(write_retry($node0, "UPDATE rv_t SET v = v + 1 WHERE id = $id"), + "L4 recycler update xact (id=$id) committed"); + } + is($node0->safe_psql('postgres', + q{SELECT count(DISTINCT (ctid::text::point)[0]::int) FROM rv_t}), + '1', 'L4 all live rows stayed on ONE heap block'); + ok(write_retry($node0, 'CHECKPOINT'), 'L4 checkpoint'); + + my $vwire0 = state_val($node1, 'cr', 'rtvis_verdict_wire_count'); + my $fwire0 = state_val($node1, 'cr', 'rtvis_undo_fetch_wire_count'); + my $rc0 = state_val($node1, 'cr', 'rtvis_resolve_committed_count'); + + ok(wait_for_val($node1, 'SELECT count(*) FROM rv_t', '12', 20), + 'L4 node1 reads the recycled-slot block (12 rows)'); + + cmp_ok(state_val($node1, 'cr', 'rtvis_undo_fetch_wire_count'), '>', $fwire0, + 'L4 fetch leg still rides the wire first (fast-path face intact)'); + cmp_ok(state_val($node1, 'cr', 'rtvis_verdict_wire_count'), '>', $vwire0, + 'L4 C1b: the COMMITTED stamp was finalized via the ORIGIN VERDICT leg ' + . '(never consumed as a verdict by the fetch fast leg)'); + cmp_ok(state_val($node1, 'cr', 'rtvis_resolve_committed_count'), '>', $rc0, + 'L4 resolve_committed still counted once per resolution (no lost counting)'); +} + +# ============================================================ +# L5 # layer: live -- moved-partitions no-bloat invariant (review +# finding #2 face; the PROBE guard leg itself is KNOWN-BLOCKED -- header). +# A remote cross-partition UPDATE (in-flight, then committed) must never +# grow the OLD partition on the peer, and the peer's retry converges on +# the moved row. +# ============================================================ +{ + for my $n ($node0, $node1) + { + $n->safe_psql('postgres', + 'CREATE TABLE pt (id int, k int, v int) PARTITION BY LIST (k);' + . 'CREATE TABLE pt_a PARTITION OF pt FOR VALUES IN (1);' + . 'CREATE TABLE pt_b PARTITION OF pt FOR VALUES IN (5);'); + } + ok(write_retry($node0, 'INSERT INTO pt VALUES (1, 1, 100)'), 'L5 row seeded in pt_a'); + ok(write_retry($node0, 'CHECKPOINT'), 'L5 seed flushed'); + ok(wait_for_val($node1, 'SELECT v FROM pt WHERE id = 1', '100', 20), + 'L5 node1 reads the seed row in pt_a'); + + my $size0 = $node1->safe_psql('postgres', q{SELECT pg_relation_size('pt_a')}); + + my $h0 = $node0->background_psql('postgres', on_error_die => 1); + $h0->query_safe('BEGIN'); + $h0->query_safe('UPDATE pt SET k = 5 WHERE id = 1'); # moves pt_a -> pt_b + + my ($rc, $out, $err) = $node1->psql('postgres', 'UPDATE pt SET v = v + 1 WHERE id = 1'); + isnt($rc, 0, 'L5 node1 write vs the in-flight cross-partition mover fails closed'); + + $h0->query_safe('COMMIT'); + $h0->quit; + + ok(retry_until_ok($node1, 'UPDATE pt SET v = v + 1 WHERE id = 1', 30), + 'L5 node1 retry converges on the moved row'); + is($node0->safe_psql('postgres', 'SELECT k, v FROM pt WHERE id = 1'), '5|101', + 'L5 the moved row carries the +1 in pt_b (no lost update across the move)'); + is($node1->safe_psql('postgres', q{SELECT pg_relation_size('pt_a')}), $size0, + 'L5 old partition NOT extended on the peer across conflict + retry (no bloat)'); +} + +# ============================================================ +# L6 # layer: live -- 2PC in-doubt hard boundary (review #1 equivalent +# assertion; see header for the crash-in-window KNOWN-BLOCKED note). +# ============================================================ +{ + my $h0 = $node0->background_psql('postgres', on_error_die => 1); + $h0->query_safe('BEGIN'); + $h0->query_safe('UPDATE ww_t SET ctr = 500 WHERE id = 2'); + $h0->query_safe("PREPARE TRANSACTION 'd7a'"); + $h0->quit; + + # L6a: while PREPARED the xact is in-doubt BY DEFINITION. node1 must + # fail closed on its touched row -- and above all must NEVER serve the + # uncommitted 500 (false-visible) nor apply a write over it + # (false-commit chain). + my $saw_500 = 0; + my $failclosed = 0; + for my $try (1 .. 10) + { + my ($rc, $out, $err) = $node1->psql('postgres', 'SELECT ctr FROM ww_t WHERE id = 2'); + $saw_500 = 1 if defined $out && $out =~ /^500$/m; + $failclosed++ if $rc != 0 && $err =~ /cluster TT (status unknown|slot recycled)/; + usleep(200_000); + } + is($saw_500, 0, 'L6a the in-doubt (PREPARED) value 500 was NEVER served on node1'); + cmp_ok($failclosed, '>', 0, 'L6a in-doubt reads failed closed (cluster judge)'); + + my ($wrc, $wout, $werr) + = $node1->psql('postgres', 'UPDATE ww_t SET ctr = ctr + 1 WHERE id = 2'); + isnt($wrc, 0, 'L6b a write against the in-doubt holder fails closed (never chains)'); + + # L6c: COMMIT PREPARED resolves the doubt -- the SAME durable COMMITTED + # stamp that would be in-doubt in the crash window is now backed by the + # commit record, so the C1b verdict discipline lets node1 heal to 500 + # and apply on top. + $node0->safe_psql('postgres', "COMMIT PREPARED 'd7a'"); + ok(wait_for_val($node1, 'SELECT ctr FROM ww_t WHERE id = 2', '500', 30), + 'L6c after COMMIT PREPARED node1 heals to the 2PC value'); + + # Peer-write availability boundary (pre-existing; escalated to the + # integration owner, NOT a 7.1a regression): COMMIT PREPARED leaves the + # page ITL unstamped (a prepared xact's touched-buffer list is gone, so + # no fast-commit stamp), the owner deferred the writer-transfer on that + # uncommitted-looking ITL, and node1's cached DEFERRED read-image never + # re-requests X -- so node1 writes on this block stay fail-closed + # (retryable) even after owner-side xact churn recycles the cold ITL. + # Fail-closed is the correct 8.A direction (never a false chain): + # assert the boundary, then close the value chain from the owner side. + my $churned = 0; + for my $i (1 .. 10) + { + $churned++ if write_retry($node0, 'UPDATE ww_t SET ctr = ctr WHERE id = 1'); + } + is($churned, 10, 'L6c owner-side xact churn completes (cold 2PC ITL recycled)'); + my ($wrc2, $wout2, $werr2) + = $node1->psql('postgres', 'UPDATE ww_t SET ctr = ctr + 1 WHERE id = 2'); + isnt($wrc2, 0, 'L6c peer write on the 2PC-touched block stays fail-closed (deferred image)'); + like($werr2, qr/cannot write a block held in X by a remote node|cluster TT/, + 'L6c the refusal is the writer-transfer defer gate, never a silent wrong write'); + ok(write_retry($node0, 'UPDATE ww_t SET ctr = ctr + 1 WHERE id = 2'), + 'L6c owner applies +1 on top of the decided 2PC value'); + is($node0->safe_psql('postgres', 'SELECT ctr FROM ww_t WHERE id = 2'), '501', + 'L6c final value chains the 2PC commit and the +1'); +} + +$pair->stop_pair; +done_testing(); diff --git a/src/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index ae08788210..2360929a73 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -48,7 +48,7 @@ TESTS = test_cluster_basic test_cluster_version test_cluster_backend_types \ test_cluster_heap_lock_tuple test_cluster_perf_gates \ test_cluster_subtrans test_cluster_multixact test_cluster_undo_format \ test_cluster_undo_record test_cluster_undo_lifecycle test_cluster_cr test_cluster_cr_cache test_cluster_cr_key test_cluster_cr_pool test_cluster_cr_lifecycle test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_resolver_cache test_cluster_cr_coordinator test_cluster_tt_durable test_cluster_terminal_authority test_cluster_sf_dep \ - test_cluster_retention test_cluster_undo_cleaner test_cluster_visibility_variants test_cluster_tt_2pc \ + test_cluster_retention test_cluster_undo_cleaner test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc \ test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_undo_extent \ test_cluster_wal_thread test_cluster_wal_state test_cluster_recovery_plan \ test_cluster_backup \ @@ -180,8 +180,7 @@ test_cluster_backup: test_cluster_backup.c unit_test.h $(CLUSTER_VERSION_O) \ # separate rules because they also link additional cluster_*.o # objects (the test files stub the PG backend symbols those # objects reference). -SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority,$(TESTS)) - +SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority,$(TESTS)) # spec-2.4 D16: test_cluster_epoch links cluster_epoch.o standalone. # cluster_epoch.c references ShmemInitStruct + cluster_shmem_register_region # + pg_atomic_*; the test stubs all of these and exercises only the @@ -546,6 +545,14 @@ test_cluster_visibility_variants: test_cluster_visibility_variants.c unit_test.h $(CC) $(CFLAGS) $(CPPFLAGS) $< \ $(CLUSTER_VIS_VERDICT_O) \ $(top_builddir)/src/port/libpgport_srv.a -o $@ +# spec-7.1a D0/U0: test_cluster_writer_chain links the pure terminal-writer +# outcome -> TM_Result mapping standalone (no buffer / no page / no backend). +CLUSTER_WRITER_CHAIN_O = $(top_builddir)/src/backend/cluster/cluster_writer_chain.o +test_cluster_writer_chain: test_cluster_writer_chain.c unit_test.h \ + $(CLUSTER_WRITER_CHAIN_O) + $(CC) $(CFLAGS) $(CPPFLAGS) $< \ + $(CLUSTER_WRITER_CHAIN_O) \ + $(top_builddir)/src/port/libpgport_srv.a -o $@ test_cluster_retention: test_cluster_retention.c unit_test.h \ $(CLUSTER_UNDO_RETENTION_O) $(CC) $(CFLAGS) $(CPPFLAGS) $< \ diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index c91d184974..d1de9bad09 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -2149,6 +2149,32 @@ cluster_thread_recovery_get_recovered_through(void) { return 0; } +/* spec-7.1a D6 write-write chaining counter accessor stubs. */ +uint64 +cluster_vis_get_writer_chain_resolved_count(void) +{ + return 0; +} +uint64 +cluster_vis_get_writer_chain_failclosed_count(void) +{ + return 0; +} +uint64 +cluster_vis_get_xmax_resolved_count(void) +{ + return 0; +} +uint64 +cluster_vis_get_overlay_refresh_count(void) +{ + return 0; +} +uint64 +cluster_vis_get_covers_scn_refuse_count(void) +{ + return 0; +} /* spec-3.16 D5 recovery counter accessor stubs (dump_recovery rows). */ uint64 cluster_vis_get_recovery_undo_redo_applies(void) diff --git a/src/test/cluster_unit/test_cluster_runtime_visibility.c b/src/test/cluster_unit/test_cluster_runtime_visibility.c index 41dd707ddd..d287c75b11 100644 --- a/src/test/cluster_unit/test_cluster_runtime_visibility.c +++ b/src/test/cluster_unit/test_cluster_runtime_visibility.c @@ -40,67 +40,110 @@ ExceptionalCondition(const char *conditionName pg_attribute_unused(), abort(); } +/* policy.o references scn_time_cmp (spec-7.1a D3); SCNs here are plain + * monotonic test values, so the total-order stub is a raw compare. */ +int +scn_time_cmp(SCN a, SCN b) +{ + if (a == b) + return 0; + return (a > b) ? 1 : -1; +} + #define LOCAL_EPOCH 7 static ClusterLiveAuthority -mk_auth(uint64 epoch, XLogRecPtr hwm, uint64 gen) +mk_auth(uint64 epoch, XLogRecPtr hwm, uint64 gen, SCN authority_scn) { ClusterLiveAuthority a; a.origin_epoch = epoch; a.live_hwm_lsn = hwm; a.tt_generation = gen; + a.authority_scn = authority_scn; return a; } -/* All three admit conditions hold -> resolve is permitted (the ONLY true). */ -UT_TEST(test_covers_when_epoch_match_and_hwm_ge_anchor) +/* All admit conditions hold -> resolve is permitted (the ONLY true). */ +UT_TEST(test_covers_when_epoch_match_and_scn_ge_demand) +{ + ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, 5000, 42, (SCN)900); + + /* authority_scn (900) == demand (900): boundary-equal covers. */ + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)900, a, LOCAL_EPOCH), true); + /* authority_scn (900) > demand (450): covers. */ + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)450, a, LOCAL_EPOCH), true); +} + +/* + * spec-7.1a D3 U2 (false-refuse regression): the former gate compared the + * origin flush LSN against the tuple page LSN, refusing whenever the page + * was last written by ANOTHER WAL thread with a numerically larger LSN + * (gaps §C.2, 10/10 measured). The LSN must not gate any more: a tiny + * (but valid) hwm with a covering SCN admits. + */ +UT_TEST(test_covers_ignores_cross_thread_lsn) { - ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, 5000, 42); + ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, 1 /* tiny but valid */, 42, (SCN)900); - /* hwm (5000) >= anchor (5000): boundary-equal covers. */ - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(5000, a, LOCAL_EPOCH), true); - /* hwm (5000) > anchor (4096): covers. */ - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(4096, a, LOCAL_EPOCH), true); + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)900, a, LOCAL_EPOCH), true); } /* Authority from a different reconfig generation -> fail closed. */ UT_TEST(test_failclosed_when_epoch_differs) { - ClusterLiveAuthority older = mk_auth(LOCAL_EPOCH - 1, 9000, 42); - ClusterLiveAuthority newer = mk_auth(LOCAL_EPOCH + 1, 9000, 42); + ClusterLiveAuthority older = mk_auth(LOCAL_EPOCH - 1, 9000, 42, (SCN)900); + ClusterLiveAuthority newer = mk_auth(LOCAL_EPOCH + 1, 9000, 42, (SCN)900); - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(1, older, LOCAL_EPOCH), false); - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(1, newer, LOCAL_EPOCH), false); + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)1, older, LOCAL_EPOCH), false); + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)1, newer, LOCAL_EPOCH), false); } /* No authority sampled (invalid hwm) -> fail closed, never guess. */ UT_TEST(test_failclosed_when_hwm_invalid) { - ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, InvalidXLogRecPtr, 42); + ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, InvalidXLogRecPtr, 42, (SCN)900); + + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)1, a, LOCAL_EPOCH), false); +} + +/* Older peer shipped no SCN co-sample (zero trailer) -> fail closed. */ +UT_TEST(test_failclosed_when_authority_scn_absent) +{ + ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, 9000, 42, InvalidScn); + + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)1, a, LOCAL_EPOCH), false); +} + +/* Caller supplied no demand -> fail closed (never guess a demand). */ +UT_TEST(test_failclosed_when_demand_invalid) +{ + ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, 9000, 42, (SCN)900); - /* Even with anchor 0, an absent authority must not admit. */ - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(0, a, LOCAL_EPOCH), false); + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(InvalidScn, a, LOCAL_EPOCH), false); } -/* Origin durable TT does not yet cover this page version -> fail closed. */ -UT_TEST(test_failclosed_when_hwm_below_anchor) +/* + * spec-7.1a D3 U2 (false-pass guard): origin clock BEHIND the demand -> + * the shipped content is not provably conclusive for this demand; refuse + * (the observe of the shipped SCN makes the retry self-heal). + */ +UT_TEST(test_failclosed_when_authority_scn_below_demand) { - ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, 4095, 42); + ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH, 9000, 42, (SCN)899); - /* hwm (4095) < anchor (4096): under-covered window. */ - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(4096, a, LOCAL_EPOCH), false); + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)900, a, LOCAL_EPOCH), false); } /* - * Combined-doubt: epoch mismatch takes precedence even when the hwm would + * Combined-doubt: epoch mismatch takes precedence even when the SCN would * otherwise cover -- proves conditions are ANDed, not ORed. */ -UT_TEST(test_failclosed_epoch_mismatch_dominates_good_hwm) +UT_TEST(test_failclosed_epoch_mismatch_dominates_good_scn) { - ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH + 3, 0xFFFFFFFF, 42); + ClusterLiveAuthority a = mk_auth(LOCAL_EPOCH + 3, 0xFFFFFFFF, 42, (SCN)9999); - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(1, a, LOCAL_EPOCH), false); + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)1, a, LOCAL_EPOCH), false); } /* @@ -111,11 +154,11 @@ UT_TEST(test_failclosed_epoch_mismatch_dominates_good_hwm) UT_TEST(test_failclosed_epoch_differs_above_32bit) { uint64 wide_epoch = ((uint64)1 << 32) + LOCAL_EPOCH; - ClusterLiveAuthority a = mk_auth(wide_epoch, 9000, 42); + ClusterLiveAuthority a = mk_auth(wide_epoch, 9000, 42, (SCN)900); - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(1, a, LOCAL_EPOCH), false); + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)1, a, LOCAL_EPOCH), false); /* Same wide epoch on both sides still admits (sanity). */ - UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy(1, a, wide_epoch), true); + UT_ASSERT_EQ(cluster_vis_live_authority_covers_policy((SCN)1, a, wide_epoch), true); } /* CP2: authority trailer little-endian carrier roundtrip (wire ABI). */ @@ -129,10 +172,16 @@ UT_TEST(test_undo_auth_trailer_roundtrip) for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { ClusterGcsUndoAuthTrailerSetTtGeneration(&t, cases[i]); UT_ASSERT_EQ(ClusterGcsUndoAuthTrailerGetTtGeneration(&t), cases[i]); + /* spec-7.1a D3: the SCN carrier rides the former reserved bytes. */ + ClusterGcsUndoAuthTrailerSetAuthorityScn(&t, cases[i] ^ 0x5a5a5a5a5a5a5a5aULL); + UT_ASSERT_EQ(ClusterGcsUndoAuthTrailerGetAuthorityScn(&t), + cases[i] ^ 0x5a5a5a5a5a5a5a5aULL); + /* The two carriers are independent (no overlap). */ + UT_ASSERT_EQ(ClusterGcsUndoAuthTrailerGetTtGeneration(&t), cases[i]); } - /* The setter must not touch the reserved (must-be-zero) tail. */ - for (size_t i = 0; i < sizeof(t.reserved_0); i++) - UT_ASSERT_EQ(t.reserved_0[i], 0); + /* Zeroed trailer reads as InvalidScn (older-peer fail-closed signal). */ + memset(&t, 0, sizeof(t)); + UT_ASSERT_EQ(ClusterGcsUndoAuthTrailerGetAuthorityScn(&t), 0); } /* ============================================================ @@ -189,6 +238,24 @@ UT_TEST(test_ttproof_committed_and_aborted) UT_ASSERT_EQ(wrap, 2); } +/* spec-7.1a hardening (C1b): PROOF_COMMITTED is EVIDENCE, not a verdict -- + * the consumer routes it to the origin verdict leg and takes NO payload from + * the stamp (a durable COMMITTED stamp lands at 2PC pre-commit, so a + * stamped-then-crashed xid is in-doubt). Pin the NULL-out-param consumer + * shape: the proof classification itself is unchanged and payload pointers + * are optional. */ +UT_TEST(test_ttproof_committed_is_evidence_not_verdict) +{ + UndoSegmentHeaderData *hdr = mk_header(PROOF_SEG, PROOF_OWNER, TT_SLOTS_PER_SEGMENT); + + set_slot(hdr, 3, 1000, 5, (uint8)TT_SLOT_COMMITTED, (SCN)777); + + /* The hardened fast leg passes NULL/NULL: classification only. */ + UT_ASSERT_EQ(cluster_vis_tt_block_positive_proof(proof_block.data, PROOF_SEG, PROOF_OWNER, 1000, + NULL, NULL), + CLUSTER_VIS_TT_PROOF_COMMITTED); +} + /* 0-match / ACTIVE / COMMITTED-without-scn: never a proof (user boundary: * a single fetched block's 0-match is NOT recycled/aborted evidence). */ UT_TEST(test_ttproof_zero_match_active_invalid_scn) @@ -362,14 +429,18 @@ UT_TEST(test_undo_fetch_tag_roundtrip) int main(void) { - UT_PLAN(13); - UT_RUN(test_covers_when_epoch_match_and_hwm_ge_anchor); + UT_PLAN(16); + UT_RUN(test_covers_when_epoch_match_and_scn_ge_demand); + UT_RUN(test_covers_ignores_cross_thread_lsn); UT_RUN(test_failclosed_when_epoch_differs); UT_RUN(test_failclosed_when_hwm_invalid); - UT_RUN(test_failclosed_when_hwm_below_anchor); - UT_RUN(test_failclosed_epoch_mismatch_dominates_good_hwm); + UT_RUN(test_failclosed_when_authority_scn_absent); + UT_RUN(test_failclosed_when_demand_invalid); + UT_RUN(test_failclosed_when_authority_scn_below_demand); + UT_RUN(test_failclosed_epoch_mismatch_dominates_good_scn); UT_RUN(test_failclosed_epoch_differs_above_32bit); UT_RUN(test_ttproof_committed_and_aborted); + UT_RUN(test_ttproof_committed_is_evidence_not_verdict); UT_RUN(test_ttproof_zero_match_active_invalid_scn); UT_RUN(test_ttproof_ambiguity_and_garbage); UT_RUN(test_ttproof_header_mismatch); diff --git a/src/test/cluster_unit/test_cluster_writer_chain.c b/src/test/cluster_unit/test_cluster_writer_chain.c new file mode 100644 index 0000000000..eba211a2bd --- /dev/null +++ b/src/test/cluster_unit/test_cluster_writer_chain.c @@ -0,0 +1,266 @@ +/*------------------------------------------------------------------------- + * + * test_cluster_writer_chain.c + * cluster_unit U0 truth table for cross-node terminal-writer chaining + * (spec-7.1a D0): outcome kind x next-version availability -> + * {TM_Updated / TM_Deleted / TM_Ok / not-mappable(53R9H)} plus the + * TM_FailureData field contract (ctid / xmax real values, + * cmax == InvalidCommandId per the native other-transaction contract). + * + * cluster_writer_chain_decide is pure (no buffer / no page), so the + * table is enumerated exhaustively here. The resolver integration + * (TT / live-IC verdict / cross-node next-version probe) is covered + * by cluster_tap (2-node write-write legs). + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/test/cluster_unit/test_cluster_writer_chain.c + * + * NOTES + * This is a pgrac-original file. + * Spec: spec-7.1a-cross-instance-write-write-mvcc-coordination.md §4 U0. + * + *------------------------------------------------------------------------- + */ +#define USE_PGRAC_CLUSTER 1 + +#include "postgres.h" + +#include "access/htup_details.h" +#include "cluster/cluster_writer_chain.h" + +/* Drop PG's port.h printf -> pg_printf override; unit_test.h uses + * stdlib printf and we don't link full libpgport in this test binary. */ +#undef printf + +#include "unit_test.h" + +UT_DEFINE_GLOBALS(); + +/* ============================================================ + * Stubs — this binary links only cluster_writer_chain.o + libpgport. + * ============================================================ */ + +void +ExceptionalCondition(const char *conditionName pg_attribute_unused(), + const char *fileName pg_attribute_unused(), + int lineNumber pg_attribute_unused()) +{ + abort(); +} + +/* backend itemptr.c is not linked; block+offset equality is the contract. */ +bool +ItemPointerEquals(ItemPointer pointer1, ItemPointer pointer2) +{ + return ItemPointerGetBlockNumberNoCheck(pointer1) == ItemPointerGetBlockNumberNoCheck(pointer2) + && ItemPointerGetOffsetNumberNoCheck(pointer1) + == ItemPointerGetOffsetNumberNoCheck(pointer2); +} + +#define OLD_BLK 7 +#define OLD_OFF 3 +#define NEW_BLK 9 +#define NEW_OFF 5 +#define UPDATE_XID ((TransactionId)4242) + +/* Sentinels: assert decide() never writes res/tmfd on the false path. */ +#define RES_SENTINEL ((TM_Result)0x7f) +#define XMAX_SENTINEL ((TransactionId)0xdeadbeef) +#define CMAX_SENTINEL ((CommandId)0xfeedface) + +static HeapTupleData dummy_new_tuple; + +static ClusterWriterOutcome +make_outcome(ClusterWriterOutcomeKind kind, bool with_new_ctid, bool with_new_tuple) +{ + ClusterWriterOutcome wo; + + memset(&wo, 0, sizeof(wo)); + wo.kind = kind; + if (with_new_ctid) + ItemPointerSet(&wo.new_ctid, NEW_BLK, NEW_OFF); + else + ItemPointerSetInvalid(&wo.new_ctid); + wo.new_tuple = with_new_tuple ? &dummy_new_tuple : NULL; + return wo; +} + +static void +make_sentinels(TM_Result *res, TM_FailureData *tmfd, ItemPointerData *old_ctid, bool old_ctid_self) +{ + *res = RES_SENTINEL; + memset(tmfd, 0, sizeof(*tmfd)); + ItemPointerSetInvalid(&tmfd->ctid); + tmfd->xmax = XMAX_SENTINEL; + tmfd->cmax = CMAX_SENTINEL; + /* DELETE: old tuple's t_ctid self-points; UPDATE: it names the next version. */ + if (old_ctid_self) + ItemPointerSet(old_ctid, OLD_BLK, OLD_OFF); + else + ItemPointerSet(old_ctid, NEW_BLK, NEW_OFF); +} + +/* ---- U0.1 remote writer aborted -> TM_Ok, tmfd untouched ---- */ +UT_TEST(test_u0_aborted_maps_tm_ok) +{ + ClusterWriterOutcome wo = make_outcome(CWO_ABORTED, false, false); + TM_Result res; + TM_FailureData tmfd; + ItemPointerData old_ctid; + + make_sentinels(&res, &tmfd, &old_ctid, true); + UT_ASSERT(cluster_writer_chain_decide(&wo, &old_ctid, UPDATE_XID, &res, &tmfd)); + UT_ASSERT_EQ((int)res, (int)TM_Ok); + /* TM_Ok never writes failure data (native contract: tmfd only on failure). */ + UT_ASSERT_EQ((int)tmfd.xmax, (int)XMAX_SENTINEL); + UT_ASSERT_EQ((int)tmfd.cmax, (int)CMAX_SENTINEL); +} + +/* ---- U0.2 remote DELETE committed -> TM_Deleted + full tmfd ---- */ +UT_TEST(test_u0_delete_committed_maps_tm_deleted) +{ + ClusterWriterOutcome wo = make_outcome(CWO_DELETED, false, false); + TM_Result res; + TM_FailureData tmfd; + ItemPointerData old_ctid; + + make_sentinels(&res, &tmfd, &old_ctid, true); + UT_ASSERT(cluster_writer_chain_decide(&wo, &old_ctid, UPDATE_XID, &res, &tmfd)); + UT_ASSERT_EQ((int)res, (int)TM_Deleted); + UT_ASSERT(ItemPointerEquals(&tmfd.ctid, &old_ctid)); + UT_ASSERT_EQ((int)tmfd.xmax, (int)UPDATE_XID); + UT_ASSERT_EQ((int)tmfd.cmax, (int)InvalidCommandId); +} + +/* ---- U0.3 remote UPDATE committed + next version probed -> TM_Updated ---- */ +UT_TEST(test_u0_update_committed_maps_tm_updated) +{ + ClusterWriterOutcome wo = make_outcome(CWO_UPDATED, true, true); + TM_Result res; + TM_FailureData tmfd; + ItemPointerData old_ctid; + + make_sentinels(&res, &tmfd, &old_ctid, false); + UT_ASSERT(cluster_writer_chain_decide(&wo, &old_ctid, UPDATE_XID, &res, &tmfd)); + UT_ASSERT_EQ((int)res, (int)TM_Updated); + /* ctid = the NEXT version (what EPQ chases), never the old tid. */ + UT_ASSERT(ItemPointerEquals(&tmfd.ctid, &wo.new_ctid)); + UT_ASSERT_EQ((int)ItemPointerGetBlockNumber(&tmfd.ctid), NEW_BLK); + UT_ASSERT_EQ((int)tmfd.xmax, (int)UPDATE_XID); + UT_ASSERT_EQ((int)tmfd.cmax, (int)InvalidCommandId); +} + +/* ---- U0.4 UPDATE committed but next version NOT fetched -> not mappable ---- */ +UT_TEST(test_u0_update_without_new_tuple_fails_closed) +{ + ClusterWriterOutcome wo = make_outcome(CWO_UPDATED, true, false); + TM_Result res; + TM_FailureData tmfd; + ItemPointerData old_ctid; + + make_sentinels(&res, &tmfd, &old_ctid, false); + UT_ASSERT(!cluster_writer_chain_decide(&wo, &old_ctid, UPDATE_XID, &res, &tmfd)); + /* false return must leave res/tmfd unwritten (no partial failure data). */ + UT_ASSERT_EQ((int)res, (int)RES_SENTINEL); + UT_ASSERT_EQ((int)tmfd.xmax, (int)XMAX_SENTINEL); + UT_ASSERT_EQ((int)tmfd.cmax, (int)CMAX_SENTINEL); +} + +/* ---- U0.5 UPDATE committed but next-version tid invalid -> not mappable ---- */ +UT_TEST(test_u0_update_without_new_ctid_fails_closed) +{ + ClusterWriterOutcome wo = make_outcome(CWO_UPDATED, false, true); + TM_Result res; + TM_FailureData tmfd; + ItemPointerData old_ctid; + + make_sentinels(&res, &tmfd, &old_ctid, false); + UT_ASSERT(!cluster_writer_chain_decide(&wo, &old_ctid, UPDATE_XID, &res, &tmfd)); + UT_ASSERT_EQ((int)res, (int)RES_SENTINEL); +} + +/* ---- U0.6 DELETE/UPDATE with invalid update xid -> not mappable ---- */ +UT_TEST(test_u0_invalid_update_xid_fails_closed) +{ + ClusterWriterOutcome del = make_outcome(CWO_DELETED, false, false); + ClusterWriterOutcome upd = make_outcome(CWO_UPDATED, true, true); + TM_Result res; + TM_FailureData tmfd; + ItemPointerData old_ctid; + + make_sentinels(&res, &tmfd, &old_ctid, true); + UT_ASSERT(!cluster_writer_chain_decide(&del, &old_ctid, InvalidTransactionId, &res, &tmfd)); + UT_ASSERT_EQ((int)res, (int)RES_SENTINEL); + + make_sentinels(&res, &tmfd, &old_ctid, false); + UT_ASSERT(!cluster_writer_chain_decide(&upd, &old_ctid, InvalidTransactionId, &res, &tmfd)); + UT_ASSERT_EQ((int)res, (int)RES_SENTINEL); +} + +/* ---- U0.7 UNRESOLVABLE -> not mappable, res/tmfd untouched ---- */ +UT_TEST(test_u0_unresolvable_fails_closed) +{ + ClusterWriterOutcome wo = make_outcome(CWO_UNRESOLVABLE, false, false); + TM_Result res; + TM_FailureData tmfd; + ItemPointerData old_ctid; + + make_sentinels(&res, &tmfd, &old_ctid, true); + UT_ASSERT(!cluster_writer_chain_decide(&wo, &old_ctid, UPDATE_XID, &res, &tmfd)); + UT_ASSERT_EQ((int)res, (int)RES_SENTINEL); + UT_ASSERT_EQ((int)tmfd.xmax, (int)XMAX_SENTINEL); + UT_ASSERT_EQ((int)tmfd.cmax, (int)CMAX_SENTINEL); +} + +/* ---- U0.8 tmfd == NULL supported (heapam callers reuse native fill) ---- */ +UT_TEST(test_u0_null_tmfd_supported) +{ + ClusterWriterOutcome del = make_outcome(CWO_DELETED, false, false); + ClusterWriterOutcome upd = make_outcome(CWO_UPDATED, true, true); + ClusterWriterOutcome abo = make_outcome(CWO_ABORTED, false, false); + TM_Result res; + ItemPointerData old_ctid; + + ItemPointerSet(&old_ctid, OLD_BLK, OLD_OFF); + res = RES_SENTINEL; + UT_ASSERT(cluster_writer_chain_decide(&del, &old_ctid, UPDATE_XID, &res, NULL)); + UT_ASSERT_EQ((int)res, (int)TM_Deleted); + res = RES_SENTINEL; + UT_ASSERT(cluster_writer_chain_decide(&upd, &old_ctid, UPDATE_XID, &res, NULL)); + UT_ASSERT_EQ((int)res, (int)TM_Updated); + res = RES_SENTINEL; + UT_ASSERT(cluster_writer_chain_decide(&abo, &old_ctid, UPDATE_XID, &res, NULL)); + UT_ASSERT_EQ((int)res, (int)TM_Ok); +} + +/* ---- U0.9 enum ABI stability (test-session / TAP contract) ---- */ +UT_TEST(test_u0_outcome_kind_enum_stable) +{ + UT_ASSERT_EQ((int)CWO_UPDATED, 0); + UT_ASSERT_EQ((int)CWO_DELETED, 1); + UT_ASSERT_EQ((int)CWO_ABORTED, 2); + UT_ASSERT_EQ((int)CWO_UNRESOLVABLE, 3); +} + +int +main(void) +{ + UT_PLAN(9); + UT_RUN(test_u0_aborted_maps_tm_ok); + UT_RUN(test_u0_delete_committed_maps_tm_deleted); + UT_RUN(test_u0_update_committed_maps_tm_updated); + UT_RUN(test_u0_update_without_new_tuple_fails_closed); + UT_RUN(test_u0_update_without_new_ctid_fails_closed); + UT_RUN(test_u0_invalid_update_xid_fails_closed); + UT_RUN(test_u0_unresolvable_fails_closed); + UT_RUN(test_u0_null_tmfd_supported); + UT_RUN(test_u0_outcome_kind_enum_stable); + UT_DONE(); + return ut_failed_count == 0 ? 0 : 1; +}