Commit 27cc7cd2 authored by Andres Freund's avatar Andres Freund

Reorder EPQ work, to fix rowmark related bugs and improve efficiency.

In ad0bda5d I changed the EvalPlanQual machinery to store
substitution tuples in slot, instead of using plain HeapTuples. The
main motivation for that was that using HeapTuples will be inefficient
for future tableams.  But it turns out that that conversion was buggy
for non-locking rowmarks - the wrong tuple descriptor was used to
create the slot.

As a secondary issue 5db6df0c changed ExecLockRows() to begin EPQ
earlier, to allow to fetch the locked rows directly into the EPQ
slots, instead of having to copy tuples around. Unfortunately, as Tom
complained, that forces some expensive initialization to happen
earlier.

As a third issue, the test coverage for EPQ was clearly insufficient.

Fixing the first issue is unfortunately not trivial: Non-locked row
marks were fetched at the start of EPQ, and we don't have the type
information for the rowmarks available at that point. While we could
change that, it's not easy. It might be worthwhile to change that at
some point, but to fix this bug, it seems better to delay fetching
non-locking rowmarks when they're actually needed, rather than
eagerly. They're referenced at most once, and in cases where EPQ
fails, might never be referenced. Fetching them when needed also
increases locality a bit.

To be able to fetch rowmarks during execution, rather than
initialization, we need to be able to access the active EPQState, as
that contains necessary data. To do so move EPQ related data from
EState to EPQState, and, only for EStates creates as part of EPQ,
reference the associated EPQState from EState.

To fix the second issue, change EPQ initialization to allow use of
EvalPlanQualSlot() to be used before EvalPlanQualBegin() (but
obviously still requiring EvalPlanQualInit() to have been done).

As these changes made struct EState harder to understand, e.g. by
adding multiple EStates, significantly reorder the members, and add a
lot more comments.

Also add a few more EPQ tests, including one that fails for the first
issue above. More is needed.

Reported-By: yi huang
Author: Andres Freund
Reviewed-By: Tom Lane
Discussion:
    https://postgr.es/m/CAHU7rYZo_C4ULsAx_LAj8az9zqgrD8WDd4hTegDTMM1LMqrBsg@mail.gmail.com
    https://postgr.es/m/24530.1562686693@sss.pgh.pa.us
Backpatch: 12-, where the EPQ changes were introduced
parent 7e041603
......@@ -3363,8 +3363,7 @@ GetTupleForTrigger(EState *estate,
{
TupleTableSlot *epqslot;
epqslot = EvalPlanQual(estate,
epqstate,
epqslot = EvalPlanQual(epqstate,
relation,
relinfo->ri_RangeTableIndex,
oldslot);
......
This diff is collapsed.
......@@ -40,8 +40,10 @@ ExecScanFetch(ScanState *node,
CHECK_FOR_INTERRUPTS();
if (estate->es_epqTupleSlot != NULL)
if (estate->es_epq_active != NULL)
{
EPQState *epqstate = estate->es_epq_active;
/*
* We are inside an EvalPlanQual recheck. Return the test tuple if
* one is available, after rechecking any access-method-specific
......@@ -51,29 +53,43 @@ ExecScanFetch(ScanState *node,
if (scanrelid == 0)
{
TupleTableSlot *slot = node->ss_ScanTupleSlot;
/*
* This is a ForeignScan or CustomScan which has pushed down a
* join to the remote side. The recheck method is responsible not
* only for rechecking the scan/join quals but also for storing
* the correct tuple in the slot.
*/
TupleTableSlot *slot = node->ss_ScanTupleSlot;
if (!(*recheckMtd) (node, slot))
ExecClearTuple(slot); /* would not be returned by scan */
return slot;
}
else if (estate->es_epqTupleSlot[scanrelid - 1] != NULL)
else if (epqstate->relsubs_done[scanrelid - 1])
{
/*
* Return empty slot, as we already performed an EPQ substitution
* for this relation.
*/
TupleTableSlot *slot = node->ss_ScanTupleSlot;
/* Return empty slot if we already returned a tuple */
if (estate->es_epqScanDone[scanrelid - 1])
return ExecClearTuple(slot);
/* Else mark to remember that we shouldn't return more */
estate->es_epqScanDone[scanrelid - 1] = true;
/* Return empty slot, as we already returned a tuple */
return ExecClearTuple(slot);
}
else if (epqstate->relsubs_slot[scanrelid - 1] != NULL)
{
/*
* Return replacement tuple provided by the EPQ caller.
*/
slot = estate->es_epqTupleSlot[scanrelid - 1];
TupleTableSlot *slot = epqstate->relsubs_slot[scanrelid - 1];
Assert(epqstate->relsubs_rowmark[scanrelid - 1] == NULL);
/* Mark to remember that we shouldn't return more */
epqstate->relsubs_done[scanrelid - 1] = true;
/* Return empty slot if we haven't got a test tuple */
if (TupIsNull(slot))
......@@ -83,7 +99,30 @@ ExecScanFetch(ScanState *node,
if (!(*recheckMtd) (node, slot))
return ExecClearTuple(slot); /* would not be returned by
* scan */
return slot;
}
else if (epqstate->relsubs_rowmark[scanrelid - 1] != NULL)
{
/*
* Fetch and return replacement tuple using a non-locking rowmark.
*/
TupleTableSlot *slot = node->ss_ScanTupleSlot;
/* Mark to remember that we shouldn't return more */
epqstate->relsubs_done[scanrelid - 1] = true;
if (!EvalPlanQualFetchRowMark(epqstate, scanrelid, slot))
return NULL;
/* Return empty slot if we haven't got a test tuple */
if (TupIsNull(slot))
return NULL;
/* Check if it meets the access-method conditions */
if (!(*recheckMtd) (node, slot))
return ExecClearTuple(slot); /* would not be returned by
* scan */
return slot;
}
}
......@@ -268,12 +307,13 @@ ExecScanReScan(ScanState *node)
ExecClearTuple(node->ss_ScanTupleSlot);
/* Rescan EvalPlanQual tuple if we're inside an EvalPlanQual recheck */
if (estate->es_epqScanDone != NULL)
if (estate->es_epq_active != NULL)
{
EPQState *epqstate = estate->es_epq_active;
Index scanrelid = ((Scan *) node->ps.plan)->scanrelid;
if (scanrelid > 0)
estate->es_epqScanDone[scanrelid - 1] = false;
epqstate->relsubs_done[scanrelid - 1] = false;
else
{
Bitmapset *relids;
......@@ -295,7 +335,7 @@ ExecScanReScan(ScanState *node)
while ((rtindex = bms_next_member(relids, rtindex)) >= 0)
{
Assert(rtindex > 0);
estate->es_epqScanDone[rtindex - 1] = false;
epqstate->relsubs_done[rtindex - 1] = false;
}
}
}
......
......@@ -156,8 +156,6 @@ CreateExecutorState(void)
estate->es_per_tuple_exprcontext = NULL;
estate->es_epqTupleSlot = NULL;
estate->es_epqScanDone = NULL;
estate->es_sourceText = NULL;
estate->es_use_parallel_mode = false;
......
......@@ -420,25 +420,27 @@ void
ExecIndexOnlyMarkPos(IndexOnlyScanState *node)
{
EState *estate = node->ss.ps.state;
EPQState *epqstate = estate->es_epq_active;
if (estate->es_epqTupleSlot != NULL)
if (epqstate != NULL)
{
/*
* We are inside an EvalPlanQual recheck. If a test tuple exists for
* this relation, then we shouldn't access the index at all. We would
* instead need to save, and later restore, the state of the
* es_epqScanDone flag, so that re-fetching the test tuple is
* possible. However, given the assumption that no caller sets a mark
* at the start of the scan, we can only get here with es_epqScanDone
* relsubs_done flag, so that re-fetching the test tuple is possible.
* However, given the assumption that no caller sets a mark at the
* start of the scan, we can only get here with relsubs_done[i]
* already set, and so no state need be saved.
*/
Index scanrelid = ((Scan *) node->ss.ps.plan)->scanrelid;
Assert(scanrelid > 0);
if (estate->es_epqTupleSlot[scanrelid - 1] != NULL)
if (epqstate->relsubs_slot[scanrelid - 1] != NULL ||
epqstate->relsubs_rowmark[scanrelid - 1] != NULL)
{
/* Verify the claim above */
if (!estate->es_epqScanDone[scanrelid - 1])
if (!epqstate->relsubs_done[scanrelid - 1])
elog(ERROR, "unexpected ExecIndexOnlyMarkPos call in EPQ recheck");
return;
}
......@@ -455,17 +457,19 @@ void
ExecIndexOnlyRestrPos(IndexOnlyScanState *node)
{
EState *estate = node->ss.ps.state;
EPQState *epqstate = estate->es_epq_active;
if (estate->es_epqTupleSlot != NULL)
if (estate->es_epq_active != NULL)
{
/* See comments in ExecIndexOnlyMarkPos */
/* See comments in ExecIndexMarkPos */
Index scanrelid = ((Scan *) node->ss.ps.plan)->scanrelid;
Assert(scanrelid > 0);
if (estate->es_epqTupleSlot[scanrelid - 1])
if (epqstate->relsubs_slot[scanrelid - 1] != NULL ||
epqstate->relsubs_rowmark[scanrelid - 1] != NULL)
{
/* Verify the claim above */
if (!estate->es_epqScanDone[scanrelid - 1])
if (!epqstate->relsubs_done[scanrelid - 1])
elog(ERROR, "unexpected ExecIndexOnlyRestrPos call in EPQ recheck");
return;
}
......
......@@ -827,25 +827,27 @@ void
ExecIndexMarkPos(IndexScanState *node)
{
EState *estate = node->ss.ps.state;
EPQState *epqstate = estate->es_epq_active;
if (estate->es_epqTupleSlot != NULL)
if (epqstate != NULL)
{
/*
* We are inside an EvalPlanQual recheck. If a test tuple exists for
* this relation, then we shouldn't access the index at all. We would
* instead need to save, and later restore, the state of the
* es_epqScanDone flag, so that re-fetching the test tuple is
* possible. However, given the assumption that no caller sets a mark
* at the start of the scan, we can only get here with es_epqScanDone
* relsubs_done flag, so that re-fetching the test tuple is possible.
* However, given the assumption that no caller sets a mark at the
* start of the scan, we can only get here with relsubs_done[i]
* already set, and so no state need be saved.
*/
Index scanrelid = ((Scan *) node->ss.ps.plan)->scanrelid;
Assert(scanrelid > 0);
if (estate->es_epqTupleSlot[scanrelid - 1] != NULL)
if (epqstate->relsubs_slot[scanrelid - 1] != NULL ||
epqstate->relsubs_rowmark[scanrelid - 1] != NULL)
{
/* Verify the claim above */
if (!estate->es_epqScanDone[scanrelid - 1])
if (!epqstate->relsubs_done[scanrelid - 1])
elog(ERROR, "unexpected ExecIndexMarkPos call in EPQ recheck");
return;
}
......@@ -862,17 +864,19 @@ void
ExecIndexRestrPos(IndexScanState *node)
{
EState *estate = node->ss.ps.state;
EPQState *epqstate = estate->es_epq_active;
if (estate->es_epqTupleSlot != NULL)
if (estate->es_epq_active != NULL)
{
/* See comments in ExecIndexMarkPos */
Index scanrelid = ((Scan *) node->ss.ps.plan)->scanrelid;
Assert(scanrelid > 0);
if (estate->es_epqTupleSlot[scanrelid - 1] != NULL)
if (epqstate->relsubs_slot[scanrelid - 1] != NULL ||
epqstate->relsubs_rowmark[scanrelid - 1] != NULL)
{
/* Verify the claim above */
if (!estate->es_epqScanDone[scanrelid - 1])
if (!epqstate->relsubs_done[scanrelid - 1])
elog(ERROR, "unexpected ExecIndexRestrPos call in EPQ recheck");
return;
}
......
......@@ -64,12 +64,6 @@ lnext:
/* We don't need EvalPlanQual unless we get updated tuple version(s) */
epq_needed = false;
/*
* Initialize EPQ machinery. Need to do that early because source tuples
* are stored in slots initialized therein.
*/
EvalPlanQualBegin(&node->lr_epqstate, estate);
/*
* Attempt to lock the source tuple(s). (Note we only have locking
* rowmarks in lr_arowMarks.)
......@@ -259,12 +253,14 @@ lnext:
*/
if (epq_needed)
{
/* Initialize EPQ machinery */
EvalPlanQualBegin(&node->lr_epqstate);
/*
* Now fetch any non-locked source rows --- the EPQ logic knows how to
* do that.
* To fetch non-locked source rows the EPQ logic needs to access junk
* columns from the tuple being tested.
*/
EvalPlanQualSetSlot(&node->lr_epqstate, slot);
EvalPlanQualFetchRowMarks(&node->lr_epqstate);
/*
* And finally we can re-evaluate the tuple.
......
......@@ -828,7 +828,7 @@ ldelete:;
* Already know that we're going to need to do EPQ, so
* fetch tuple directly into the right slot.
*/
EvalPlanQualBegin(epqstate, estate);
EvalPlanQualBegin(epqstate);
inputslot = EvalPlanQualSlot(epqstate, resultRelationDesc,
resultRelInfo->ri_RangeTableIndex);
......@@ -843,8 +843,7 @@ ldelete:;
{
case TM_Ok:
Assert(tmfd.traversed);
epqslot = EvalPlanQual(estate,
epqstate,
epqslot = EvalPlanQual(epqstate,
resultRelationDesc,
resultRelInfo->ri_RangeTableIndex,
inputslot);
......@@ -1370,7 +1369,6 @@ lreplace:;
* Already know that we're going to need to do EPQ, so
* fetch tuple directly into the right slot.
*/
EvalPlanQualBegin(epqstate, estate);
inputslot = EvalPlanQualSlot(epqstate, resultRelationDesc,
resultRelInfo->ri_RangeTableIndex);
......@@ -1386,8 +1384,7 @@ lreplace:;
case TM_Ok:
Assert(tmfd.traversed);
epqslot = EvalPlanQual(estate,
epqstate,
epqslot = EvalPlanQual(epqstate,
resultRelationDesc,
resultRelInfo->ri_RangeTableIndex,
inputslot);
......@@ -2013,7 +2010,7 @@ ExecModifyTable(PlanState *pstate)
* case it is within a CTE subplan. Hence this test must be here, not in
* ExecInitModifyTable.)
*/
if (estate->es_epqTupleSlot != NULL)
if (estate->es_epq_active != NULL)
elog(ERROR, "ModifyTable should not be called during EvalPlanQual");
/*
......
......@@ -198,9 +198,9 @@ extern void ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
extern LockTupleMode ExecUpdateLockMode(EState *estate, ResultRelInfo *relinfo);
extern ExecRowMark *ExecFindRowMark(EState *estate, Index rti, bool missing_ok);
extern ExecAuxRowMark *ExecBuildAuxRowMark(ExecRowMark *erm, List *targetlist);
extern TupleTableSlot *EvalPlanQual(EState *estate, EPQState *epqstate,
Relation relation, Index rti, TupleTableSlot *testslot);
extern void EvalPlanQualInit(EPQState *epqstate, EState *estate,
extern TupleTableSlot *EvalPlanQual(EPQState *epqstate, Relation relation,
Index rti, TupleTableSlot *testslot);
extern void EvalPlanQualInit(EPQState *epqstate, EState *parentestate,
Plan *subplan, List *auxrowmarks, int epqParam);
extern void EvalPlanQualSetPlan(EPQState *epqstate,
Plan *subplan, List *auxrowmarks);
......@@ -208,9 +208,9 @@ extern TupleTableSlot *EvalPlanQualSlot(EPQState *epqstate,
Relation relation, Index rti);
#define EvalPlanQualSetSlot(epqstate, slot) ((epqstate)->origslot = (slot))
extern void EvalPlanQualFetchRowMarks(EPQState *epqstate);
extern bool EvalPlanQualFetchRowMark(EPQState *epqstate, Index rti, TupleTableSlot *slot);
extern TupleTableSlot *EvalPlanQualNext(EPQState *epqstate);
extern void EvalPlanQualBegin(EPQState *epqstate, EState *parentestate);
extern void EvalPlanQualBegin(EPQState *epqstate);
extern void EvalPlanQualEnd(EPQState *epqstate);
/*
......
......@@ -571,17 +571,12 @@ typedef struct EState
ExprContext *es_per_tuple_exprcontext;
/*
* These fields are for re-evaluating plan quals when an updated tuple is
* substituted in READ COMMITTED mode. es_epqTupleSlot[] contains test
* tuples that scan plan nodes should return instead of whatever they'd
* normally return, or an empty slot if there is nothing to return; if
* es_epqTupleSlot[] is not NULL if a particular array entry is valid; and
* es_epqScanDone[] is state to remember if the tuple has been returned
* already. Arrays are of size es_range_table_size and are indexed by
* scan node scanrelid - 1.
* If not NULL, this is an EPQState's EState. This is a field in EState
* both to allow EvalPlanQual aware executor nodes to detect that they
* need to perform EPQ related work, and to provide necessary information
* to do so.
*/
TupleTableSlot **es_epqTupleSlot; /* array of EPQ substitute tuples */
bool *es_epqScanDone; /* true if EPQ tuple has been fetched */
struct EPQState *es_epq_active;
bool es_use_parallel_mode; /* can we use parallel workers? */
......@@ -1057,17 +1052,73 @@ typedef struct PlanState
/*
* EPQState is state for executing an EvalPlanQual recheck on a candidate
* tuple in ModifyTable or LockRows. The estate and planstate fields are
* NULL if inactive.
* tuples e.g. in ModifyTable or LockRows.
*
* To execute EPQ a separate EState is created (stored in ->recheckestate),
* which shares some resources, like the rangetable, with the main query's
* EState (stored in ->parentestate). The (sub-)tree of the plan that needs to
* be rechecked (in ->plan), is separately initialized (into
* ->recheckplanstate), but shares plan nodes with the corresponding nodes in
* the main query. The scan nodes in that separate executor tree are changed
* to return only the current tuple of interest for the respective
* table. Those tuples are either provided by the caller (using
* EvalPlanQualSlot), and/or found using the rowmark mechanism (non-locking
* rowmarks by the EPQ machinery itself, locking ones by the caller).
*
* While the plan to be checked may be changed using EvalPlanQualSetPlan() -
* e.g. so all source plans for a ModifyTable node can be processed - all such
* plans need to share the same EState.
*/
typedef struct EPQState
{
EState *estate; /* subsidiary EState */
PlanState *planstate; /* plan state tree ready to be executed */
TupleTableSlot *origslot; /* original output tuple to be rechecked */
/* Initialized at EvalPlanQualInit() time: */
EState *parentestate; /* main query's EState */
int epqParam; /* ID of Param to force scan node re-eval */
/*
* Tuples to be substituted by scan nodes. They need to set up, before
* calling EvalPlanQual()/EvalPlanQualNext(), into the slot returned by
* EvalPlanQualSlot(scanrelid). The array is indexed by scanrelid - 1.
*/
List *tuple_table; /* tuple table for relsubs_slot */
TupleTableSlot **relsubs_slot;
/*
* Initialized by EvalPlanQualInit(), may be changed later with
* EvalPlanQualSetPlan():
*/
Plan *plan; /* plan tree to be executed */
List *arowMarks; /* ExecAuxRowMarks (non-locking only) */
int epqParam; /* ID of Param to force scan node re-eval */
/*
* The original output tuple to be rechecked. Set by
* EvalPlanQualSetSlot(), before EvalPlanQualNext() or EvalPlanQual() may
* be called.
*/
TupleTableSlot *origslot;
/* Initialized or reset by EvalPlanQualBegin(): */
EState *recheckestate; /* EState for EPQ execution, see above */
/*
* Rowmarks that can be fetched on-demand using
* EvalPlanQualFetchRowMark(), indexed by scanrelid - 1. Only non-locking
* rowmarks.
*/
ExecAuxRowMark **relsubs_rowmark;
/*
* True if a relation's EPQ tuple has been fetched for relation, indexed
* by scanrelid - 1.
*/
bool *relsubs_done;
PlanState *recheckplanstate; /* EPQ specific exec nodes, for ->plan */
} EPQState;
......
......@@ -42,6 +42,16 @@ setup
CREATE TABLE another_parttbl1 PARTITION OF another_parttbl FOR VALUES IN (1);
CREATE TABLE another_parttbl2 PARTITION OF another_parttbl FOR VALUES IN (2);
INSERT INTO another_parttbl VALUES (1, 1, 1);
CREATE FUNCTION noisy_oper(p_comment text, p_a anynonarray, p_op text, p_b anynonarray)
RETURNS bool LANGUAGE plpgsql AS $$
DECLARE
r bool;
BEGIN
EXECUTE format('SELECT $1 %s $2', p_op) INTO r USING p_a, p_b;
RAISE NOTICE '%: % % % % %: %', p_comment, pg_typeof(p_a), p_a, p_op, pg_typeof(p_b), p_b, r;
RETURN r;
END;$$;
}
teardown
......@@ -53,6 +63,7 @@ teardown
DROP TABLE table_a, table_b, jointest;
DROP TABLE parttbl;
DROP TABLE another_parttbl;
DROP FUNCTION noisy_oper(text, anynonarray, text, anynonarray)
}
session "s1"
......@@ -62,6 +73,10 @@ step "wx1" { UPDATE accounts SET balance = balance - 200 WHERE accountid = 'chec
# wy1 then wy2 checks the case where quals pass then fail
step "wy1" { UPDATE accounts SET balance = balance + 500 WHERE accountid = 'checking' RETURNING balance; }
step "wxext1" { UPDATE accounts_ext SET balance = balance - 200 WHERE accountid = 'checking' RETURNING balance; }
step "tocds1" { UPDATE accounts SET accountid = 'cds' WHERE accountid = 'checking'; }
step "tocdsext1" { UPDATE accounts_ext SET accountid = 'cds' WHERE accountid = 'checking'; }
# d1 then wx1 checks that update can deal with the updated row vanishing
# wx2 then d1 checks that the delete affects the updated row
# wx2, wx2 then d1 checks that the delete checks the quals correctly (balance too high)
......@@ -89,7 +104,7 @@ step "writep2" { UPDATE p SET b = -b WHERE a = 1 AND c = 0; }
step "c1" { COMMIT; }
step "r1" { ROLLBACK; }
# these tests are meant to exercise EvalPlanQualFetchRowMarks,
# these tests are meant to exercise EvalPlanQualFetchRowMark,
# ie, handling non-locked tables in an EvalPlanQual recheck
step "partiallock" {
......@@ -98,8 +113,10 @@ step "partiallock" {
FOR UPDATE OF a1;
}
step "lockwithvalues" {
SELECT * FROM accounts a1, (values('checking'),('savings')) v(id)
WHERE a1.accountid = v.id
-- Reference rowmark column that differs in type from targetlist at some attno.
-- See CAHU7rYZo_C4ULsAx_LAj8az9zqgrD8WDd4hTegDTMM1LMqrBsg@mail.gmail.com
SELECT a1.*, v.id FROM accounts a1, (values('checking'::text, 'nan'::text),('savings', 'nan')) v(id, notnumeric)
WHERE a1.accountid = v.id AND v.notnumeric != 'einszwei'
FOR UPDATE OF a1;
}
step "partiallock_ext" {
......@@ -231,6 +248,20 @@ step "updwctefail" { WITH doup AS (UPDATE accounts SET balance = balance + 1100
step "delwcte" { WITH doup AS (UPDATE accounts SET balance = balance + 1100 WHERE accountid = 'checking' RETURNING *) DELETE FROM accounts a USING doup RETURNING *; }
step "delwctefail" { WITH doup AS (UPDATE accounts SET balance = balance + 1100 WHERE accountid = 'checking' RETURNING *, update_checking(999)) DELETE FROM accounts a USING doup RETURNING *; }
# Check that nested EPQ works correctly
step "wnested2" {
UPDATE accounts SET balance = balance - 1200
WHERE noisy_oper('upid', accountid, '=', 'checking')
AND noisy_oper('up', balance, '>', 200.0)
AND EXISTS (
SELECT accountid
FROM accounts_ext ae
WHERE noisy_oper('lock_id', ae.accountid, '=', accounts.accountid)
AND noisy_oper('lock_bal', ae.balance, '>', 200.0)
FOR UPDATE
);
}
step "c2" { COMMIT; }
step "r2" { ROLLBACK; }
......@@ -282,6 +313,15 @@ permutation "wx2" "d2" "d1" "r2" "c1" "read"
permutation "d1" "wx2" "c1" "c2" "read"
permutation "d1" "wx2" "r1" "c2" "read"
# Check that nested EPQ works correctly
permutation "wnested2" "c1" "c2" "read"
permutation "wx1" "wxext1" "wnested2" "c1" "c2" "read"
permutation "wx1" "wx1" "wxext1" "wnested2" "c1" "c2" "read"
permutation "wx1" "wx1" "wxext1" "wxext1" "wnested2" "c1" "c2" "read"
permutation "wx1" "wxext1" "wxext1" "wnested2" "c1" "c2" "read"
permutation "wx1" "tocds1" "wnested2" "c1" "c2" "read"
permutation "wx1" "tocdsext1" "wnested2" "c1" "c2" "read"
# test that an update to a self-modified row is ignored when
# previously updated by the same cid
permutation "wx1" "updwcte" "c1" "c2" "read"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment