Bug description
On React Native (in-process storage, no worker), RxStorageInstance.count() of the abstract-filesystem storage intermittently resolves to the bare string "slow" instead of a { count, mode } object — the mode field of the slow-path count result arrives as the entire resolution value.
Captured by a validation wrapper sitting directly on the instance returned by getRxStorageExpoAsync().createStorageInstance(...) (nothing between the wrapper and premium code). On the reproduction run we also instrumented the wrapper to dump the query plan of the malformed occurrence — verbatim from the Metro console:
ERROR [count-recovery] typeof=string result="slow" collection=logs
ERROR [count-recovery] plan selectorSatisfiedByIndex=false sortSatisfiedByIndex=true index=["_deleted","logId"] selector={"level":{"$eq":"error"},"timestamp":{"$gt":1784791011966},"_deleted":{"$eq":false}}
(result= is JSON.stringify of the resolved value, so the resolution value is the 4-character string slow. typeof result === "string" per the first log field.)
selectorSatisfiedByIndex: false means the failing call took exactly the count() fallback branch — the only place in the whole plugin where the literal "slow" exists:
// storage-instance.js (dist/cjs, 17.4.0)
count = async (preparedQuery) =>
preparedQuery.queryPlan.selectorSatisfiedByIndex
? this.taskQueue.runRead((ctx) => abstractFilesystemCount(this, preparedQuery, ctx))
: { count: (await this.query(preparedQuery)).documents.length, mode: 'slow' };
The branch that runs constructs { count, mode: 'slow' } — and the awaiting caller receives just 'slow'.
Environment
|
|
| rxdb / rxdb-premium |
17.4.0 |
| storage |
getRxStorageExpoAsync() (abstract-filesystem over expo-opfs) |
| expo-opfs |
1.0.9 |
| platform |
React Native 0.85.3 / Expo SDK 56 / Hermes, iOS simulator |
| worker |
none — in-process (inWorker: false), multiInstance: false |
| bundler |
Metro; async functions in the premium dist are compiled via Babel _asyncToGenerator over native generators (see below) |
Reproduction (confirmed on device)
-
Fresh install → first login → cold first sync (replication pulling thousands of documents) with concurrent count/query traffic on a busy logs collection.
-
Reproduced deliberately on the iOS simulator with plan instrumentation in place: 2 occurrences in a single cold round — one ~40 s after login mid-bootstrap (log lines above), and one more on the app relaunch immediately after first sync completed, with an identical plan shape:
ERROR [count-recovery] typeof=string result="slow" collection=logs
ERROR [count-recovery] plan selectorSatisfiedByIndex=false sortSatisfiedByIndex=true index=["_deleted","logId"] selector={"level":{"$eq":"error"},"timestamp":{"$gt":1784791307087},"_deleted":{"$eq":false}}
-
In earlier capture rounds without the plan line it fired 10× across two cold-sync rounds (~90 s + ~45 s); never on warm relaunches (6/6 clean).
-
Always the same busy collection (logs); the failing query is an app query counting recent error-level log entries (selector on level + timestamp, not satisfied by the chosen ["_deleted","logId"] index → fallback branch).
Downstream symptom (how we noticed)
RxQuery count execution does:
var countResult = await this.collection.storageInstance.count(preparedQuery);
if (countResult.mode === 'slow' && !this.collection.database.allowSlowCount) {
throw newRxError('QU14', ...);
} else {
result = { result: countResult.count, ... };
}
With countResult === "slow" (a string), countResult.mode is undefined, so the QU14 gate doesn't trip; countResult.count is also undefined, and _setResultData(undefined) then throws QU18 "Malformed query result data". So the user-visible failure is an intermittent QU18 on a count query during first sync. (Note the slow-count gate is silently bypassed by the malformed value.)
Why we believe the value is born at the count() resolution itself
- Shape-logging brackets around
query() and findDocumentsById() at both the innermost (directly on the premium instance) and outermost (above all app wrappers) positions never fired while QU18 reproduced — those methods always resolved well-formed.
- The count() wrapper directly on the premium instance captured the string
"slow" as the raw resolution value, now with the query plan of the failing call (log above).
- The fallback branch's own
await this.query(...) must have resolved well-formed: if query() had resolved malformed, .documents.length would have thrown a TypeError and count() would have rejected — instead it resolved with "slow".
- With an app-side guard that detects the malformed result and recomputes the count via
query(), the QU18s disappear entirely and the app behaves correctly.
So: between constructing { count: N, mode: 'slow' } and the caller's await, the resolution value became the .mode constant.
The compiled shape on device (we suspect this is the key)
In the Metro-served bundle, the premium dist's async functions are not run as native Hermes async — Babel compiles them with _asyncToGenerator over a (native) generator. count() compiles to:
r.count = function () {
var _ref5 = (0, _asyncToGenerator.default)(function* (e) {
var _this3 = this;
return e.queryPlan.selectorSatisfiedByIndex ? this.taskQueue.runRead(...) : {
count: (yield this.query(e)).documents.length,
mode: "slow"
};
});
return function (_x7) { return _ref5.apply(this, arguments); };
}();
The generator suspends mid-object-literal (yield inside the first property value), resumes with the query result, loads the constant "slow", constructs the object, and returns it; Babel's asyncGeneratorStep then resolves the outer promise with the generator's completion value. The observed corruption is exactly "the generator's completion value came out as the last-loaded constant ("slow") instead of the constructed object" — i.e. a resume-mid-literal generator state issue in the Hermes + asyncToGenerator combination, intermittent and load-dependent (GC/timing-sensitive), not a logic bug in the plugin's JS as written.
Consistent with that: we could not reproduce on Node (V8, running the dist's native async functions — no generator transform) despite far heavier concurrency than the device ever sees:
getRxStorageFilesystemNode (same abstract-filesystem core + TaskQueue, inWorker: false), 15,000 docs written in paced concurrent batches while two loops issued index-satisfied count() bursts and two loops issued query() calls: 2,636,214 counts — 0 malformed.
- A variant hammering the fallback (
mode: 'slow') branch specifically: 13,800 counts — 0 malformed.
(Node repro harness available on request; it may still be useful as a scaffold if you want to chase this with a Hermes-based runner instead.)
Workaround we ship
A wrapper directly around the storage instance validates every count resolution and self-heals by deriving an exact count from query():
const count = instance.count.bind(instance);
instance.count = async (preparedQuery) => {
const result = await count(preparedQuery);
if (result && typeof result === 'object' && typeof result.count === 'number') {
return result;
}
console.error(`[count-recovery] typeof=${typeof result} result=${JSON.stringify(result)?.slice(0, 200)}`);
const queryResult = await instance.query(preparedQuery);
const parsed = typeof queryResult === 'string' ? JSON.parse(queryResult) : queryResult;
// report 'fast': the count is exact, and 'slow' would trip rx-query's
// allowSlowCount gate (QU14), defeating the recovery
return { count: parsed.documents.length, mode: 'fast' };
};
With this in place the storage self-heals and the QU18s are gone.
Ask
-
Defensive restructure of the fallback branch so the transpiled generator doesn't suspend mid-object-literal, e.g.:
const queryResult = await this.query(preparedQuery);
const slowCount = { count: queryResult.documents.length, mode: 'slow' };
return slowCount;
This sidesteps the suspect codegen shape entirely and costs nothing.
-
Consider validating/normalizing count()'s resolution shape in non-worker mode the way query/findDocumentsById/bulkWrite are already wrapped there (JSON.parse-if-string in createStorageInstance) — a malformed count currently slips past both the QU14 gate and any storage-level check.
-
If you want to chase the root cause: this smells like a Hermes (or asyncToGenerator-interaction) bug worth escalating upstream; we're happy to run instrumented premium builds on the failing app — the cold-first-sync repro is reliable (≥1 hit per cold round, 10 hits over two earlier rounds).
We can share: the full Metro logs of the reproduction, the app-side wrapper, and the Node harness.
Bug description
On React Native (in-process storage, no worker),
RxStorageInstance.count()of the abstract-filesystem storage intermittently resolves to the bare string"slow"instead of a{ count, mode }object — themodefield of the slow-path count result arrives as the entire resolution value.Captured by a validation wrapper sitting directly on the instance returned by
getRxStorageExpoAsync().createStorageInstance(...)(nothing between the wrapper and premium code). On the reproduction run we also instrumented the wrapper to dump the query plan of the malformed occurrence — verbatim from the Metro console:(
result=isJSON.stringifyof the resolved value, so the resolution value is the 4-character stringslow.typeof result === "string"per the first log field.)selectorSatisfiedByIndex: falsemeans the failing call took exactly the count() fallback branch — the only place in the whole plugin where the literal"slow"exists:The branch that runs constructs
{ count, mode: 'slow' }— and the awaiting caller receives just'slow'.Environment
getRxStorageExpoAsync()(abstract-filesystem over expo-opfs)inWorker: false),multiInstance: false_asyncToGeneratorover native generators (see below)Reproduction (confirmed on device)
Fresh install → first login → cold first sync (replication pulling thousands of documents) with concurrent count/query traffic on a busy
logscollection.Reproduced deliberately on the iOS simulator with plan instrumentation in place: 2 occurrences in a single cold round — one ~40 s after login mid-bootstrap (log lines above), and one more on the app relaunch immediately after first sync completed, with an identical plan shape:
In earlier capture rounds without the plan line it fired 10× across two cold-sync rounds (~90 s + ~45 s); never on warm relaunches (6/6 clean).
Always the same busy collection (
logs); the failing query is an app query counting recent error-level log entries (selector onlevel+timestamp, not satisfied by the chosen["_deleted","logId"]index → fallback branch).Downstream symptom (how we noticed)
RxQuerycount execution does:With
countResult === "slow"(a string),countResult.modeisundefined, so the QU14 gate doesn't trip;countResult.countis alsoundefined, and_setResultData(undefined)then throws QU18 "Malformed query result data". So the user-visible failure is an intermittent QU18 on a count query during first sync. (Note the slow-count gate is silently bypassed by the malformed value.)Why we believe the value is born at the count() resolution itself
query()andfindDocumentsById()at both the innermost (directly on the premium instance) and outermost (above all app wrappers) positions never fired while QU18 reproduced — those methods always resolved well-formed."slow"as the raw resolution value, now with the query plan of the failing call (log above).await this.query(...)must have resolved well-formed: ifquery()had resolved malformed,.documents.lengthwould have thrown a TypeError andcount()would have rejected — instead it resolved with"slow".query(), the QU18s disappear entirely and the app behaves correctly.So: between constructing
{ count: N, mode: 'slow' }and the caller'sawait, the resolution value became the.modeconstant.The compiled shape on device (we suspect this is the key)
In the Metro-served bundle, the premium dist's
asyncfunctions are not run as native Hermes async — Babel compiles them with_asyncToGeneratorover a (native) generator.count()compiles to:The generator suspends mid-object-literal (
yieldinside the first property value), resumes with the query result, loads the constant"slow", constructs the object, and returns it; Babel'sasyncGeneratorStepthen resolves the outer promise with the generator's completion value. The observed corruption is exactly "the generator's completion value came out as the last-loaded constant ("slow") instead of the constructed object" — i.e. a resume-mid-literal generator state issue in the Hermes +asyncToGeneratorcombination, intermittent and load-dependent (GC/timing-sensitive), not a logic bug in the plugin's JS as written.Consistent with that: we could not reproduce on Node (V8, running the dist's native async functions — no generator transform) despite far heavier concurrency than the device ever sees:
getRxStorageFilesystemNode(same abstract-filesystem core + TaskQueue,inWorker: false), 15,000 docs written in paced concurrent batches while two loops issued index-satisfiedcount()bursts and two loops issuedquery()calls: 2,636,214 counts — 0 malformed.mode: 'slow') branch specifically: 13,800 counts — 0 malformed.(Node repro harness available on request; it may still be useful as a scaffold if you want to chase this with a Hermes-based runner instead.)
Workaround we ship
A wrapper directly around the storage instance validates every count resolution and self-heals by deriving an exact count from
query():With this in place the storage self-heals and the QU18s are gone.
Ask
Defensive restructure of the fallback branch so the transpiled generator doesn't suspend mid-object-literal, e.g.:
This sidesteps the suspect codegen shape entirely and costs nothing.
Consider validating/normalizing
count()'s resolution shape in non-worker mode the wayquery/findDocumentsById/bulkWriteare already wrapped there (JSON.parse-if-string increateStorageInstance) — a malformed count currently slips past both the QU14 gate and any storage-level check.If you want to chase the root cause: this smells like a Hermes (or
asyncToGenerator-interaction) bug worth escalating upstream; we're happy to run instrumented premium builds on the failing app — the cold-first-sync repro is reliable (≥1 hit per cold round, 10 hits over two earlier rounds).We can share: the full Metro logs of the reproduction, the app-side wrapper, and the Node harness.