Files
gitmost/apps/server/src/integrations/git-sync/git-sync.constants.ts
claude code agent 227 b7e5cb6970 fix(git-sync): push 503 starvation + concurrent-edit marker leak/silent loss
Bug #1 (push 503 starvation): an external receive-pack that briefly overlapped
a poll cycle immediately 503'd because the per-space single-writer lock was
held. Add a BOUNDED retry-acquire on the PUSH path only (SpaceLockService
.withSpaceLock acquireRetry: capped exponential backoff up to ~5s); a transient
overlap now waits and succeeds, a genuinely stuck cycle still 503s after the
bound. The poll cycle passes no retry (immediate skip). Push result stays
deterministic: the receive-pack only runs once the lock is held, so a 503 never
leaves a half-applied ref.

Bug #2 (concurrent-edit marker leak + silent same-block loss):
- Marker leak (a): the push UPDATE path stripped markers for the body sent to
  Docmost but left raw <<<<<<</>>>>>>> committed on the published `main` vault
  forever (autoMergeConflicts ON). Now the cleaned body is written back to the
  vault file + recorded in writtenBack so runPush commits it on `main` and the
  vault converges to clean bytes.
- Marker leak (b): pin merge.conflictStyle=merge in ensureRepo and teach
  stripConflictMarkers/hasConflictMarkers about the diff3 `|||||||` base section
  (drop the marker AND the stale base region) so diff3/zdiff3 conflicts can
  never leak `|||||||` + base content into a page. Also scrub the 3-way merge
  BASE markdown.
- Silent same-block loss: the block 3-way merge still resolves same-block
  conflicts deterministically to git, but it is no longer silent: diff3Plan now
  reports a conflict count (mergeXmlFragments3WayWithStats), gitSyncWriteBody
  logs it, and the persistence boundary-snapshot now fires for git-sync writes
  over a non-git-sync baseline so the human's pre-merge content is preserved in
  page history (recoverable). Full both-preserved persisted-conflict UI remains
  the deferred redesign.

Tests: space-lock bounded-retry (success/stuck/poll-immediate); push vault-clean
+ diff3 |||||||  strip; ensureRepo conflictStyle pin; diff3Plan/3-way conflict
counts; persistence git-sync boundary snapshot. Server tsc clean; git-sync
vitest + server collaboration/git-sync jest all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:03:21 +03:00

63 lines
3.0 KiB
TypeScript

/**
* Git-sync control-plane constants.
*
* Event/job names are REUSED from the shared event contract (event.contants.ts)
* so the listener subscribes to the exact names the rest of the server emits —
* never a string literal that could drift. The Redis lock-key prefix + TTLs back
* the single-writer leader lock (§9); the debounce default backs the per-space
* event coalescing (§10).
*/
import { EventName } from '../../common/events/event.contants';
/**
* The page lifecycle events the git-sync listener reacts to. A change
* to any of these in an enabled space schedules a debounced sync cycle.
* - PAGE_CREATED / PAGE_UPDATED / PAGE_MOVED — structural + content edits;
* - PAGE_SOFT_DELETED / PAGE_RESTORED — Trash transitions (deletes are soft);
* - PAGE_MOVED_TO_SPACE — cross-space move (cross-repo).
*
* NOTE: body edits arrive via PAGE_UPDATED (emitted from persistence.extension),
* NOT via EventName.PAGE_CONTENT_UPDATED — that name is a BullMQ queue-job name,
* not an EventEmitter2 event, so @OnEvent would never fire for it.
*/
export const GIT_SYNC_PAGE_EVENTS = [
EventName.PAGE_CREATED,
EventName.PAGE_UPDATED,
EventName.PAGE_MOVED,
EventName.PAGE_MOVED_TO_SPACE,
EventName.PAGE_SOFT_DELETED,
EventName.PAGE_RESTORED,
] as const;
/** Redis key prefix for the per-space leader lock. */
export const GIT_SYNC_LOCK_PREFIX = 'git-sync:lock:';
/**
* Leader-lock TTL (ms). Must exceed the maximum expected cycle duration so the
* lock is not lost mid-cycle; on a crash it expires on its own. The
* in-process mutex (orchestrator) prevents overlapping cycles on one instance,
* and the Redis lock prevents two instances racing the same space.
*/
export const GIT_SYNC_LOCK_TTL_MS = 5 * 60 * 1000;
/**
* Bounded retry budget for ACQUIRING the per-space lock on the PUSH (external
* receive-pack) path. The poll cycle holds the single-writer lock while it
* processes a whole space, so a legitimate `git push` that arrives during a
* cycle would otherwise IMMEDIATELY 503 (GitSyncLockHeldError) even though the
* cycle is about to release the lock in well under a second for most spaces.
* Under continuous polling that made a majority of pushes 503 non-
* deterministically. So the push path retries the acquire with a small capped
* backoff for up to ~`TOTAL_MS` BEFORE giving up — a transient overlap with a
* cycle no longer fails the push, while a genuinely stuck/long cycle still
* surfaces a 503 after the bound (git then retries the whole push, which is
* safe: the receive-pack only runs ONCE the lock is held, so a 503 never leaves
* a half-applied ref). The POLL cycle itself does NOT retry (it just skips and
* the next tick reconciles), so this is push-only — the smaller blast radius.
*/
export const GIT_SYNC_PUSH_LOCK_RETRY_TOTAL_MS = 5_000;
/** First backoff between push lock-acquire attempts (ms); doubles, capped. */
export const GIT_SYNC_PUSH_LOCK_RETRY_BASE_MS = 100;
/** Cap on the per-attempt push lock-acquire backoff (ms). */
export const GIT_SYNC_PUSH_LOCK_RETRY_MAX_MS = 500;