Skip to content

fix(import): serialize comment marker-dedupe across concurrent imports#4

Merged
unbraind merged 3 commits into
mainfrom
feat/serialize-comment-import-lock
Jul 18, 2026
Merged

fix(import): serialize comment marker-dedupe across concurrent imports#4
unbraind merged 3 commits into
mainfrom
feat/serialize-comment-import-lock

Conversation

@unbraind

@unbraind unbraind commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Problem

The native comment sync in pm github import --comments-mode annotations performed a read-markers-then-append sequence that is not atomic across two concurrent import processes targeting the same workspace: both can observe a GitHub comment id as absent and append it twice. Individual writes go through the pm CLI (which locks per mutation), but the check-then-act spans multiple mutations. Previously documented as a known limitation on syncGithubCommentsToAnnotations (CodeRabbit, PR #33).

Solution

A cross-process mutex serializes the marker-dedupe critical section, scoped per item so unrelated items in concurrent imports still sync in parallel:

  • acquireImportLock() — atomic fs.openSync(path, "wx") (O_EXCL) lockfile under the workspace runtime dir, JSON payload (pid, timestamp) shaped like the CLI's own locks so pm gc can sweep strays.
  • Stale-lock handling: a held lock is broken with a stderr warning when older than the TTL (default 5 min) or its recorded owner PID is dead.
  • syncGithubCommentsToAnnotations docstring updated: the limitation is lifted.

Tests

  • 10 new test cases in test/import-lock.test.ts, including a two-process parallel-import race test (via test/helpers/comment-sync-child.mjs) asserting zero duplicate markers.
  • Full suite: 122/122 green; build + changelog:check pass.

pm item

🤖 Generated with pi (K3) under Claude orchestration


Summary by cubic

Stops duplicate comments during concurrent imports by serializing the comment marker dedupe in pm github import --comments-mode annotations. Adds a per-item lockfile so the read-and-append step is atomic across processes; satisfies pm-github-503u.

  • Bug Fixes
    • Added a cross-process per-item lockfile in workspace locks/ via fs.openSync("wx"); payload mirrors the pm CLI lock format and includes a per-acquisition token. Staleness is liveness-first: break only on dead or recycled owner PIDs; TTL (5 minutes) applies only to unparseable payloads; never age-break a live owner.
    • Stale-break is now atomic via an O_EXCL <lock>.break sidecar election; the single winner re-verifies then unlinks, and crashed breaker sidecars age out (10s) and are cleaned.
    • Jittered backoff waits up to 30s; on timeout, skips that item's sync with a warning (never runs unlocked). If the lock mechanism is unavailable, degrades to unlocked with a warning. Cleans partial lockfiles on write failure and uses token-checked release to avoid unlinking a successor’s lock.
    • Lock scope is per item so unrelated items still sync in parallel; syncGithubCommentsToAnnotations now uses this lock to prevent duplicates. Tests cover live-owner-never-age-broken, token-checked release, recycled-PID handling, partial-lockfile cleanup, breaker election (in-progress and aged-out), and a two-process race test with a start barrier asserting zero duplicate markers.

Written for commit 7046446. Summary will update on new commits.

Review in cubic

The native comment sync's read-markers-then-append sequence was not
atomic across two concurrent 'pm github import' processes on the same
workspace: both could observe a GitHub comment id as absent and append
it twice (pm-github-503u, CodeRabbit PR #33).

Add acquireImportLock(): a cross-process per-item lockfile in the
workspace locks/ dir (fs.openSync 'wx', payload mirroring the pm CLI's
lock convention so pm gc can sweep it). Stale locks (older than the 5
min TTL, or dead owner PID) are broken with a stderr warning; live
contention waits with jittered backoff up to 30s, then the item's
comment sync is skipped with a warning (never run unlocked); lock
mechanism failures degrade to the pre-lock behavior with a warning.

Per-item scope keeps unrelated issues in concurrent imports syncing in
parallel. Tests: 10 new cases incl. a two-process parallel-import race
test asserting zero duplicate markers. Full suite 122/122 green.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @unbraind, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds cross-process per-item lockfiles around GitHub comment marker synchronization, handles stale locks and contention, and adds unit, in-process, and cross-process concurrency tests. Task records and the changelog document the completed fix.

Changes

Comment synchronization locking

Layer / File(s) Summary
Lock acquisition subsystem
index.ts
Adds lock path helpers, lock payload types, acquisition outcomes, atomic lock creation, stale-lock recovery, waiting, and release behavior.
Locked marker synchronization
index.ts
Serializes marker reads and comment appends, skips on prolonged contention, and preserves unlocked degraded-mode behavior.
Concurrency validation and project records
test/import-lock.test.ts, test/helpers/comment-sync-child.mjs, .agents/pm/..., CHANGELOG.md
Tests lock lifecycle, stale locks, parallel in-process and cross-process imports, idempotent reruns, and records the completed change.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ImportProcess
  participant syncGithubCommentsToAnnotations
  participant acquireImportLock
  participant PmComments
  ImportProcess->>syncGithubCommentsToAnnotations: import GitHub comments
  syncGithubCommentsToAnnotations->>acquireImportLock: acquire per-item lock
  acquireImportLock-->>syncGithubCommentsToAnnotations: acquired or contended
  syncGithubCommentsToAnnotations->>PmComments: read markers and append missing comments
  syncGithubCommentsToAnnotations-->>ImportProcess: return added and skipped counts
Loading

Possibly related PRs

  • unbraind/pm-github#33: Adds the marker-based GitHub comment synchronization flow extended by this change.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: serializing comment marker dedupe during concurrent imports.
Description check ✅ Passed The description is directly related to the fix and explains the problem, solution, and tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/serialize-comment-import-lock

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces cross-process serialization for the comment marker read-and-append critical section during GitHub imports, resolving a race condition where concurrent imports could duplicate comments. It implements a robust lockfile mechanism with stale-lock breaking, jittered backoff, and comprehensive tests. Feedback on the implementation highlights a potential issue where a failure during lockfile writing or closing could leave an orphaned lockfile on disk, and suggests cleaning up the file in the catch block to prevent blocking other processes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread index.ts
Comment on lines +907 to +913
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
// Ignore close errors on the failure path.
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If fs.writeFileSync or fs.closeSync throws an error after the lockfile has been successfully created via fs.openSync(lockPath, "wx"), the empty or partially written lockfile will be left on disk. Other concurrent processes will see this file, fail to parse it, and will be blocked from acquiring the lock until the 5-minute TTL expires.

To prevent this, we should clean up and delete the lockfile in the catch block if we were the ones who successfully opened/created it (i.e., when fd is defined).

      if (fd !== undefined) {
        try {
          fs.closeSync(fd);
        } catch {
          // Ignore close errors on the failure path.
        }
        try {
          fs.unlinkSync(lockPath);
        } catch {
          // Ignore unlink errors on the failure path.
        }
      }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Valid catch — fixed in df153ba: on the failure path after a successful O_EXCL create (fd defined), the partial lockfile is now unlinked (best-effort) so contenders are not blocked until the TTL. Covered indirectly by the existing unparseable-payload tests; the cleanup keeps that path rare.

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR serializes native GitHub comment marker dedupe during concurrent imports. The main changes are:

  • Adds a per-item workspace lockfile around the read-markers-then-append comment sync path.
  • Adds stale-lock handling with live-owner checks, breaker sidecars, and token-checked release.
  • Updates the comment-sync documentation, task metadata, and changelog entry.
  • Adds unit coverage and a child-process race test for concurrent comment imports.

Confidence Score: 5/5

Safe to merge with low risk.

The locking path addresses the concurrent duplicate-marker race and includes safeguards for live owners, stale breakers, and token-checked release. Tests cover the main lock edge cases and the cross-process import scenario.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • I reviewed the general-contract-validation-proof and confirmed it documents the test run for the two-process duplicate-marker race test.
  • I opened the evidence log to verify the command, cwd, UTC timestamps, full test output, and exit code reported during the run.
  • I confirmed the two-process duplicate-marker race test passed and recorded the duration as 1044.363663 milliseconds.
  • I noted the evidence log artifact is available for reviewer inspection and linked to the general-contract-validation-proof.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
index.ts Adds a per-item cross-process lock around native GitHub comment marker dedupe, with stale-lock handling, token-checked release, and contention fallback.
test/import-lock.test.ts Adds unit and integration coverage for lock path resolution, acquisition/release, stale breaking, breaker sidecars, and concurrent sync behavior.
test/helpers/comment-sync-child.mjs Adds a child-process harness used by the cross-process comment-sync race test.
CHANGELOG.md Documents the unreleased concurrent import marker-dedupe serialization fix.
.agents/pm/tasks/pm-github-503u.toon Closes the tracked task and records implementation notes for the lock-based fix.
.agents/pm/history/pm-github-503u.jsonl Records task claim, progress, notes, and closure history for the completed fix.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant A as Import process A
participant L as Workspace lockfile
participant P as pm comments SDK
participant B as Import process B
A->>L: acquireImportLock for item
L-->>A: acquired with token
B->>L: acquireImportLock for item
L-->>B: lock exists, wait while owner live
A->>P: read existing markers
A->>P: append missing marked comments
A->>L: release when token matches
B->>L: retry acquire
L-->>B: acquired
B->>P: read markers including A appends
B->>L: release when token matches
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant A as Import process A
participant L as Workspace lockfile
participant P as pm comments SDK
participant B as Import process B
A->>L: acquireImportLock for item
L-->>A: acquired with token
B->>L: acquireImportLock for item
L-->>B: lock exists, wait while owner live
A->>P: read existing markers
A->>P: append missing marked comments
A->>L: release when token matches
B->>L: retry acquire
L-->>B: acquired
B->>P: read markers including A appends
B->>L: release when token matches
Loading

Reviews (5): Last reviewed commit: "fix(lock): breaker election makes stale-..." | Re-trigger Greptile

Comment thread index.ts Outdated
…; clean up partial lockfiles

Review feedback from gemini-code-assist and greptile on PR #4:
- gemini (index.ts catch path): a write/close failure after O_EXCL create
  left an empty/partial lockfile blocking contenders until TTL — now
  unlinked on the failure path.
- greptile P1: TTL-only staleness could break a live slow holder mid-append,
  and release() blindly unlinked whatever was at lockPath (possibly a
  successor's lock), reopening the duplicate-marker race. Staleness is now
  liveness-first (dead owner PID, or own-PID leftovers not held in-process;
  age applies only to unparseable payloads), and release() is token-checked
  via a per-acquisition UUID in the payload.
- In-process concurrent holders share our PID: a module-level held-locks set
  distinguishes legitimate in-process contention from recycled-PID leftovers.

Tests: 124/124 (new: live-owner-never-age-broken, token-checked release,
recycled-own-pid; updated payload/header expectations).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@index.ts`:
- Around line 824-847: Update importLockStaleReason so a lock with a
verified-live owner is not marked stale solely because its age exceeds ttlMs;
either renew the lease while held or evaluate owner liveness before applying the
TTL expiration. Preserve stale detection for dead owners and locks without a
verifiable live owner, and adjust the TTL test accordingly.
- Around line 920-938: Update the stale-lock handling around
importLockStaleReason and unlinkSync to use an ownership-safe atomic takeover
protocol: revalidate that the lock is still the same file inspected before
removing it, and perform replacement atomically so a contender cannot delete a
newer lock or acquire ownership concurrently. Preserve the existing retry
behavior when the lock changes or disappears.
- Around line 884-918: Update the lock acquisition try/catch around fs.openSync
in the lock helper to track whether the lock file was successfully created, and
unlink lockPath on any subsequent failure before returning the degraded status.
Keep descriptor cleanup intact and ensure cleanup is best-effort without masking
the original error; do not remove locks for failures that occur before file
creation.

In `@test/import-lock.test.ts`:
- Around line 304-340: Add a synchronization barrier to the two-child flow in
the test “two concurrent processes importing the same comments never duplicate
(pm-github-503u)” so both child processes report readiness before either begins
the import, then have the parent release them together. Update the child
invocation/CHILD_SCRIPT coordination while preserving the existing result
parsing and assertions, ensuring the test reliably creates lock contention
rather than merely launching processes near each other.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 639af799-4dfd-4d3f-999f-2899825b495f

📥 Commits

Reviewing files that changed from the base of the PR and between 6c5edab and 2705453.

📒 Files selected for processing (6)
  • .agents/pm/history/pm-github-503u.jsonl
  • .agents/pm/tasks/pm-github-503u.toon
  • CHANGELOG.md
  • index.ts
  • test/helpers/comment-sync-child.mjs
  • test/import-lock.test.ts

Comment thread index.ts Outdated
Comment thread index.ts
Comment thread index.ts Outdated
Comment thread test/import-lock.test.ts Outdated
@unbraind

Copy link
Copy Markdown
Owner Author

@greptileai @coderabbitai full review

Pushed df153ba addressing the round-1 findings: liveness-first staleness (a live owner's lock is never age-broken), token-checked release (per-acquisition UUID), partial-lockfile cleanup on the create-failure path, and an in-process held-locks set so same-PID contention is not mistaken for a recycled PID. 124/124 tests green incl. three new regression tests. Please re-review.

(Sourcery: acknowledged your weekly diff limit notice — nothing actionable this round.)

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

@unbraind, starting a full re-review of the updated PR, including the new lock-regression coverage.

✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 51 minutes.

Comment thread index.ts Outdated
…n race test

CodeRabbit round-2 feedback on PR #4:
- Stale locks are now removed under an O_EXCL sidecar election
  (<lock>.break): only the single winner may unlink, and it re-verifies
  staleness under the mutex, so two concurrent breakers can never
  double-break or unlink a fresh replacement lock (the double-acquire
  scenario). Crashed breakers age out via a 10s sidecar TTL.
- Cross-process race test now uses a start-barrier file so both children
  enter the sync concurrently by construction.

Tests: 126/126 (new: election-in-progress blocks second breaker;
aged-out sidecar cleared then stale lock broken).
@unbraind

Copy link
Copy Markdown
Owner Author

@greptileai @coderabbitai full review

Round 2 pushed (7046446): stale-break is now atomic via an O_EXCL breaker-election sidecar with staleness re-verified under the mutex (kills the double-break/unlink-replacement race), and the cross-process race test uses a start barrier. 126/126 tests green. Please re-review.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

@unbraind, starting a full re-review of the updated concurrency-lock implementation and its regression coverage.

✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 44 minutes.

@unbraind
unbraind merged commit 368f907 into main Jul 18, 2026
6 checks passed
@unbraind
unbraind deleted the feat/serialize-comment-import-lock branch July 18, 2026 10:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant