Skip to content

abstract-filesystem: count() slow-path intermittently resolves to the bare string "slow" instead of { count, mode } (React Native / Hermes, Babel asyncToGenerator, in-process) #8841

Description

@kilbot

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

  1. 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.
  2. 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).
  3. 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".
  4. 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

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions