DEV Community

Hiroki Nomura
Hiroki Nomura

Posted on AI-assisted

Recording what the Firestore emulator sends to Cloud Functions, and replaying it in Rust

hidane is a reimplementation of the Cloud Firestore emulator as a single Rust binary: no JDK, the same gRPC, REST and WebChannel wire protocols, and it runs under firebase-tools with hidane exec -- firebase emulators:start. Its rule is that every claim of compatibility has a test behind it: scripts record how the official emulator answers, and hidane's test suite replays every recording.

Until now, one thing was missing under emulators:start: Firestore-triggered Cloud Functions. Since 0.2.3 hidane accepted the Functions emulator's trigger registrations, so functions loaded, but no event ever reached them. 0.4.0 delivers those events. This post is about how.

How the two emulators talk

When you run firebase emulators:start with Firestore and Functions, firebase-tools starts the Firestore emulator with --functions_emulator <host:port>. The Functions emulator then registers each Firestore trigger with the Firestore emulator. After a commit, the Firestore emulator sends an event for each document the commit changed to every trigger that watches it, as POST /functions/projects/{p}/triggers/{key} on the Functions emulator.

None of this is a documented API. It is a conversation between two local processes, and the only reliable description of it is what the official emulator actually sends.

Recording it from the outside

hidane treats the official emulator as a black box: its jar is never decompiled, and everything below was observed on the wire (docs/clean-room describes the policy).

tools/oracle/function_events.py starts the official emulator (v1.22.0, the version firebase-tools pins) three times, under an English locale:

  1. with --functions_emulator pointing at a small server of the script's own, which records every request it receives and answers 200, or 500, 404 or a late answer depending on the trigger's key;
  2. without the flag;
  3. with the flag pointing at a port nothing listens on.

Against each, it registers triggers the way firebase-tools does, writes over REST, gRPC Commit and the Write stream, and records for every step: the answer, every request the Functions emulator received (in order, every header, the body as sent), and the WARNING lines the emulator printed.

What it sends

v1 triggers (providers/cloud.firestore/eventTypes/document.*) get a compact JSON body:

{"data":{"value":…,"oldValue":…,"updateMask":{}},
 "context":{"eventId":…,"timestamp":…,"eventType":…,
            "resource":{"name":…,"service":"firestore.googleapis.com"}}}
Enter fullscreen mode Exit fullscreen mode

A few details only a recording shows: the v1 triggers of one change share the same eventId; updateMask is always empty; the timestamp is the commit time; and v1 triggers only ever see the (default) database.

v2 triggers (google.cloud.firestore.document.v1.*, including withAuthContext) get a binary-mode CloudEvent. The attributes travel as ce-* headers (ce-type, ce-source, ce-id, ce-subject, ce-time, ce-database, ce-document, and so on), and the body is a google.events.cloud.firestore.v1.DocumentEventData protobuf, base64-encoded, which firebase-tools decodes. For withAuthContext, the emulator sends ce-authtype: unknown and the same placeholder ce-authid whoever wrote.

The order: a Java HashMap

Ordering looked simple at first: all v1 events of a commit, then the v2 ones, document by document in the order the writes name them. But when several triggers watch the same document, they did not come in registration order, nor alphabetically.

They come in the iteration order of a Java HashMap sized for the project's triggers of that version. The model was fitted on 116 observations, and hidane reproduces it in presized_hash_order:

pub fn presized_hash_order<S: AsRef<str>>(keys: &[S]) -> Vec<usize> {
    // `(int) Math.ceil(n / 0.75)`, then `tableSizeFor`.
    let wanted = (keys.len() * 4).div_ceil(3).max(1);
    let capacity = u32::try_from(wanted.next_power_of_two()).unwrap_or(1 << 30);
    let mut ordered: Vec<(usize, u32)> = keys
        .iter()
        .enumerate()
        .map(|(i, key)| (i, spread(string_hash(key.as_ref())) & (capacity - 1)))
        .collect();
    ordered.sort_by_key(|&(i, bucket)| (bucket, i));
    ordered.into_iter().map(|(i, _)| i).collect()
}
Enter fullscreen mode Exit fullscreen mode

string_hash is Java's String.hashCode (base 31 over UTF-16 code units), spread folds the high half into the low one, and the table is the smallest power of two that holds n / 0.75 entries. Keys come out by bucket, and by insertion within a bucket. Functions that depend on the order in which their siblings fire are fragile, but a test suite that passes on the official emulator should not start failing because the order changed.

Timing and failures

Events go out after the commit, from one background queue, so a write never waits for a function. Each event waits for the answer to the previous one, for up to 20 seconds. hidane does the same, one event at a time from a task of its own.

When delivery fails (an error status, a refused connection, a timeout), the official emulator logs a WARNING and does not retry, for example Unsuccessful HTTP response when publishing event for … or Failed to publish event to {host}: Connection refused. Without --functions_emulator, every event is a WARNING that the Functions emulator host/port is not configured. hidane prints the same wording.

Batches, transactions, :batchWrite, gRPC Commit, the Write stream, deleted paths and cleared databases send events; imports and POST /reset do not.

Replaying it

crates/hidane/tests/function_events.rs replays the 133 steps of the three runs against hidane and compares the status of each answer, every request the Functions emulator receives (v1 bodies byte for byte, v2 bodies decoded, with times and IDs compared as labels), and every WARNING.

Recording also turned up something unrelated: Java prints the smallest double as 4.9E-324, and hidane printed 5.0E-324, over REST and WebChannel too. That is fixed now.

What hidane does not copy

Matching the official emulator is the goal, but not its bugs. Each of these is listed in docs/parity-exceptions.md:

  • A v2 event's ce-source (a function's event.source) names the project twice: //firestore.googleapis.com/projects/projects/{p}/databases/{d}. hidane names it once, as production does.
  • A write that changes nothing sends v2 updated and written events, and deleting a missing document sends v2 created and written; v1 triggers get neither. hidane sends no event for either, as production does.
  • POST /reset makes the official emulator forget every registered trigger, so functions stop firing, silently, until they reload. hidane keeps them.
  • A v2 trigger registered without a database makes every write to the project answer 500. hidane has it watch (default).

End to end

Recordings check the wire; the last check is whether functions actually receive the same data. tools/oracle/functions/ is a functions project (firebase-functions 6.6.0) with v1 onCreate / onUpdate / onDelete / onWrite, v2 onDocumentCreated / Updated / Deleted / Written, onDocumentWrittenWithAuthContext, a subcollection and a named database. It runs under firebase emulators:exec --only functions,firestore (firebase-tools 15.33.0), once with the official emulator and once under hidane exec.

The same 37 events reach the same functions with the same data. The official run has 6 more, the v2 events for the no-op write and the missing delete, and projects/projects/ in event.source; both transcripts are in results/functions-triggers-*.json.

Also in 0.4.0

  • A commit's problems are reported in the official emulator's order.
  • The Go, Python and Java server SDKs are verified against both emulators: documents, queries, aggregations, vector search, transactions and listeners.
  • A weekly CI job starts the official emulator, re-records a set of recordings and fails if one differs from the committed fixture.
  • The parity table now counts 20,224 recorded answers that the test suite replays.

Try it

curl -fsSL https://hidane.dev/install.sh | sh   # or Homebrew, npm, cargo, pub, Docker
hidane exec -- firebase emulators:start
Enter fullscreen mode Exit fullscreen mode

Other ways to install:

brew install hidane-dev/tap/hidane
npm install --save-dev hidane
cargo binstall hidane
docker run --rm -p 8080:8080 ghcr.io/hidane-dev/hidane
Enter fullscreen mode Exit fullscreen mode

hidane is also listed on DevHunt this week: https://devhunt.org/tool/hidane

If your tests get a different answer from hidane than from the official emulator, please open an issue with the Parity gap template. That is the most useful feedback.

Unofficial; not affiliated with Google. Firebase and Cloud Firestore are trademarks of Google LLC.

Top comments (0)