Skip to content

fix(incrservice): avoid service lock during cache I/O - #27597

Merged
XuPeng-SH merged 4 commits into
matrixorigin:mainfrom
aptend:fix/incrservice-no-io-under-lock
Aug 26, 2026
Merged

fix(incrservice): avoid service lock during cache I/O#27597
XuPeng-SH merged 4 commits into
matrixorigin:mainfrom
aptend:fix/incrservice-no-io-under-lock

Conversation

@aptend

@aptend aptend commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • API-change
  • BUG
  • Improvement
  • Documentation
  • Feature
  • Test and CI
  • Code Refactoring

Which issue(s) this PR fixes:

issue #27588

What this PR does / why we need it:

CurrentValue cold-cache construction held the service-wide mutex while reading committed columns and waiting for the first auto-increment range allocation. A slow fileservice/DiskCache operation could therefore block unrelated auto-increment work and DDL on the same CN, which amplified the storage stall observed in #27588.

This change:

  • reuses the existing generation-fenced committed-cache builder, keeping GetColumns, cache construction, and allocator/store I/O outside service.mu;
  • preserves the any-epoch fast path required by CurrentValue, while returning generation invalidation and store errors to the caller without an internal retry loop;
  • treats a committed delete as a generation boundary even when the cache is still being built, so an old builder cannot revive a dropped table;
  • records the delete tombstone even when the deleting service never published the table cache, allowing lazy metadata cleanup to proceed;
  • removes deleted caches from shared state under the lock and retires them after unlocking, because retirement may wait for a column lock held across allocator/store I/O.

Regression coverage blocks both GetColumns and initial allocation to prove unrelated deletes can still acquire the service lock. It also covers Delete/Reload/Close invalidation, tombstone cleanup from another service, blocked cache retirement, and propagation of store retry errors without looping.

Validation on 1312b142e4e9ee9bb4a4c172c804a789f74b9281 rebased onto origin/main 4343cb17f74aae790e7619a956c1d9bfc8997e84:

  • GOWORK=off go list -mod=readonly ./pkg/incrservice: PASS
  • controlled CGo pkg/incrservice full test: PASS
  • controlled CGo pkg/incrservice full -race test: PASS
  • 13 directly affected tests, each with adaptive -race -count=100: PASS
  • GOWORK=off go build -mod=readonly ./pkg/incrservice: PASS
  • GOWORK=off go vet -mod=readonly ./pkg/incrservice: PASS
  • git diff --check origin/main...HEAD: PASS

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main direction is correct: CurrentValue no longer performs GetColumns or initial allocation while holding service.mu, committed Delete now invalidates an in-flight generation, and Delete retirement is moved after the lock. However, the current head (68afae1b9172) still has three concurrency closure gaps, including one regression that can amplify the same slow-storage incident this PR is intended to contain.

  1. [P1] Concurrent cold CurrentValue calls are not singleflight and each performs store I/O plus a persistent range allocation. acquireCommittedTableCache misses under service.mu and every caller independently enters getCommittedTableCacheForEpoch. generationBuilds is only an invalidation/publication fence; it does not give same-generation waiters one shared builder. All callers can therefore pass the post-GetColumns table check before the first newTableCache finishes, and every newTableCache performs its own initial preAllocate. Only one cache is eventually published, but all loser allocations have already advanced the persistent AUTO_INCREMENT offset and their ranges are abandoned. A deterministic 8-caller barrier test on this head observed exactly 8 GetColumns calls and 8 Allocate calls. Thus a cold burst turns one slow fileservice/store operation into N slow operations and can skip N allocation ranges, which is a serious regression from the old serialized path. Please add a per-table/per-generation in-flight build record so same-generation callers share one build/result while retaining independent waiter cancellation. Delete, Reload, and Close must invalidate/wake all waiters; a failed or canceled owner must not leave a stuck generation, and a later call must be able to rebuild.

  2. [P1] The any-epoch CurrentValue contract is only implemented in the first fast-path check and races with epoch-aware publication. The function can observe no cache, unlock, and then call getCommittedTableCacheForEpoch(..., 0, nil). If an InsertValues/GetLastAllocateTS builder publishes epoch 8 in that gap, the delegated function sees current.epoch() > 0 and returns TxnNeedRetryWithDefChanged, even though CurrentValue explicitly accepts a published cache of any epoch and no definition changed. I reproduced this deterministically by blocking the cold CurrentValue in GetColumns, publishing epoch 8, and then releasing it; the epoch-8 cache remained valid but CurrentValue returned the retry error. Please make any-epoch a first-class mode inside the generation-fenced acquisition, including every recheck before and after construction, and add this interleaving as a regression.

  3. [P1] The service-wide lock can still wait on cache lifecycle work in the CREATE transaction-close path. txnClosed calls handleCreatesLocked while holding service.mu, and that helper still invokes previous.retire(), tc.commit(), or tc.retire() under the lock. tableCache.retire/commit take table and column locks; a column can hold its lock across allocator/store I/O. A deterministic rollback test with a blocked retire showed an unrelated Reload remaining blocked on service.mu until retirement was released, which is the same global lock-amplification failure mode fixed for Delete. Please mutate/remove the relevant map entries under service.mu, collect commit/retire actions, and execute all lifecycle calls after unlocking. Cover committed and aborted CREATE, created-reset replacement, blocked commit/retire, and unrelated table operations.

Unhappy-path audit: Q1 store errors and caller cancellation are returned and generation counters are cleaned up; Q2 stale publication is generation-fenced, but loser side effects are not owned by a single build (finding 1); Q3 Delete/Reload/Close invalidation is substantially improved, but any-epoch reuse and CREATE close/retire still have uncovered interleavings (findings 2-3).

Validation: the unmodified pkg/incrservice suite and full -race suite pass. The three deterministic white-box interleavings above also ran under -race -count=20 after making the diagnostic store return independent column snapshots. git diff --check passes. The temporary diagnostic tests were removed from the worktree after verification.

@gouhongshen gouhongshen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex automated review

Reviewed head 68afae1 against origin/main and independently verified the change from first principles. Direction is correct: CurrentValue no longer builds caches under service.mu, committed deletes invalidate in-flight builders and always leave tombstones, and Delete retirement moved after unlock. I built libmo in this worktree and ran the full pkg/incrservice suite plus -race -count=5 on all new/affected tests: all pass.

The existing CHANGES_REQUESTED review (XuPeng-SH) targets exactly this head and the author has not yet responded; I independently confirmed all three P1 findings from the code, so I do not re-raise them: (1) cold CurrentValue bursts are not singleflighted — acquireCommittedTableCache misses delegate independently, generationBuilds is only a counter, and each newTableCache runs its own preAllocate -> asyncAllocate -> allocator worker, persistently advancing the AUTO_INCREMENT offset before N-1 loser caches are closed and their ranges abandoned; (2) the any-epoch contract lives only in the fast path — the delegated epoch-0 builder's rechecks (service.go:812-820 and 866-874) return TxnNeedRetryWithDefChanged when an epoch>0 cache publishes during the build; (3) txnClosed -> handleCreatesLocked still executes previous.retire()/tc.commit()/tc.retire() under service.mu, and tableCache.commit/retire take column locks that allocateLocked holds across allocator/store I/O — the same lock-amplification mode fixed for Delete.

Areas I examined with no new issues: generation bookkeeping closes on every builder exit path (start/finish/bump, error and cancel paths, Close resets the maps); the unconditional destroyed tombstone is safe given sqlStore.Delete semantics (succeeds on empty delete and on a vanished account; table IDs are globally unreused) and actually fixes a pre-existing mo_increment_columns GC gap when DROP runs on a CN that never cached the table; retiring deleted caches after unlock preserves acquire/release lifecycle with no double-retire hazard; Close/builders WaitGroup discipline is sound; removing CurrentValue's internal retry loop is an explicitly documented tradeoff tied to real definition changes. Test updates strictly improve coverage — TestCreateOnOtherService now genuinely exercises the other service (was ss[0], the creator, before).

@aptend

aptend commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three P1 findings in e571eb8.

  1. Cold committed-cache construction is now singleflight per table/generation. Same-generation callers share one GetColumns/newTableCache/allocation result, waiter cancellation is independent, owner failure/cancellation wakes all waiters, and a later caller can rebuild. Reload/Delete/Close invalidate the in-flight record and wake waiters.
  2. CurrentValue now carries an explicit any-epoch mode through every locked recheck. A higher explicit epoch can supersede an older blocked build; both owner and attached explicit waiters translate the internal superseded result to ErrTxnNeedRetryWithDefChanged.
  3. CREATE transaction-close commit/retire work is collected under service.mu and executed after unlock. A per-table pending-commit fence prevents new same-table users from observing the still-transactional cache, while unrelated tables continue immediately. All lifecycle actions are registered before unlock so Close cannot race WaitGroup.Add or close allocator/store state before those actions finish.

Added deterministic coverage for the 8-caller cold burst (one GetColumns and one Allocate), independent waiter cancellation, failed/canceled owner rebuilds, any-epoch publication, Delete/Reload/Close invalidation and wakeup, explicit-epoch owner+waiter supersession, committed/aborted/created-reset CREATE lifecycle paths, same-table fencing, unrelated-table progress, and Close waiting for pending commit.

Validation passed:

  • full pkg/incrservice suite
  • focused concurrency suite with -race -count=20
  • full pkg/incrservice suite with -race
  • go list, go build, go vet, and git diff --check
  • final first-principles self-review and independent read-only review (APPROVE; no P0/P1/P2 findings)

Please re-review when convenient.

@matrix-meow matrix-meow added size/XL Denotes a PR that changes [1000, 1999] lines and removed size/M Denotes a PR that changes [100,499] lines labels Aug 26, 2026

@XuPeng-SH XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 3eca65b against merge base a5877b6. The three blockers from my previous review are closed: committed-cache construction is singleflight per table/generation, any-epoch CurrentValue semantics are preserved across every recheck and epoch supersession, and CREATE commit/retire work no longer holds service.mu while same-table users are fenced until commit completes.

I traced publication, invalidation, waiter cancellation, owner failure, Delete/Reload/Close, transaction commit/rollback, replacement retirement, and WaitGroup shutdown. Q1: each build and lifecycle action has one effective completion/cleanup owner. Q2: waiters are released by build completion, invalidation, context cancellation, or pending-commit completion without making unrelated tables wait. Q3: a cold burst shares one GetColumns/cache construction/allocation, avoiding duplicate persistent range consumption. I found no remaining correctness, liveness, resource, or material performance blocker.

Evidence: exact-head CI is complete and green; diff check passes; the 10 focused concurrency/lifecycle regressions were selected explicitly and passed locally for 5 consecutive runs with matching CGo artifacts. The author race evidence remains semantically valid because the final commit only replaces the internal sentinel construction for static-check compliance.

@mergify mergify Bot added the queued label Aug 26, 2026
@mergify

mergify Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Merge Queue Status

  • Entered queue2026-08-26 08:32 UTC · Rule: main · triggered by rule Automatic queue on approval for main
  • 🟠 Checks running · in-place
  • 🚫 Left the queue2026-08-26 08:38 UTC · at e06b601a4c8020f3015ca336584422cc3f4350c7

This pull request spent 6 minutes 8 seconds in the queue, with no time running CI.

Waiting for
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone CI / UT Test on Ubuntu/x86
    • check-skipped = Matrixone CI / UT Test on Ubuntu/x86
    • check-success = Matrixone CI / UT Test on Ubuntu/x86
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone CI / SCA Test on Linux/arm64
    • check-skipped = Matrixone CI / SCA Test on Linux/arm64
    • check-success = Matrixone CI / SCA Test on Linux/arm64
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Utils CI / Coverage
    • check-skipped = Matrixone Utils CI / Coverage
    • check-success = Matrixone Utils CI / Coverage
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
    • check-skipped = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
    • check-success = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
  • any of: [🛡 GitHub branch protection]
    • check-neutral = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)
    • check-skipped = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)
    • check-success = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)
All conditions
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone CI / UT Test on Ubuntu/x86
    • check-skipped = Matrixone CI / UT Test on Ubuntu/x86
    • check-success = Matrixone CI / UT Test on Ubuntu/x86
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone CI / SCA Test on Linux/arm64
    • check-skipped = Matrixone CI / SCA Test on Linux/arm64
    • check-success = Matrixone CI / SCA Test on Linux/arm64
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
    • check-skipped = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
    • check-success = Matrixone Compose CI / multi cn e2e bvt test docker compose(PROXY)
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Utils CI / Coverage
    • check-skipped = Matrixone Utils CI / Coverage
    • check-success = Matrixone Utils CI / Coverage
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
    • check-skipped = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
    • check-success = Matrixone UT Coverage / UT Coverage on Ubuntu/x86
  • any of [🛡 GitHub branch protection]:
    • check-neutral = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)
    • check-skipped = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)
    • check-success = Matrixone Standlone CI / multi CN e2e BVT Test on Linux/x64(COMPOSE, PESSIMISTIC)
  • #review-threads-unresolved = 0 [🛡 GitHub branch protection]
  • github-review-approved [🛡 GitHub branch protection]

Reason

Pull request #27597 has been dequeued

Pull request from fork cannot be queued. This pull request comes from a fork, and Mergify needs the author's permission to update its branch.

The author needs to enable "Allow edits from maintainers" on this pull request.

Hint

You should look at the reason for the failure and decide if the pull request needs to be fixed or if you want to requeue it.
If you do update this pull request, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a @mergifyio queue comment.

Tick the box to put this pull request back in the merge queue (same as @mergifyio queue).

  • Requeue this pull request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dequeued kind/bug Something isn't working size/XL Denotes a PR that changes [1000, 1999] lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants