Skip to content

[Bug]: Concurrent UNIQUE-key ODKU returns duplicate visible rows on a KEY-partitioned table #30084

Closed

Description

@LeftHandCold

Is there an existing issue for the same bug?

  • I have checked the existing issues.

This reports a query correctness failure after concurrent ODKU, separately from the resolved hidden-UNIQUE-index scan-performance issue #29197. Searches for ODKU, key_concurrent_conflicts, concurrent duplicate rows, and recent duplicate-key issues did not identify an open issue accurately tracking this observation. Related issues and attribution limits are listed below.

Branch Name

main

Commit ID

cd8d5abf9665a97d13e50b04840fb74b877fd339

Other Environment Information

  • Failing Main MOTR job: Run 38268423576 / Job 114990399929.
  • TKE Family weekly-shadow-1x2: one TP CN and two AP CNs. This test connects directly to the single TP CN service, not through Proxy or random AP routing; the uploaded routing proof verifies the TP service endpoint.
  • Namespace: mo-main-family-38268423576-1; TP pod: nightly-regression-dis-tp-cn-nvqjf.
  • Each CN: 14 CPUs, 55 GiB container memory, 16 GiB FileService memory cache. TN: 14 CPUs, 32 GiB.
  • Image: ccr.ccs.tencentyun.com/matrixone-dev/matrixone:commit-cd8d5abf9665a97d13e50b04840fb74b877fd339.
  • Verified image digest: sha256:d554cbb460dc128049b329fb0e1d5e2e9c35d9a559cb2986d732b37e3a970b04.
  • MOTR: 125b724ac7bddac2d8c634427ce20d7b3bef8f9a; nightly workflow: 27de5806d6a5f2ead8912199edd8ba54b601f491.
  • The cluster CR does not override transaction mode/isolation. The tested code defaults to pessimistic RC, not the retired optimistic-RC mode of [Bug]: Optimistic RC concurrent UPDATE exposes duplicate primary-key rows (test-only mode) #29401. An in-session mode dump was not preserved.
  • Important configured options: enable-sacrificing-freshness = 1, enable-cn-based-consistency = 0. See the freshness qualification below.
  • The final Kubernetes snapshot shows all nine pods Ready with zero restarts. This is not a claim that every resource or service was healthy throughout the test.

Actual Behavior

The case creates a 100,000-row KEY-partitioned table with an integer primary key, a separate integer UNIQUE key, and an integer payload. A preliminary EXPLAIN ANALYZE ODKU executes once and establishes exactly (1,10,1) for uk=10.

It then issues 20 increment operations using a pool of at most five workers. Each operation opens its own autocommit connection, executes one ODKU, and closes the connection. All 20 futures return successfully; the helper has no write retry loop.

After all futures finish, a single UNIQUE-key SELECT returns 18 rows. The bounded diagnostic preserves the first three, all (1,10,16). A later PK SELECT returns three rows, all (1,10,16).

The first failure was printed at 2026-10-12 20:27:49.998 UTC / 2026-10-13 04:27:49.998 Asia/Shanghai. The full case ran from approximately 20:27:43.358 to 20:27:51.039 UTC. The report records 7,588 ms for the case; the later 21:01:46 UTC suite summary is a reprint, not a second execution.

PASS: plain_fixture_100k
PASS: plain_no_full_validation_scan
PASS: plain_conflict_target
PASS: plain_no_insert
PASS: key_fixture_100k
PASS: key_no_full_validation_scan
PASS: key_conflict_target
PASS: key_no_insert
PASS: range_fixture_100k
PASS: range_no_full_validation_scan
PASS: range_conflict_target
PASS: range_no_insert
PASS: pk_only_control_selective
PASS: pk_only_control_result
FAIL: key_concurrent_conflicts :: actual=[(1, 10, 16), (1, 10, 16), (1, 10, 16)] ops=20/20 total=not_checked expected=[(1, 10, 21)] expected_total=100000 matches=18 omitted=15
PASS: key_unique_update_rejected
FAIL: key_rejection_atomic :: actual=[(1, 10, 16), (1, 10, 16), (1, 10, 16)] ops=20/20 total=not_checked expected=[(1, 10, 21)] expected_total=100000 matches=3 omitted=0 prior_key_concurrent_conflicts=failed
SUMMARY: 2 assertion(s) failed

The same output is preserved in the uploaded MOTR artifact, not only in console formatting:

Artifact: tke-family-weekly-shadow-1x2-motr-38268423576-1
Artifact ID: 11774401013
File: run-1791836581604421739/results.jsonl
Case: 14_issue_regression.issue_29197_odku_unique_scan
Fields: actual_output / diff

Why this is actual query-result evidence

  • The frozen scenario takes each result from a single run(...)[1] call. matches is len(rows) and actual is rows[:3]; it does not combine results from multiple queries or workers.
  • The frozen helper opens connections with autocommit=True and returns list(cur.fetchall()) from a fresh cursor per statement.
  • The database name includes the process ID and nanosecond timestamp, limiting ordinary cross-case fixture-name collisions.
  • future.result() must succeed before completed_operations is incremented. An operation exception would instead produce scenario_completed failure and bypass this assertion.
  • All three original hidden-index selective-scan checks passed; this failure is not another full-scan performance observation.

Expected Behavior

At any valid snapshot, selecting this primary/UNIQUE key should expose one logical row, not multiple copies. Once all successful commits are visible, the row should be exactly (1,10,21) and the table should still contain 100,000 rows.

The current fixture expects v=21 immediately after all worker acknowledgements. However, this cluster enables sacrificing freshness and disables CN-wide commit-timestamp consistency. The exact-version transaction-client contract explicitly allows reduced cross-session freshness. Therefore v=16 alone is not proof that five updates were lost; a follow-up synchronized read is required for that claim. Freshness does not make three or eighteen duplicate visible rows for a unique key a valid result.

Steps to Reproduce

Use the pinned existing MOTR case above against a disposable cluster matching the incident configuration. This is the observed failing nightly procedure, not a claim of deterministic isolated reproduction. Preserve the existing plain/KEY/RANGE controls and query ordering when replaying.

The essential KEY-table portion is:

CREATE TABLE odku_key (
    id INT PRIMARY KEY,
    uk INT UNIQUE,
    v INT
) PARTITION BY KEY(id) PARTITIONS 8;

INSERT INTO odku_key
SELECT result, result * 10, 0 FROM generate_series(1,100000) g;

-- EXPLAIN ANALYZE executes the statement and establishes v=1.
EXPLAIN ANALYZE
INSERT INTO odku_key VALUES (100099,10,999)
ON DUPLICATE KEY UPDATE v=v+1;

SELECT id,uk,v FROM odku_key WHERE uk=10;
-- One row: (1,10,1).

Then execute this statement 20 times using independent autocommit connections, at most five concurrent workers, and wait for every operation to succeed:

INSERT INTO odku_key VALUES (100099,10,999)
ON DUPLICATE KEY UPDATE v=v+1;

On the original observer connection, the existing case then executes:

SELECT id,uk,v FROM odku_key WHERE uk=10;
-- Incident: 18 rows; the first three are (1,10,16).

INSERT INTO odku_key VALUES (100099,10,999)
ON DUPLICATE KEY UPDATE uk=11,v=999;
-- Expected unsupported UNIQUE-key-member update rejection; rejected here.

SELECT id,uk,v FROM odku_key WHERE id=1;
-- Incident: three rows, all (1,10,16).

Additional information

Proven scope and unproven claims

Confirmed: the nightly SQL result contains duplicate visible keys after successful concurrent ODKUs. This is a correctness defect worth tracking independently of #29197's scan-performance fix.

Not established:

  • Whether duplicate rows were committed to persistent storage, introduced by CN/logtail visibility, or multiplied by the query/index execution path. Neither __mo_rowid nor the failing SELECT plans were captured.
  • Whether increments were actually lost. There is no synchronized follow-up read after the immediate failed checks.
  • The table's final total row count: total=not_checked is intentional short-circuit behavior once the returned tuples already mismatch. expected_total=100000 is not an observed count.
  • Whether all 18 UNIQUE-query rows equal (1,10,16); only the first three were preserved.
  • Whether the rejected UNIQUE-key update caused an additional atomicity violation. The PK read follows that statement, but the result was already invalid beforehand. Treat the two failed assertions as one incident, not two independently proven bugs.
  • Whether current Main HEAD, 4.2, or another table shape is affected. Only the tested Main SHA and KEY-table concurrent path are established here.
  • The introducing PR, or a new regression between adjacent commits. This investigation did not run a new isolated reproduction or counterfactual build.

Relationship to prior findings

Narrow next investigation

  1. Replay the pinned case/configuration and preserve all returned rows, __mo_rowid, SELECT plans, actual CN/session IDs, and per-operation success/transaction IDs on failure. Do not retry acknowledged writes.
  2. Capture UNIQUE, PK and full-scan projections in one controlled read snapshot; compare their row identities and multiplicities. Do not use an optimized COUNT(*) alone as the uniqueness oracle.
  3. Preserve immediate reads first, then perform a separate follow-up read after the workers' commits are known to be visible. This distinguishes reduced freshness from persistent wrong results without erasing the original observation.
  4. Trace conflict-key/physical-partition locking, snapshot refresh or RC retry, update delete/insert effects, and base/hidden-index logtail visibility using the same operation IDs. These are investigation candidates, not a proven root cause.
  5. Only after reproduction distinguish a recent code regression from an older timing-dependent defect, and select the smallest fix supported by that evidence.

Activity

  1. LeftHandCold commented on Oct 13, 2026

    @LeftHandCold
    CollaboratorAuthor

    Root cause reproduced; focused main fix submitted

    PR: #30112, head 5959373ccdddf0e7a6f7f03afaccc3f9166f3aa9.

    The investigation has progressed beyond the original nightly result: a real SQL reproduction now demonstrates a partition-selection defect in post-lock version validation, and a deterministic regression fails before the fix and passes afterward.

    The violated invariant

    After acquiring the lock on a resolved row, RC validation must inspect the physical partition containing that row's PK. It must not inspect the partition selected by the incoming INSERT row merely because that row occupies the original schema positions in the operator batch.

    For the existing case, incoming id=100099, uk=10 conflicts with existing id=1, uk=10. The row lock itself targets the resolved PK correctly. However, the lock input has the resolved PK at vector index 5 and no explicit partition column (-1). The old combinedTxnTable.PrimaryKeysMayBeModified forwards -1 to pruning. Its stored partition expression then reads the incoming ID from the projected batch. In the one-row KEY(8) fixture, existing id=1 is in p1 while incoming id=100099 maps to p7.

    The failing interleaving is:

    1. Writer B obtains its statement snapshot and resolves the existing row.
    2. Before B acquires the base-row lock, writer A updates the row and commits.
    3. B acquires the now-free lock. There is no active lock conflict to force an unconditional retry.
    4. The normal RC path waits for logtail and asks whether the locked PK changed after B's snapshot.
    5. The combined relation checks the wrong physical partition and returns false. B continues with its stale row image/RowID rather than restarting from A's committed version.

    This is why fixing the resolved lock key alone was insufficient: the subsequent version-check partition must use the same identity.

    Reproduction evidence

    I first replayed the frozen 100K-row MOTR case and bounded concurrency variants on the exact incident image. Those attempts passed; they did not reproduce the narrow interleaving and were not used to dismiss the nightly result.

    A separate SQL-only probe on that image then held a statement between snapshot acquisition and write locking while a competing statement committed. With one seed row (1,10,1) and two successful ODKUs:

    • Non-partitioned control: one row (1,10,3).
    • KEY and RANGE tables: two rows (1,10,2) with different __mo_rowid values.
    • The duplicate rows remained after mo_ctl(cn, synccommit, '') and subsequent reads.

    For the submitted regression, timing sleeps were replaced by the existing before-lock testing callback. No lock result is fabricated, no planner/storage result is mocked, and both writes go through the real frontend and TN. The competing writer commits before releasing the original writer's barrier.

    Unpatched source 21f19ae063:

    plain: PASS
    key:   FAIL expected [[1 10 3]], actual [[1 10 2] [1 10 2]]
    range: FAIL expected [[1 10 3]], actual [[1 10 2] [1 10 2]]
    non_pk_partition: PASS
    FAIL github.com/matrixorigin/matrixone/pkg/tests/issues 5.872s
    

    Patched source:

    plain: PASS
    key: PASS
    range: PASS
    non_pk_partition: PASS
    composite: PASS
    

    Each patched scenario checks the complete returned tuples through full-scan, PK and UNIQUE predicates after the commits have become visible. The corrected output is exactly one (1,10,3).

    This establishes committed duplicate-row behavior in the controlled reproduction. The original nightly artifact did not preserve RowIDs, so this is not a claim that its exact 18 UNIQUE rows and 3 PK rows were all independently proven to be physical duplicates. No introducing PR has been bisected.

    Fix scope and risk

    The patch changes partition selection only in PrimaryKeysMayBeModified:

    • If the partition expressions depend only on the same scalar primary key, rebind the existing pruning path to the resolved PK vector, respecting its RowID offset convention.
    • If that equivalence cannot be proven, inspect all existing physical partitions instead of making an unsafe pruning assumption.
    • Explicit partition-column callers, hidden-index arrays and PrimaryKeysMayBeUpserted are unchanged.
    • No new lock/retry algorithm, global state, protocol, configuration or persistent format is introduced.

    Common scalar-PK KEY/RANGE schemas retain pruning. Composite/non-PK-partition fallback can perform more version checks; that bounded correctness cost is explicitly retained instead of claiming zero performance impact everywhere. The patch prevents the demonstrated write error; it does not repair already duplicated rows.

    Completed validation

    • Normal disttae owning-package tests: PASS, 11.258s.
    • Deterministic real-SQL regression: PASS, all five scenarios, including two normal same-process repetitions.
    • Focused -race -count=3: PASS, all five scenarios in every repetition, 15.206s total.
    • Existing ODKU BVT: 170 checks passed, 8 existing ignored checks unchanged.
    • Existing resolved-target-lock BVT: 15 checks passed.
    • Both BVT cases and the frozen original MOTR scenario passed twice on the same instance; key_concurrent_conflicts and key_rejection_atomic passed.
    • go vet, prealloc analysis, formatting and whitespace checks passed.

    The globally skipped legacy hash_key_partition.sql case is not counted as executed coverage. No test expectation was weakened, and no extra large BVT was added.

    Latest main 35c8dd5854 currently has an independent EXPLAIN compilation failure, already tracked in #30108 / #30109. The before/after source builds therefore use the immediately preceding buildable main commit 21f19ae063; the relevant disttae, partition-pruning, lockop and INSERT-planner paths are unchanged in latest main. No EXPLAIN workaround is included in this PR. Local verification is complete; repository CI/integration with the upstream build repair is a separate pending gate.

  2. added a commit that references this issue on Oct 13, 2026
    b468f16
  3. added
    severity/s0Active / top priority for current sprint. Owner has committed to working on it now.
    and removed on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

kind/bugSomething isn't workingseverity/s0Active / top priority for current sprint. Owner has committed to working on it now.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions