{"record":{"id":"1d48013842364a58","repo":"actualbudget/actual","slug":"cannot-start-a-batch-process-batch-already-starte","errorCode":null,"errorMessage":"Cannot start a batch process: batch already started","messagePattern":"Cannot start a batch process: batch already started","errorType":"exception","errorClass":"APIError","httpStatus":null,"severity":"error","filePath":"packages/loot-core/src/server/api.ts","lineNumber":133,"sourceCode":"    throw APIError(`${debug}: category \"${id}\" does not exist`);\n  }\n\n  if (row.is_income !== 0) {\n    throw APIError(`${debug}: category \"${id}\" is not an expense category`);\n  }\n}\n\nfunction checkFileOpen() {\n  if (!(prefs.getPrefs() || {}).id) {\n    throw APIError('No budget file is open');\n  }\n}\n\nlet batchPromise = null;\n\nhandlers['api/batch-budget-start'] = async function () {\n  if (batchPromise) {\n    throw APIError('Cannot start a batch process: batch already started');\n  }\n\n  // If we are importing, all we need to do is start a raw database\n  // transaction. Updating spreadsheet cells doesn't go through the\n  // syncing layer in that case.\n  if (IMPORT_MODE) {\n    void db.asyncTransaction(() => {\n      return new Promise((resolve, reject) => {\n        batchPromise = { resolve, reject };\n      });\n    });\n  } else {\n    void batchMessages(() => {\n      return new Promise((resolve, reject) => {\n        batchPromise = { resolve, reject };\n      });\n    });\n  }","sourceCodeStart":115,"sourceCodeEnd":151,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/loot-core/src/server/api.ts#L115-L151","documentation":"api.batchBudgetStart() opens a single mutation/transaction scope for bulk updates. The module keeps a module-level batchPromise; if a batch is already open, a second start would nest transactions, so the API throws instead.","triggerScenarios":"Calling api.batchBudgetStart() twice without an intervening api.batchBudgetEnd() — e.g. concurrent code paths both starting a batch, or a previous batch never ended because of an exception between start and end.","commonSituations":"Import scripts where an earlier iteration threw inside the batch and the end call was skipped; parallel workers sharing one API instance; re-entrant calls from event handlers.","solutions":["Ensure every batchBudgetStart() is paired with batchBudgetEnd() in a try/finally","Track a boolean in your code so only one batch runs at a time, or serialize callers","If a stale batch is stuck (e.g. after a crash), restart the API process to reset the module state"],"exampleFix":"// before\nawait api.batchBudgetStart();\napplyUpdates(items);\nawait api.batchBudgetEnd();\n// after\nawait api.batchBudgetStart();\ntry {\n  applyUpdates(items);\n} finally {\n  await api.batchBudgetEnd();\n}","handlingStrategy":"validation","validationCode":"if (batchPromise) throw APIError('Cannot start a batch process: batch already started');","typeGuard":null,"tryCatchPattern":"if (batchPromise) {\n  try {\n    await batchPromise.resolve();\n  } catch (e) {\n    logger.error('stale batch', e);\n  }\n}","preventionTips":["Null out batchPromise in a finally block so failures can't wedge the batch state","Expose batch state for diagnostics","Reject nested batch starts early"],"tags":["api","batching","concurrency"],"backgroundTag":"operation-already-in-progress","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}