Skip to content

fix: prepare subscription updates inside the writer lock - #7544

Open
diegolmello wants to merge 1 commit into
developfrom
bugsnag-db-sub
Open

fix: prepare subscription updates inside the writer lock#7544
diegolmello wants to merge 1 commit into
developfrom
bugsnag-db-sub

Conversation

@diegolmello

@diegolmello diegolmello commented Aug 3, 2026

Copy link
Copy Markdown
Member

Proposed changes

Fixes a Bugsnag crash: "Cannot update a record with pending changes". Two functions prepared WatermelonDB records outside the writer lock and committed the batch later. A concurrent writer on the same cached record made the commit throw, and the pending change was lost.

  • createOrUpdateSubscription (app/lib/methods/subscriptions/rooms.ts) now reads, prepares, and batches inside one db.write callback.
  • decryptPendingSubscriptions (app/lib/encryption/encryption.ts) does the same; decryption still runs outside the lock.
  • Adds regression tests that run each function against a concurrent writer on the same record and assert the batch commits without a throw.

Issue(s)

https://rocketchat.atlassian.net/browse/NATIVE-1470

How to test or reproduce

  • Run TZ=UTC pnpm test app/lib/methods/subscriptions/rooms.test.ts app/lib/methods/subscriptions/room.test.ts.
  • Manual check: open a room list on a busy server while subscriptions update (for example, mark rooms read on another client). Subscriptions must keep updating with no "pending changes" error in the logs.

Screenshots

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Improvement (non-breaking change which improves a current function)
  • New feature (non-breaking change which adds functionality)
  • Documentation update (if none of the other choices apply)

Checklist

  • I have read the CONTRIBUTING doc
  • I have signed the CLA
  • Lint and unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works (if applicable)
  • I have added necessary documentation (if applicable)
  • Any dependent changes have been merged and published in downstream modules

Further comments

This is the first fix of a defect class tracked in https://rocketchat.atlassian.net/browse/NATIVE-1462: prepare calls outside db.write with the batch committed later. Seven more call sites follow the same pattern in later PRs.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when subscription updates occur simultaneously with room activity.
    • Prevented subscription changes from being lost or reported as pending-change errors.
    • Ensured the most recent room-opened timestamp is saved correctly.
    • Improved handling of unsuccessful subscription updates to avoid applying incomplete changes.
  • Tests

    • Added coverage for concurrent subscription and room activity updates.

createOrUpdateSubscription and decryptPendingSubscriptions called
prepareUpdate outside db.write and committed the batch later. A
concurrent updateLastOpen took the writer lock in that gap, called
update() on the same cached subscription record, and threw
"Cannot update a record with pending changes". Both paths now
prepare and batch inside one db.write, as room.ts already does.
@diegolmello
diegolmello temporarily deployed to approve_e2e_testing August 3, 2026 21:38 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The database write paths now prepare subscription records and batch updates while holding the writer lock. Pending subscription decryption occurs before the lock. A concurrency test verifies that createOrUpdateSubscription and updateLastOpen persist without pending-change errors.

Subscription write concurrency

Layer / File(s) Summary
Encrypted subscription write preparation
app/lib/encryption/encryption.ts
Pending subscriptions are decrypted before db.write. Valid prepared updates are filtered and batched inside the writer lock.
Subscription update serialization
app/lib/methods/subscriptions/rooms.ts
createOrUpdateSubscription is exported. Subscription and message preparation now occurs inside db.write before batching.
Concurrency regression coverage
app/lib/methods/subscriptions/rooms.test.ts
Mocks simulate overlapping prepared updates and verify concurrent subscription creation with updateLastOpen.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant createOrUpdateSubscription
  participant db.write
  participant subscriptionRecord
  participant db.batch
  createOrUpdateSubscription->>db.write: prepare subscription and message changes
  db.write->>subscriptionRecord: prepareUpdate
  subscriptionRecord-->>db.write: prepared changes
  db.write->>db.batch: commit prepared changes
Loading

Possibly related PRs

Suggested labels: type: bug

Suggested reviewers: rohit3523

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: moving subscription update preparation inside the database writer lock.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (2)
  • NATIVE-1470: Request failed with status code 401
  • NATIVE-1462: Request failed with status code 401

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/lib/methods/subscriptions/rooms.ts (1)

49-49: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add an explicit return type to the newly exported function.

createOrUpdateSubscription is now part of the module's public surface. The coding guidelines require explicit return type annotations for TypeScript functions.

♻️ Proposed change
-export const createOrUpdateSubscription = async (subscription: ISubscription, room: IServerRoom | IRoom) => {
+export const createOrUpdateSubscription = async (subscription: ISubscription, room: IServerRoom | IRoom): Promise<void> => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/methods/subscriptions/rooms.ts` at line 49, Add an explicit
Promise-based return type annotation to the exported createOrUpdateSubscription
function, using the actual value it resolves to and preserving its existing
behavior.

Source: Coding guidelines

app/lib/encryption/encryption.ts (1)

398-403: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a descriptive name and explicit callback return types.

newSub does not identify the value as decrypted subscription data. Rename it to decryptedSubscription. Add explicit return types to the async mapper and the db.write callback.

As per coding guidelines, use descriptive names and explicit TypeScript parameter and return types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/encryption/encryption.ts` around lines 398 - 403, Update the
Promise.all mapper in decryptSubscription to rename newSub to
decryptedSubscription and add an explicit callback return type. Also add an
explicit parameter type and return type to the associated db.write callback,
preserving the existing decrypted subscription behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/lib/encryption/encryption.ts`:
- Around line 398-417: Update the subscription flow around decryptSubscription
and prepareUpdate to prevent stale decrypted snapshots from overwriting newer
lastMessage values: capture the source last-message identity or timestamp before
decryption, validate it against the current record inside prepareUpdate, and
when it differs re-read and decrypt the current subscription or skip it and
schedule a retry. Add a regression test covering a newer lastMessage arriving
while decryption is delayed.

In `@app/lib/methods/subscriptions/rooms.test.ts`:
- Around line 153-173: The concurrency test should force updateLastOpen to hold
the writer lock while createOrUpdateSubscription completes its preparation. In
the test around createOrUpdateSubscription and updateLastOpen, delay the
getSubscriptionByRoomId mock until the intended ordering is established, and
retain a positive assertion that mockDbBatch was called to verify the writer
path executed.

---

Nitpick comments:
In `@app/lib/encryption/encryption.ts`:
- Around line 398-403: Update the Promise.all mapper in decryptSubscription to
rename newSub to decryptedSubscription and add an explicit callback return type.
Also add an explicit parameter type and return type to the associated db.write
callback, preserving the existing decrypted subscription behavior.

In `@app/lib/methods/subscriptions/rooms.ts`:
- Line 49: Add an explicit Promise-based return type annotation to the exported
createOrUpdateSubscription function, using the actual value it resolves to and
preserving its existing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e7c1edd0-bd86-4ef2-8858-d0c57a345ac9

📥 Commits

Reviewing files that changed from the base of the PR and between 8a7bc83 and a05dbde.

📒 Files selected for processing (3)
  • app/lib/encryption/encryption.ts
  • app/lib/methods/subscriptions/rooms.test.ts
  • app/lib/methods/subscriptions/rooms.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: E2E Build Android / android-build
  • GitHub Check: ESLint and Test / run-eslint-and-test
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,jsx,tsx}: Use descriptive names for functions, variables, and classes that clearly convey their purpose
Write comments that explain the 'why' behind code decisions, not the 'what'
Keep functions small and focused on a single responsibility
Use const by default, let when reassignment is needed, and avoid var
Prefer async/await over .then() chains for handling asynchronous operations
Use explicit error handling with try/catch blocks for async operations
Avoid deeply nested code; refactor complex logic into helper functions

Files:

  • app/lib/methods/subscriptions/rooms.test.ts
  • app/lib/encryption/encryption.ts
  • app/lib/methods/subscriptions/rooms.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use TypeScript for type safety; add explicit type annotations to function parameters and return types
Prefer interfaces over type aliases for defining object shapes in TypeScript
Use enums for sets of related constants rather than magic strings or numbers

Files:

  • app/lib/methods/subscriptions/rooms.test.ts
  • app/lib/encryption/encryption.ts
  • app/lib/methods/subscriptions/rooms.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{js,jsx,ts,tsx}: Format JavaScript and TypeScript code with Oxfmt using the repository configuration: tabs, single quotes, 130-character width, no trailing commas, omitted arrow-function parentheses where possible, and same-line brackets.
Follow Oxlint rules configured in .oxlintrc.json, including the import, React, Jest, TypeScript, and React Native plugins.

Files:

  • app/lib/methods/subscriptions/rooms.test.ts
  • app/lib/encryption/encryption.ts
  • app/lib/methods/subscriptions/rooms.ts
🧠 Learnings (2)
📚 Learning: 2026-04-30T17:07:51.020Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7274
File: app/lib/services/voip/MediaCallEvents.ts:0-0
Timestamp: 2026-04-30T17:07:51.020Z
Learning: In this Rocket.Chat React Native codebase, the ESLint rule `no-void: error` is enforced. When you see a promise returned from an async call that is not awaited (a “floating promise”), do not silence it with the `void somePromise()` pattern. Instead, handle the promise explicitly by attaching `.catch(...)` (or otherwise awaiting/handling the error) so unhandled-rejection risks are addressed in a way that satisfies the existing ESLint configuration.

Applied to files:

  • app/lib/methods/subscriptions/rooms.test.ts
  • app/lib/encryption/encryption.ts
  • app/lib/methods/subscriptions/rooms.ts
📚 Learning: 2026-06-25T18:37:25.526Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7434
File: app/views/ScreenLockConfigView.test.tsx:16-22
Timestamp: 2026-06-25T18:37:25.526Z
Learning: In Rocket.Chat ReactNative tests that mock selectors for `useAppSelector`, don’t require the mocked selector input to be typed as `IApplicationState` when the fixture only includes a partial Redux state slice (e.g., only `server` and `settings`). Requiring the full `IApplicationState` type in that scenario forces unsafe `as IApplicationState` casts and undermines type-safety. For these narrowly scoped selector-mock fixtures, use a less strict type (e.g., `any`) to keep the mock focused on the slice under test.

Applied to files:

  • app/lib/methods/subscriptions/rooms.test.ts
🔇 Additional comments (4)
app/lib/methods/subscriptions/rooms.ts (1)

154-225: LGTM!

app/lib/methods/subscriptions/rooms.test.ts (2)

82-112: LGTM!


116-138: LGTM!

app/lib/encryption/encryption.ts (1)

1-1: LGTM!

Comment on lines +398 to +417
const decryptedSubscriptions = await Promise.all(
subsEncryptedToDecrypt.map(async (sub: TSubscriptionModel) => ({
sub,
newSub: await this.decryptSubscription(sub)
}))
);

// Prepare and batch under the writer lock so a concurrent writer can't
// call prepareUpdate on a record with pending changes.
await db.write(async () => {
await db.batch(preparedSubscriptions.filter((record): record is Model => record !== null));
const preparedSubscriptions = decryptedSubscriptions
.map(({ sub, newSub }) => {
try {
return sub.prepareUpdate(
protectedFunction((m: TSubscriptionModel) => {
if (newSub?.lastMessage) {
m.lastMessage = newSub.lastMessage;
}
})
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

mapfile -t files < <(
	fd -t f -e ts -e tsx . app |
		rg '/(encryption|room|rooms(\.test)?)\.(ts|tsx)$'
)

if ((${`#files`[@]} > 0)); then
	rg -n -C 8 \
		'decryptPendingSubscriptions|decryptSubscription|lastMessage|createOrUpdateSubscription|updateLastOpen|prepareUpdate|db\.write|db\.batch' \
		"${files[@]}"
fi

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 224


🏁 Script executed:

#!/usr/bin/env/bash
set -euo pipefail

printf 'Files matching path:\n'
fd -t f -e ts -e tsx '^(encryption|room|rooms(\.test)?)$' app || true

printf '\nTracked files around encryption/room:\n'
git ls-files app | rg 'app/(lib/encryption/(encryption\.ts|.*test.*)|.*room.*\.ts|.*rooms.*\.ts)$' || true

printf '\nSearch relevant symbols:\n'
rg -n -C 6 \
  'decryptPendingSubscriptions|decryptSubscription|lastMessage|createOrUpdateSubscription|updateLastOpen|prepareUpdate|db\.write|db\.batch' app || true

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 50391


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf 'lock file locations:\n'
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$' || true

printf '\nWatermelonDB version declarations:\n'
for f in $(git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$' | head -20); do
  echo "--- $f"
  rg -n -C 3 '"watermelondb"|watermelondb@|/watermelondb/' "$f" | head -80 || true
done

printf '\nencryption.ts relevant functions:\n'
sed -n '60,120p' app/lib/encryption/encryption.ts
sed -n '380,430p' app/lib/encryption/encryption.ts

printf '\nsubscription model and updates near lastMessage:\n'
git ls-files | rg 'app/.+_subscription.*\.ts$|app/.*subscription.*\.ts$|app/.*model.*\.ts$' | head -50
rg -n -C 8 'class .*Subscription|model.*subscription|lastMessage|updateLastOpen|createOrUpdateSubscription|prepareUpdate' app/lib app/sagas/rooms.js app/actions/room.ts app/actions/rooms.ts | head -220

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 18472


🌐 Web query:

WatermelonDB v0.28.1 db.write prepareUpdate concurrent writes lock writer callbacks

💡 Result:

In WatermelonDB, database writes are strictly serialized to ensure data integrity [1][2]. The library employs a writer-locking mechanism where only one writer (an action defined via database.write() or a @writer method) can execute at any given time [1][2]. Other incoming write requests are queued until the active writer completes [3]. Regarding prepareUpdate and concurrent writes, note the following critical technical constraints: 1. Writer Exclusivity: Because only one writer can be active at once, concurrent write conflicts are prevented at the library level [1][2]. You do not need to implement manual locks for database consistency, as the queueing system manages this automatically [3][2]. 2. Synchronous Batching Requirement: The method prepareUpdate (along with prepareCreate, prepareMarkAsDeleted, etc.) must be executed and passed to database.batch() synchronously within the same writer context [1][4][5]. Performing asynchronous operations (like await) between preparing an update and executing it in batch() can lead to errors such as: "record.prepareUpdate was called on... but wasn't sent to batch synchronously -- this is bad!" [4][6]. 3. Correct Pattern: All logically connected read and write operations should be wrapped together inside a single database.write() block or an @writer method [1][2]. Any necessary asynchronous data fetching or calculations should be completed before preparing the operations, or the batching must occur within the same asynchronous writer block where the preparations are made [4][5]. If you need to nest writer operations, you must use callWriter to ensure the nested operation runs within the existing writer's exclusive lock [1][3]. Nesting writers without callWriter will result in errors or deadlocks because the system expects only one active writer [1].

Citations:


🌐 Web query:

WatermelonDB v0.28.1 Source Database/index.js writer lock prepareUpdate callback

💡 Result:

In WatermelonDB, the prepareUpdate method is used to prepare an update operation on a model record, but it does not execute the update immediately [1]. To ensure consistency and performance, WatermelonDB requires that these prepared operations are executed synchronously within a single database batch transaction [2][1]. If you receive an error stating that record.prepareUpdate was called but was not sent to batch synchronously, it indicates that the prepared update was created inside a writer but not passed to database.batch within the same execution context [2]. Key points regarding the writer lock and prepareUpdate: 1. Writer Locks: WatermelonDB enforces that all database modifications must occur within a Writer [3][4]. Only one Writer can execute at a time to ensure a consistent view of the database [3]. 2. Synchronous Requirement: When you use methods like prepareUpdate or prepareCreate, you are creating a "prepared" record [1]. These must be passed to database.batch or writer.batch synchronously [2][1]. 3. Correct Usage: You should perform your prepare calls inside the same writer block where you call the batch function [2]. Example of correct usage: await database.write(async => { const record = await collection.find('id'); const preparedUpdate = record.prepareUpdate(model => { model.field = 'new value'; }); await database.batch(preparedUpdate); }); If you perform asynchronous operations (e.g., fetching other data) between preparing an update and batching it, you risk breaking the synchronous requirement [2]. All necessary data should be prepared or fetched before the final batch execution [5].

Citations:


🌐 Web query:

@nozbe/watermelondb 0.28.1-0 GitHub source write writer lock pendingQueue changeRecords

💡 Result:

The version 0.28.1-0 of @nozbe/watermelondb was released on July 24, 2025 [1][2]. This release is primarily a version bump [1] and does not contain specific changes to the underlying architecture of writer locks, the pending queue, or changeRecords handling. In WatermelonDB, the core concurrency model remains unchanged in this version: 1. Writer Locks and PendingQueue: WatermelonDB enforces a strict concurrency model where only one writer can execute at a time [3]. When multiple write operations are initiated, they are placed in a pending queue [4]. If a writer is already active, subsequent writers are queued [4]. A common diagnostic warning occurs when many writers are queued, which can occur if operations are not batched efficiently [4][5]. 2. ChangeRecords: The concept of changeRecords is intrinsic to WatermelonDB's reactivity and synchronization system, where database mutations (create, update, delete) are tracked to propagate changes to observers and the synchronization engine [6]. 3. Development Recommendations: To avoid issues with writer locks and pending queues, the official documentation recommends batching database writes using database.batch rather than executing many individual writers [3][5]. For nested operations, the library requires the use of callWriter or callReader to manage context correctly within existing transactions [3][4]. For detailed implementation logic or migration guides, developers should refer to the standard documentation for the 0.28.x release series, as no breaking structural changes were introduced in 0.28.1-0 [7][8].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf 'Find lock-creation/update paths targeting encrypted subscription lastMessage:\n'
rg -n -C 5 \
  'lastMessage|lastMessageTs|updateLastOpen|createOrUpdateSubscription|prepareCreate|Object\.assign|subscription\.lastMessage|e2e_key_id|encrypted' \
  app/lib app/sagas/rooms.js app/actions/room.ts app/actions/rooms.ts \
  | rg -C 4 'lastMessage|updateLastOpen|createOrUpdateSubscription|Object\.assign|prepareCreate|e2e_key_id|encrypted' \
  | head -280

printf '\nList subscription-related tracked files:\n'
git ls-files app | rg 'subscription|room|rooms|subscriptions' | head -120

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 16619


🌐 Web query:

"Nozbe/WatermelonDB" "v0.28.1-0" "src/Database/index.js" "writer" "pendingQueue"

💡 Result:

In WatermelonDB, including version 0.28.1-0, database operations are managed by a queuing system to ensure data consistency [1][2]. The code mentioned in src/Database/index.js interacts with an ActionQueue (or WorkQueue in later versions) which handles the scheduling of readers and writers [1][3]. The pending queue system functions as follows: 1. Consistency and Locking: WatermelonDB enforces that only one writer can execute at a time [2]. All write operations (create, update, delete) must occur within a writer block [1][2]. When a write operation is initiated, it is added to a pending queue if another action is currently running [1][2]. 2. Handling Queued Actions: If multiple write operations are triggered simultaneously, they are placed in a queue to be executed sequentially [4]. The library provides warnings if a large number of actions accumulate in this queue, as this may indicate that previous actions are stuck or that the application is inefficiently dispatching individual writes instead of using batching [5][4]. 3. Nesting: If you need to perform a write operation from within another writer, you must use the callWriter method [2]. Direct execution of a nested writer will fail because the database lock is already held by the parent writer [2]. 4. Internal Mechanics: Internally, the Database class maintains an instance of the queue (often referenced as _actionQueue or _workQueue in the source) [1][3]. Methods like unsafeResetDatabase specifically trigger an abort of all pending actions to ensure a clean state during reset operations [1]. If you are encountering issues related to a large number of writers in the queue, the recommended practice is to aggregate changes into a single batch operation using db.batch instead of executing multiple individual writes [4][2].

Citations:


Guard against stale decrypted subscription snapshots.

decryptSubscription(lastMessage) runs before db.write. If another writer changes the same subscription between decryption and prepareUpdate, this callback can overwrite the newer lastMessage with the older decrypted value.

Capture the source last-message identity or timestamp before decryption. Validate it against the current record inside prepareUpdate; if it changed, re-read and decrypt the current record or skip and schedule a retry. Add a regression test for a newer lastMessage arriving during delayed subscription decryption.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/encryption/encryption.ts` around lines 398 - 417, Update the
subscription flow around decryptSubscription and prepareUpdate to prevent stale
decrypted snapshots from overwriting newer lastMessage values: capture the
source last-message identity or timestamp before decryption, validate it against
the current record inside prepareUpdate, and when it differs re-read and decrypt
the current subscription or skip it and schedule a retry. Add a regression test
covering a newer lastMessage arriving while decryption is delayed.

Comment on lines +153 to +173
it('does not leave a prepared subscription visible to a concurrent updateLastOpen', async () => {
const record = makeSubscriptionRecord();
(getSubscriptionByRoomId as jest.Mock).mockResolvedValue(record);
// Slow message lookup keeps createOrUpdateSubscription busy after it fetched the subscription.
(getMessageById as jest.Mock).mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(null), 10)));

const subscription = {
rid,
_id: rid,
lastMessage: { _id: 'msg-id', rid, msg: 'hi' }
} as any;

await Promise.all([
createOrUpdateSubscription(subscription, undefined as any),
updateLastOpen(rid, [{ _updatedAt: '2026-01-01T12:00:00.000Z' }])
]);

const loggedPendingChanges = (log as jest.Mock).mock.calls.some(([error]) => /pending changes/.test(error?.message));
expect(loggedPendingChanges).toBe(false);
expect(record.lastOpen).toEqual(new Date('2026-01-01T12:00:00.000Z'));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether the new test fails on the pre-fix implementation of createOrUpdateSubscription.
set -euo pipefail

fd -t f 'rooms.ts' app/lib/methods/subscriptions --exec sh -c '
  echo "=== $1 (current) ==="
  rg -n "db.write|prepareUpdate|prepareCreate|db.batch|getSubscriptionByRoomId|getMessageById" "$1"
' _ {}

echo "=== previous revision of rooms.ts ==="
git log --oneline -3 -- app/lib/methods/subscriptions/rooms.ts
git show HEAD~1:app/lib/methods/subscriptions/rooms.ts 2>/dev/null \
  | rg -n "db.write|prepareUpdate|prepareCreate|db.batch|getSubscriptionByRoomId|getMessageById" || echo "previous revision not available"

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 2005


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== current implementation ==="
sed -n '150,228p' app/lib/methods/subscriptions/rooms.ts

echo "=== previous implementation ==="
git show HEAD~1:app/lib/methods/subscriptions/rooms.ts | sed -n '150,228p'

echo "=== test ==="
sed -n '130,180p' app/lib/methods/subscriptions/rooms.test.ts

echo "=== mockDbBatch / db.write definitions in test file ==="
rg -n "mockDbBatch|db\.write|createOrUpdateSubscription|updateLastOpen" app/lib/methods/subscriptions/rooms.test.ts

echo "=== imports and setup ==="
sed -n '1,80p' app/lib/methods/subscriptions/rooms.test.ts

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 8595


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import asyncio

class MockDb:
    def __init__(self):
        self.lock = None
        self.batch_calls = []
    async def write(self, fn):
        while self.lock is not None:
            await asyncio.sleep(0)
        self.lock = True
        try:
            return await fn()
        finally:
            self.lock = None
    def batch(self, batch):
        self.batch_calls.append(batch); return asyncio.sleep(0)

class SubscriptionRecord:
    def __init__(self):
        self._preparedState = 'pending'
        self.lastOpen = 'old'
    def prepareUpdate(self, update):
        if self._preparedState is None:
            raise Exception('RecordAlreadyPrepared: pending changes')
        update(self)
        return self
    def update(self, data):
        self.lastOpen = data.get('lastOpen', self.lastOpen)

async def update_last_open_old(db, rid, records):
    sub = SubscriptionRecord()
    await db.write(lambda: (
        db.batch(sub.prepareUpdate(lambda s: s.update(records[0]))),
    )[0])
    return sub.lastOpen

async def create_or_update_old(db, rid, msg_delay=False):
    sub = SubscriptionRecord()
    batch = []
    batch.append(sub.prepareUpdate(lambda s: s.update({'lastMessage': {'_id': 'msg-id'}})))
    if msg_delay:
        await asyncio.sleep(0.01)
    await db.write(lambda: db.batch(batch))

async def main():
    result = []
    for name, cfunc in [('old', create_or_update_old), ('old', update_last_open_old)]:
        pass

for name, cfunc in [('createOrUpdateSubscription_old', create_or_update_old), ('updateLastOpen_old', update_last_open_old)]:
    print(name)
    async def run():
        db = MockDb()
        try:
            await asyncio.gather(cfunc(db, 'rid'), update_last_open_old(db, 'rid', [{'lastOpen': 'new'}]))
            print('ok')
        except Exception as e:
            print(f'raised: {e}')
    asyncio.run(run())

print('non-deterministic interleaving example: updateLastOpen acquires writer before createOrUpdateSubscription prepares')
async def run():
    db = MockDb()
    sub = SubscriptionRecord()
    batch = []
    batch.append(sub.prepareUpdate(lambda s: s.update({'lastMessage': {'_id': 'msg-id'}})))
    await asyncio.sleep(0)
    await db.write(lambda: db.batch(batch)).get() if hasattr((tmp := db.write(lambda: db.batch(batch))).get, '__await__') else None
PY

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 420


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== updateLastOpen implementation / mocks ==="
rg -n "function updateLastOpen|const updateLastOpen|updateLastOpen|db\.write|prepareUpdate|_preparedState" app/lib -g '*.ts' -g '*.tsx'

echo "=== focused updateLastOpen files ==="
fd -t f '^updateLastOpen\.(ts|tsx)$' app/lib
while IFS= read -r f; do
  echo "=== $f ==="
  sed -n '1,220p' "$f"
done < <(fd -t f '^updateLastOpen\.(ts|tsx)$' app/lib)

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 11238


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== updateLastOpen implementation ==="
sed -n '1,80p' app/lib/methods/updateLastOpen.ts

echo "=== Subscription service implementation ==="
sed -n '1,180p' app/lib/database/services/Subscription.ts | sed -n '1,160p'

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 2515


Make the concurrency ordering explicit.

updateLastOpen awaits getSubscriptionByRoomId(rid) before entering db.write, while this test resolves that lookup immediately. updateLastOpen can acquire and finish inside the writer lock before createOrUpdateSubscription performs any pre-write preparation, so the existing arrangement may pass even on the old implementation. Delay the subscription lookup so createOrUpdateSubscription prepares while updateLastOpen is inside its writer lock, and keep the positive mockDbBatch assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/methods/subscriptions/rooms.test.ts` around lines 153 - 173, The
concurrency test should force updateLastOpen to hold the writer lock while
createOrUpdateSubscription completes its preparation. In the test around
createOrUpdateSubscription and updateLastOpen, delay the getSubscriptionByRoomId
mock until the intended ordering is established, and retain a positive assertion
that mockDbBatch was called to verify the writer path executed.

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.

1 participant