Skip to content

[AI] Fix batchMessages losing nested batch messages on outer failure#7225

Open
dearlordylord wants to merge 4 commits intoactualbudget:masterfrom
dearlordylord:fix/batch-messages-concurrency
Open

[AI] Fix batchMessages losing nested batch messages on outer failure#7225
dearlordylord wants to merge 4 commits intoactualbudget:masterfrom
dearlordylord:fix/batch-messages-concurrency

Conversation

@dearlordylord
Copy link
Contributor

@dearlordylord dearlordylord commented Mar 17, 2026

Description

batchMessages uses module-level globals IS_BATCHING and _BATCHED shared across async operations. When two async callers interleave (cooperative scheduling via await), a nested batch's messages get silently lost if the outer batch throws - the catch block ran before _sendMessages, discarding everything in _BATCHED.

Fix moves _sendMessages call outside try/catch so accumulated messages are always sent regardless of whether func() threw.

Related issue(s)

None (found via code audit)

Testing

Added batchMessages.test.ts with two tests:

  • Outer batch fails after inner completes - verifies inner messages are preserved
  • Both batches succeed - positive control

Both tests use promise-based yield control to deterministically reproduce the interleaving.

Checklist

  • Release notes added
  • No obvious regressions in affected areas
  • Self-review has been performed - I understand what each change in the code does and why it is needed

Bundle Stats

Bundle Files count Total bundle size % Changed
desktop-client 26 11.81 MB 0%
loot-core 1 4.83 MB → 4.83 MB (+259 B) +0.01%
api 4 4.05 MB → 4.05 MB (+250 B) +0.01%
View detailed bundle stats

desktop-client

Total

Files count Total bundle size % Changed
26 11.81 MB 0%
View detailed bundle breakdown

Added
No assets were added

Removed
No assets were removed

Bigger
No assets were bigger

Smaller
No assets were smaller

Unchanged

Asset File Size % Changed
static/js/index.js 3.21 MB 0%
static/js/BackgroundImage.js 119.98 kB 0%
static/js/FormulaEditor.js 716.38 kB 0%
static/js/ReportRouter.js 1002.19 kB 0%
static/js/TransactionList.js 81.29 kB 0%
static/js/ca.js 185.62 kB 0%
static/js/da.js 104.66 kB 0%
static/js/de.js 177.63 kB 0%
static/js/en-GB.js 7.16 kB 0%
static/js/en.js 169.27 kB 0%
static/js/es.js 172.13 kB 0%
static/js/fr.js 177.63 kB 0%
static/js/indexeddb-main-thread-worker-e59fee74.js 13.46 kB 0%
static/js/it.js 168.97 kB 0%
static/js/narrow.js 353.32 kB 0%
static/js/nb-NO.js 154.72 kB 0%
static/js/nl.js 111.58 kB 0%
static/js/pl.js 88.31 kB 0%
static/js/pt-BR.js 180.55 kB 0%
static/js/resize-observer.js 18.03 kB 0%
static/js/th.js 179.94 kB 0%
static/js/theme.js 30.68 kB 0%
static/js/uk.js 213.14 kB 0%
static/js/useTransactionBatchActions.js 4.27 MB 0%
static/js/wide.js 418 B 0%
static/js/workbox-window.prod.es5.js 7.28 kB 0%

loot-core

Total

Files count Total bundle size % Changed
1 4.83 MB → 4.83 MB (+259 B) +0.01%
Changeset
File Δ Size
home/runner/work/actual/actual/packages/loot-core/src/server/sync/index.ts 📈 +259 B (+1.84%) 13.74 kB → 14 kB
View detailed bundle breakdown

Added

Asset File Size % Changed
kcab.worker.BYOYYYBz.js 0 B → 4.83 MB (+4.83 MB) -

Removed

Asset File Size % Changed
kcab.worker.Bu63xj1l.js 4.83 MB → 0 B (-4.83 MB) -100%

Bigger
No assets were bigger

Smaller
No assets were smaller

Unchanged
No assets were unchanged


api

Total

Files count Total bundle size % Changed
4 4.05 MB → 4.05 MB (+250 B) +0.01%
Changeset
File Δ Size
home/runner/work/actual/actual/packages/loot-core/src/server/sync/index.ts 📈 +250 B (+1.83%) 13.35 kB → 13.6 kB
View detailed bundle breakdown

Added
No assets were added

Removed
No assets were removed

Bigger

Asset File Size % Changed
index.js 3.83 MB → 3.83 MB (+250 B) +0.01%

Smaller
No assets were smaller

Unchanged

Asset File Size % Changed
from-Bl-Hslp4.js 167.73 kB 0%
multipart-parser-BnDysoMr.js 8.1 kB 0%
src-iMkUmuwR.js 43.64 kB 0%

@actual-github-bot actual-github-bot bot changed the title Fix batchMessages losing nested batch messages on outer failure [WIP] Fix batchMessages losing nested batch messages on outer failure Mar 17, 2026
@netlify
Copy link

netlify bot commented Mar 17, 2026

Deploy Preview for actualbudget ready!

Name Link
🔨 Latest commit 5101dad
🔍 Latest deploy log https://app.netlify.com/projects/actualbudget/deploys/69b8c8b35526950009f7ba26
😎 Deploy Preview https://deploy-preview-7225.demo.actualbudget.org
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions
Copy link
Contributor

