santifer/career-ops · error

Tracker transaction is already closed

Error message

Tracker transaction is already closed

What it means

assertOpen is the guard on every operation of a tracker transaction object (read/write/commit etc.). Once the transaction has been closed (closed=true, typically after commit() or close()), any further method call throws this error. The transaction object is single-use by design.

Solutions

  1. Create a new transaction (withTrackerTransaction / the factory used originally) for any further work instead of reusing the closed one
  2. Restructure code so commit() is the last operation and no methods are called afterward
  3. In retry logic, re-create the transaction inside the retry loop rather than reusing it
  4. Log or assert transaction lifecycle in your code to catch double-close/double-use paths

Example fix

// before
const tx = createTrackerTransaction({ tracker });
tx.commit();
tx.write(updated);  // throws: already closed
// after
const tx = createTrackerTransaction({ tracker });
tx.write(updated);
tx.commit(); // commit last; new tx for new work
Defensive patterns

Strategy: try-catch

Validate before calling

// track closed state yourself if reusing across scopes
if (tx.isClosed && tx.isClosed()) throw new Error('create a new transaction');

Try / catch

try {
  tx.write(data);
  tx.commit();
} catch (e) {
  if (String(e.message).includes('already closed')) {
    tx = createTrackerTransaction({ tracker }); // recreate and redo
  }
}

Prevention

When it happens

Trigger: Calling read(), write(), or any other transaction method after commit()/close() has already run; calling a method twice on a transaction that auto-closes after commit; keeping a reference to the transaction and reusing it in a later code path or retry handler.

Common situations: Storing the transaction in a variable used by multiple functions where one commits early; a retry wrapper that retries the whole flow (including commit) after a partial failure; accidentally calling an update helper twice on the same transaction; long-lived module-level transaction reused across requests.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.


AI-assisted analysis of santifer/career-ops@e7abd431fc (2026-09-16). Data as JSON: /api/errors/937c9a969d57c719. Report an issue: GitHub.

Appendix: source

Thrown at tracker-utils.mjs:577

/**
 * Open one serialized read/replace transaction for an applications tracker.
 * Writers receive only the canonical path plus guarded read and atomic replace
 * operations, keeping the complete mutation inside one shared lock lifetime.
 */
export async function openTrackerTransaction(appsFile, options = {}) {
  const trackerPath = canonicalizeTrackerPath(appsFile);
  const { lockDir = trackerLockDirFor(trackerPath), ...lockOptions } = options;
  const lock = await acquireTrackerLock(lockDir, {
    timeoutMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS) || 60_000,
    retryMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_RETRY_MS) || 75,
    staleMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_STALE_MS) || 10 * 60_000,
    tracker: trackerPath,
    ...lockOptions,
  });
  let closed = false;
  let closeError = null;
  const assertOpen = () => {
    if (closed) throw new Error('Tracker transaction is already closed');
  };
  return {
    path: trackerPath,
    read() {
      assertOpen();
      return readFileSync(trackerPath, 'utf-8');
    },
    replace(content) {
      assertOpen();
      writeFileAtomic(trackerPath, content);
    },
    close() {
      if (closed) return closeError;
      try {
        lock.release();
      } catch (err) {
        closeError = err;
        console.error(`Warning: tracker transaction closed but lock cleanup failed at ${lockDir}: ${err.message}`);

View on GitHub (pinned to e7abd431fc)