Update 2026-07-20: reproduced again on
harper@5.1.22, with significant new diagnostic evidence (proof the write actually commits; a full thread dump showing the entire process idle, including the HTTP worker thread; zero pending libuv requests anywhere) that strongly confirms this is a lost native completion callback. Also confirmed unrelated to and unfixed by PR #1696, a real but different merged fix in this version range. See 2026-07-20-reproduction-5.1.22.md for the full write-up -- don't rely on this README alone for current status.
Under sustained, sequential (not concurrent) bulk upsert batches to a single table, a Harper
process will — unpredictably, but reproducibly under load — get stuck on exactly one upsert
operation: the HTTP/in-process call never returns a response and never errors. No exception is
thrown, nothing is logged as an error, and the Harper process becomes otherwise idle (0% CPU,
all threads parked) while continuing to answer other, unrelated requests normally. The only
way out is killing and restarting the process; the stuck ingest job then wedges an internal
ingest lock (application-level, held past its own stated TTL, since it's never released via
the code path that only runs on success/failure).
Status: confirmed, and still present as of 5.1.22. Reproduced independently against a
clean harper@5.1.18 install with no application code involved — see "Standalone
reproduction" below. Also confirmed unchanged between 5.1.15 (original occurrences) and
5.1.18 (no relevant commits to resources/DatabaseTransaction.ts, no rocksdb-js version
change). Reproduced again on 5.1.22 on 2026-07-20 with substantially stronger diagnostic
evidence — see 2026-07-20-reproduction-5.1.22.md.
- Harper: 5.1.15 (original occurrences), 5.1.18 (first standalone repro, below), and 5.1.22 (second standalone repro — see 2026-07-20-reproduction-5.1.22.md; confirmed unchanged in this code path despite 5.1.22 including the unrelated PR #1696 fix)
- Storage engine: RocksDB (
@harperfast/rocksdb-js/@harperfast/rocksdb-js-darwin-arm642.3.0, matchespackage.json's^2.3.0— verified no version drift) - OS: macOS 26.5.1 (Darwin 25.5.0), arm64
- Node: v24.14.0
THREADS_COUNT: 1 (both the original occurrence and the standalone repro)
Found while running a large (~50-60M row) catalog ingest through a custom Harper component
that calls the in-process operation() global with { operation: 'upsert', table, records }
in batches of ~1000 records, applied sequentially (each batch awaited before the next
begins — never concurrent). Three occurrences across one session, each on a fresh push of an
already-successfully-packed bundle:
| # | Table in flight at hang | Cumulative rows written this run | Time-to-hang |
|---|---|---|---|
| 1 | ProductVariantCustomAttribute (batch ~7.17M/40M into that table) |
~46M | ~58 min |
| 2 | ProductMaster → transitioning to ProductTranslation |
~1M | ~20 min (recovered mid-transition on its own before stalling again on retry) |
| 3 | ProductTranslation (batch 708k) |
<1M | ~20 min |
Notably, occurrence 2 and 3 happened on retries of the same long-lived Harper process after occurrence 1 was cleared — each retry stalled sooner than the last, suggesting something cumulative across the process's lifetime (not purely a function of the current transaction's own size).
- Application-level
CatalogFeedRunstatus record staysrunningforever; nocompletedAt, noerrorMessage. - The calling code (
inProcessWriter.ts) wraps everyoperation({ operation: 'upsert', ... })call in a 300-secondPromise.racetimeout. This timeout never fires, even after the operation had been stuck for 14+ hours in occurrence 1. This rules out "just slow" — the call genuinely never settles (neither resolves nor rejects). ps/topon the Harper process: 0% CPU, statesleeping, not spinning.- A native thread sample (
sample <pid>) taken during the hang shows every thread idle: main thread parked inuv__io_poll/kevent(event loop has nothing to do), RocksDB's own background thread pool (6BGThreadWrapperthreads) parked oncondvar waitwith nothing queued. Nothing is executing, nothing is blocked mid-computation. - Despite that, lightweight concurrent requests to the same process keep succeeding during
the hang (status-poll
search_by_hashqueries against an unrelated table return normally) — so this is not a fully frozen event loop, and not a global deadlock. One specific in-flight write operation's completion signal is lost; everything else is fine. - Harper's own log repeats
"JavaScript execution has taken too long and is not allowing proper event queue cycling..."around the stall window, then goes silent (no further errors, no further table-load progress logged) whilesearch_by_hashpolls from the client's own status checks keep appearing and succeeding. - RocksDB's own internal stats (
database/*/LOG) show zero write-stall (Cumulative stall: 0.0 percent,rocksdb.stall.micros COUNT: 0) and its periodic background-compaction task keeps firing on schedule — so this is not RocksDB's own backpressure/stall mechanism; whatever is stuck is upstream of that.
Reproduced. Run against a clean harper@5.1.18 install, batches of 1000 synthetic records
(new distinct keys each batch, ~200 bytes/record), sequential, no concurrency, no application
code beyond the plain Operations API:
- Ran cleanly for 62,510,000 rows / 62,510 batches / ~57 minutes at a steady ~50ms/batch (~20k rows/sec), then batch 62,511 (the next one) never returned.
- At detection time, the process had been stuck for at least several minutes (see caveat below) with zero progress, zero errors, zero log output beyond what's described next.
- Diagnostics captured at the stall (
repro-logs/stall-batch62510-*/) show the identical signature to all three original occurrences:top/psreport the process at 0% CPU, statesleeping; asample <pid> 5thread dump shows the main thread parked inuv_run/kevent(event loop idle, nothing queued) and RocksDB's background thread pool idle on condvars with no queued work. Nothing is computing; nothing is blocked mid-instruction. It is not busy — it has simply stopped being told to continue. - The Harper process was deliberately left running (not killed) after the stall was detected, for live follow-up inspection.
Measurement caveat: the harness's own stall-timer was configured for 90s but the logged
stalled_for_ms at detection was ~944,000ms (~15.7 min). This is very likely the harness's own
Node process being suspended/throttled by macOS App Nap / background-process scheduling (a
known OS behavior for long-running background CLI tools, unrelated to Harper) rather than a
15-minute delay in Harper's own state — but noting it plainly rather than asserting false
precision. What's solid regardless of that caveat: the batch that started at 01:15:50 had
still not returned by the time this was written, and the diagnostic signature (0% CPU, idle
threads, no errors) is identical across all four occurrences (3 original + this one) regardless
of the exact stall-detection latency.
To isolate this from any possibility of an application-level bug, a minimal, self-contained
repro (repro.js, attached) does the following against a freshly-installed harper@5.1.18:
npm install harper@5.1.18in an empty directory (published npm package, not a source build).harper install+harper runwith a dedicatedROOTPATHand an isolated$HOME(harper installtracks a single canonical install via$HOME/.harperdb/hdb_boot_properties.file, independent of--ROOTPATH— worth knowing if you try to run multiple harper instances on one machine for testing).- Creates one plain table (
BulkHangRepro,idhash attribute, a handful of scalar columns). - Runs the plain HTTP Operations API (
POST / {operation: 'upsert', database, table, records}), in batches of 1000 synthetic records with strictly new, distinct keys per batch (row-<sequential-index>) — matching the real ingest's pattern (new rows, not repeated updates to the same keys) — sequentially, oneawaited batch after another, no concurrency. - Watches wall-clock time per batch; if one batch exceeds 90s (vs. the ~40-135ms/batch normal
rate observed), it's flagged a stall and diagnostics are captured automatically
(
ps,top -pid,sample <pid> 5,lsof -p <pid>, and the tail of Harper's own log) intorepro-logs/stall-batch<N>-<timestamp>/, and the Harper process is deliberately left running (not killed) for live follow-up inspection.
Run log: repro-logs/progress.log. Harper's own log: repro-logs/harper.log.
Result: reproduced on the first run, no tuning needed — see above. To reproduce again:
STALL_MS=90000 TOTAL_BATCHES=100000 node repro.js from a clean checkout (delete hdb/,
home/, and repro-logs/ first if re-running). Expect it to take somewhere in the range of
20 minutes to an hour of sustained load before stalling, based on both this run and the three
original occurrences — this is not a fast/deterministic repro, but it is a reliable one.
This is the transaction-commit path every upsert goes through
(dataLayer/insert.ts → harperBridge.upsertRecords →
resources/transaction.ts:transaction() → DatabaseTransaction.commit()).
commit()already has real, recently-hardened retry handling for RocksDBERR_BUSY/ERR_TRY_AGAINrejections (git history: #1223 "retry ERR_TRY_AGAIN commits instead of dropping them silently", #1252 "never drop source-applied writes on conflict", referencing a prior incident #308 where such a rejection "propagated out of the unawaitedonCommit()handler as an unhandled rejection and the write was silently dropped — records lost mid-copy"). For non-sourceApplytransactions (which this ingest path is — it's a plain in-processoperation()call, not replication/source-apply) this retry is capped at 40 attempts with a ≤1s backoff each, so a genuinely-retried conflict would surface a loudServerErrorwithin well under a minute. That doesn't match what we observe — no error ever appears, for 14+ hours in one case.- The most recent commit touching this method (
4ba9fd9c5, "Reconcile commit() type for rocksdb-js 2.2.0 coordinated-retry sentinel", 2026-06-18) is a type-only cast around a newrocksdb-js2.2+ feature:commit()can now resolve (not reject) to aRETRY_NOWsentinel whencoordinatedRetry: trueis passed to the transaction and anIsBusyconflict occurs. The commit message states Harper doesn't passcoordinatedRetry, so this "never" applies — and readingrocksdb-js's own type docs confirms that's an opt-in, default-falseoption, so on paper this shouldn't be reachable here. Flagging it anyway because: (a) it's the single most recent change in exactly this method, (b) its own commit message says "Full RETRY_NOW handling is a separate change" (i.e., acknowledged as incomplete/deferred), and (c) therocksdb-jstype docs describe the underlying mechanism as the native layer "parking on a VT slot before resolving" — i.e., a native-side wait state contingent on a different transaction's commit/release, which is a plausible shape for exactly the kind of "everything looks idle but one promise never settles" hang observed here if that parking is ever entered unexpectedly (e.g. via a concurrent internal/background transaction that does touch this path, or a native-side accounting bug that isn't strictly gated on the JS-levelcoordinatedRetryflag actually being requested by the caller that gets stuck). - There is no timeout/watchdog anywhere in
commit()'s success path for the underlying nativetransaction.commit()promise itself.checkOverloaded()exists and can reject a new transaction if an existingoutstandingCommithas been pending too long (STORAGE_MAXTRANSACTIONQUEUETIME, default 45s) — but it protects concurrent, later callers from queueing behind a slow commit; it does nothing for the stuck commit itself, and in a purely sequential single-writer ingest (this scenario, and the standalone repro), there is no later caller to trigger it in the first place. If the nativecommit()promise is ever truly lost, nothing in this file can recover it.
- Whether
transaction.commit()'s returned promise can, under any internal/background transaction interaction, ever fail to settle at all (not reject, not resolve) — independent of whether the caller requestedcoordinatedRetry. The "VT slot parking" behavior described inrocksdb-js's own docs for the coordinated-retry path is the closest known mechanism in this codebase for a promise that's contingent on another transaction's state. - Whether any Harper-internal write path (replication apply, index/analytics maintenance,
compaction-adjacent bookkeeping) passes
coordinatedRetry: trueand could leave a shared native resource (a "VT slot") in a state that a completely unrelated, non-coordinated transaction then blocks on. - Whether
rocksdb-js's native N-API completion callback (whatever fires the JS promisecommit()returns) can be dropped/lost under sustained load — e.g. auv_async_thandle ornapi_threadsafe_functionreused/closed prematurely, or a completion queued against a thread-pool slot that's already been reclaimed. Strongly confirmed by direct evidence as of the 2026-07-20 5.1.22 reproduction — see 2026-07-20-reproduction-5.1.22.md: the stuck write is proven to have committed in full, and a live full-process thread dump plus a libuv active-handle check show every thread (including the one that should generate the HTTP response) completely idle with zero pending requests anywhere. This was a plausible theory before; it now has hard evidence behind it and should be the primary lead. - A defensive fix on the Harper (JS) side regardless of native root cause: wrap the
transaction.commit()call itself in a bounded timeout that throws a distinguishable error (not silently retried asERR_BUSY/ERR_TRY_AGAIN, since we don't know it's actually a conflict) if it hasn't settled after some generous duration — converting a permanent silent hang into a bounded, loud failure the caller can act on. Caveat we'd flag to the Harper team: this is a mitigation, not a fix, and isn't safe to ship blind — if the original native commit later actually completes after the timeout-triggered retry/abort path has already run, that's a duplicate-write/consistency hazard, exactly the class of bug this same file'ssourceApply"never drop a write on a transient conflict" comment is already protecting against for a different case.
repro.js— standalone repro harness (self-contained, no dependency on any private app code)repro-logs/progress.log— batch-by-batch timing log from the runrepro-logs/harper.log— Harper's own stdout/stderr for the repro instancerepro-logs/stall-*/— diagnostics captured automatically at the moment of a detected stall, if any- 2026-07-20-reproduction-5.1.22.md — second
reproduction, against
harper@5.1.22, with significantly stronger diagnostics (proof of commit success, full live thread dump, libuv active-handle check). Read this for the current state of the investigation.