👋 Hello contributor!

We would love to review your PR! Before we can do that, please make sure:

  • ✅ All CI checks pass
  • ✅ The PR is moved from draft to open (if applicable)
  • ✅ The "[WIP]" prefix is removed from the PR title
  • ✅ All CodeRabbit code review comments are resolved (if you disagree with anything - reply to the bot with your reasoning so we can read through it). The bot will eventually approve the PR.

We do this to reduce the TOIL the core contributor team has to go through for each PR and to allow for speedy reviews and merges.

For more information, please see our Contributing Guide.

@dearlordylord dearlordylord changed the title [WIP] Fix batchMessages losing nested batch messages on outer failure [AI] Fix batchMessages losing nested batch messages on outer failure Mar 17, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 17, 2026

📝 Walkthrough

Walkthrough

Modifies batchMessages to always attempt sending accumulated batched messages even if the wrapped function throws, preserving original error semantics; adds regression tests that validate nested batch messages are not lost when outer batches fail or succeed; adds a release note entry.

Changes

Cohort / File(s) Summary
Batching logic
packages/loot-core/src/server/sync/index.ts
Changed batchMessages control flow so batched messages are sent regardless of inner errors, capturing send errors and rethrowing the original error when appropriate.
Tests
packages/loot-core/src/server/sync/batchMessages.test.ts
New concurrency/regression tests covering nested batching: outer fails after inner completes, and outer succeeds — both assert that inner and outer messages are applied.
Release notes
upcoming-release-notes/7225.md
Added a bugfix release note documenting the fix for nested batch messages being lost when an outer batch fails.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant batchMessages
  participant sendMessages
  participant DB

  Caller->>batchMessages: call with fn()
  batchMessages->>batchMessages: collect messages from fn (inner may throw)
  alt inner throws or returns
    batchMessages->>sendMessages: send accumulated messages
    sendMessages->>DB: apply messages (row-a, row-b)
    sendMessages-->>batchMessages: send result / error
    batchMessages-->>Caller: rethrow original error (if any) or throw send error
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through batches, nested tight and deep,
I gathered every message, none to lose or keep,
When outer storms arise, I send before I weep,
Both inner and outer safely planted—now we leap! 🌱✨

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing a bug where nested batch messages are lost when an outer batch fails.
Description check ✅ Passed The PR description clearly explains the bug in batchMessages with module-level globals causing message loss on nested batch failures, describes the fix, and includes testing approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/loot-core/src/server/sync/batchMessages.test.ts (1)

52-52: Clarify the purpose of stepForwardInTime in the test comment.

global.stepForwardInTime() only advances Date.now() for unique Timestamp.send() values—it does not yield the event loop. The actual interleaving is achieved via yieldPromise. The current comment at line 45 ("Yield to event loop - B will run during this await") correctly describes the mechanism, but a brief comment on line 52 would clarify why stepForwardInTime is needed.

📝 Suggestion
+    // Advance clock so inner batch gets a distinct timestamp
     global.stepForwardInTime();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loot-core/src/server/sync/batchMessages.test.ts` at line 52, Add a
brief inline test comment next to the call to global.stepForwardInTime()
explaining that this helper only advances the mocked Date.now() so
Timestamp.send() produces unique values and does not yield the event loop; make
clear that actual concurrency/interleaving is achieved by yieldPromise (the
earlier comment) and that stepForwardInTime is solely to bump timestamps for
operations relying on Timestamp.send(). Reference global.stepForwardInTime(),
Timestamp.send(), and yieldPromise in the comment for clarity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/loot-core/src/server/sync/index.ts`:
- Around line 516-518: The code throws an unknown-typed variable `error` causing
an ESLint no-throw-literal violation; update the throw to use the same type
assertion used for the errorHandler call (e.g., replace `throw error;` with
`throw (error as Error);`) so the thrown value is explicitly an Error and
matches the `void errorHandler(error as Error);` usage.

---

Nitpick comments:
In `@packages/loot-core/src/server/sync/batchMessages.test.ts`:
- Line 52: Add a brief inline test comment next to the call to
global.stepForwardInTime() explaining that this helper only advances the mocked
Date.now() so Timestamp.send() produces unique values and does not yield the
event loop; make clear that actual concurrency/interleaving is achieved by
yieldPromise (the earlier comment) and that stepForwardInTime is solely to bump
timestamps for operations relying on Timestamp.send(). Reference
global.stepForwardInTime(), Timestamp.send(), and yieldPromise in the comment
for clarity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7ea945bc-cbb8-4671-9254-fa6f4a05bb6e

📥 Commits

Reviewing files that changed from the base of the PR and between f73c5e9 and 293d8c4.

📒 Files selected for processing (2)
  • packages/loot-core/src/server/sync/batchMessages.test.ts
  • packages/loot-core/src/server/sync/index.ts

@MikesGlitch
Copy link
Member

@dearlordylord can you give us an example of a problem this solves in the app?

@dearlordylord
Copy link
Contributor Author

@dearlordylord can you give us an example of a problem this solves in the app?

It's only a defensive fix - without it, the code would bite whenever someone adds a throw path inside any outer batchMessages. So, no reproducible path in the current code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants