Skip to content

Multi-thread build race: concurrent next build across worker threads fails with ENOENT on .next/BUILD_ID (server-side deploys 404) #52

Description

@dawsontoth

Summary

On a multi-threaded Harper cluster, a deployed Next.js app (no prebuilt) fails to serve — every route 404s. system.log shows the on-startup build failing in a worker thread:

Error building Next.js application <app>:  Error: ENOENT: no such file or directory,
  errno: -2, code: 'ENOENT', syscall: 'open',
  path: '.../components/<app>/.next/BUILD_ID'
process.exit(1) called in worker thread 3 — ignored to keep Harper alive

The build worker exits 1, the plugin's catch marks the build "failure" and returns without calling serve() (src/plugin.ts ~L256–264), so nothing serves.

Root cause: concurrent next build across worker threads

handleApplication runs in every Harper worker thread, and the build dedup in build() (src/plugin.ts L267–286) is a post-hoc check, not a pre-build lock:

const buildInfo = await databases.harperfast_nextjs.nextjs_build_info.get(scope.appName);
if (buildInfo && Date.now() - buildInfo.getUpdatedTime() < 5000) {
  if (buildInfo.status === 'failure') return;
  if (buildInfo.status === 'success') { /* skip if BUILD_ID matches */ }
}
// otherwise fall through and build…
await next.build()                                   // L303/314/326
const buildId = readFileSync(join(dir, '.next', 'BUILD_ID')) // L338–339  ← throws ENOENT

On a fresh deploy/restart there is no (or a >5s stale) build-info record, so all worker threads fall through and run next.build() concurrently into the same .next. next build clears/rewrites .next at the start, so one worker deletes .next/BUILD_ID while another reads it → ENOENTexit(1) → no serve. There is no "claim building → others wait" step, so simultaneous starts (the common case) are never serialized.

@harperfast/vite already solves this — port its lock

The sibling plugin has src/buildLock.tswithBuildLock(scope, build): a worker claims { status: 'building' } in the shared vite_build_info table before compiling; other workers observe the fresh claim and poll-wait, then skip (only one worker ever builds), with a stale-claim timeout for crashed builds. Its comment even says it "mirrors the @harperfast/nextjs build-info pattern" — but the nextjs pattern lacks the pre-build claim + wait, so it doesn't actually serialize concurrent starts. Adopting the same claim-then-wait lock in build() here should fix it.

Reproduce

  1. npm create harper@latest my-app --template nextjs (the create-harper Next.js templates).
  2. Deploy to a multi-threaded cluster (no prebuilt): harper deploy_component . restart=true replicated=true.
  3. Every route 404s; system.log (System log, not hdb.log) shows the ENOENT above.
  4. Workarounds that confirm the cause: THREADS_COUNT=1 (no concurrency) serves fine, and prebuilt: true + a shipped .next (no server build) serves fine.

Env: @harperfast/nextjs 2.2.1, next 16.2.11, harper 5.1.22, multi-thread cluster.

Related build issues: #51 (Turbopack can't resolve import 'harper'), #37 (build vs. RocksDB lock).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions