firebase / firebase/firebase-android-sdk

RTDB: updateChildren() creating a new child raises no local events at locations observed only by filtered queries — silent, and dependent on invisible cache state

Open
#8,561 3 comments 0 reactions 0 assignees View on GitHub
api: database
Dominant language
Java
Stars
2.6k
Forks
710
Avg merge
2d 23h
Merged PRs (30d)
34

Description

## Summary

When a user write creates a child **implicitly** — `updateChildren()` at the
child path, or `setValue()` *below* it — under a location observed **only by
filtered queries** (e.g. `orderByKey().startAt(...)`), no local `child_added`
is ever raised. The write is persisted, syncs to the server normally, and
listeners eventually receive it as **server echo** — so while online the loss
is invisible (~50 ms latency instead of ~1 ms), and offline the app appears
frozen despite writing correctly.

`setValue()` **at** the child raises normally in the identical state. And the
behavior flips if an unfiltered (`loadsAllData`) listener has ever completed a
sync at the location this session — or is *seeded complete from persisted
tracked-query records of a previous session* — making the observable local
semantics of an identical write depend on invisible cache lineage.

The same logic exists verbatim in firebase-js-sdk, and we reproduced the
behavior empirically on the iOS SDK (macOS) as well — it appears inherited
from the common ancestral implementation.

## Root cause (line-anchored, `main` branch)

`ViewProcessor.applyUserOverwrite` (firebase-database
`core/view/ViewProcessor.java`):

1. For `changePath` deeper than one level (`childChangePath` non-empty — the
implicit-creation case), the new child must be synthesized:
`Node childNode = source.getCompleteChild(childKey)` (~line 377).
2. `WriteTreeCompleteChildSource.getCompleteChild` (~704–721) can only
succeed via (a) the event cache containing the child — never true for a
new key on a filtered view, since `CacheNode.isCompleteForChild` is
`(fullyInitialized && !filtered) || hasChild(key)`; (b) the
`optCompleteServerCache` argument; or (c)
`WriteTree.calcCompleteChild` (~336–349) passing the same
`isCompleteForChild` test against the filtered server cache — also false
for a new key.
3. `optCompleteServerCache` comes from
`SyncTree.applyOperationHelper` (~1044) →
`SyncPoint.getCompleteServerCache(emptyPath)` →
`View.getCompleteServerCache` (~99–110), which with an empty path returns
non-null **only for a `query.loadsAllData()` view** with a fully
initialized server cache. A location with only bounded listeners can never
supply it.
4. With `getCompleteChild == null`: `newChild = EmptyNode.Empty()` (~389,
comment "There is no complete child node available"), then
`!oldChild.equals(newChild)` (~392) compares Empty to Empty for the
brand-new key → treated as no change → `newViewCache = oldViewCache`
(~405). **No event, no warning, nothing.**

`setValue()` at the child takes the `childChangePath.isEmpty()` branch
(~373–375, `newChild = changedSnap`) and never consults the gate — which is
why it works.

firebase-js-sdk `ViewProcessor.ts` (`viewProcessorApplyUserOverwrite`)
contains the identical structure: `getCompleteChild` null →
`ChildrenNode.EMPTY_NODE` → `equals` skip.

## Why this is a bug and not acceptable conservatism

1. **Silent contract violation.** The documented behavior — local writes fire
events on local listeners immediately, before server acknowledgment — is
violated with no error, no log, no documented carve-out.
2. **Semantics depend on invisible cache lineage.** The same write at the
same location raises or doesn't depending on whether *some other* listener
(`loadsAllData`) ever completed a sync there this session, or left a
persisted tracked-query completeness record in a *previous* session. No
application developer can reason about this from the API surface. No
specification would choose "same write, same listener, different result
depending on how the cache got here."
3. **The conservatism is provably unnecessary for key-indexed queries.** For
`orderByKey` ranges, window membership is decidable from the key alone —
no old value is needed to know a `child_added` is due. Applying the merge
over `EmptyNode` for a key the filter accepts (or at minimum for
key-indexed filters) would be correct where the current code is silent.
(For value-indexed windows the old value genuinely matters, and a
conservative path is defensible — but it should not be silent.)

## Reproduction (no network manipulation needed)

Persistence enabled. Fresh app data (important: no persisted `loadsAllData`
completeness records for the path).

1. Attach `ref(L).orderByKey().startAt("x")` with a `ChildEventListener`;
never attach an unfiltered listener at `L` in the app's lifetime.
2. Let the initial sync complete (online).
3. `goOffline()` (offline only makes the loss *observable*; the local event
is equally absent online, masked by server echo).
4. `ref(L).child("x_new").updateChildren({"v": 1})`.
5. **Expected:** local `child_added` for `x_new`. **Actual:** nothing until
reconnect (server echo).
6. Controls, same state: `ref(L).child("x_new2").setValue(1)` → fires;
attaching an unfiltered listener at `L`, letting it sync, then repeating
step 4 → fires (the cache-lineage flip).

## Evidence from our app (verbose native logging)

Deaf (bounded-only location):

```
RepoOperation: update: /teams/.../events/2026-08/1788115390730_A3..._0
DataOperation: update: ... {us=222}
Persistence: Persisted user merge in 1ms
Persistence: Transaction completed. Elapsed: 3ms
(no "Updated tracked query keys", no EventRaiser output)
```

Identical state, `setValue` at the child:

```
Persistence: Persisted user merge in 0ms
Persistence: Updated tracked query keys (1 added, 0 removed) for tracked query id 4256
EventRaiser: Raising 1 event(s)
EventRaiser: Raising /teams/.../events/2026-08: CHILD_ADDED: { 1788121988664_FF..._0: 1 }
```

## Environment / impact

- flutterfire firebase_database 12.4.1 **and** 12.0.4 (pre/post Pigeon —
identical; native SDK behavior), Android 14/15; reproduced on macOS via the
iOS SDK.
- Impact: our offline-first sports-timing console wrote every live event with
`updateChildren` under bounded `orderByKey` listeners; during venue Wi-Fi
outages (events still arriving over BLE) the operator display froze
entirely while data was written and synced correctly — discovered only
after a two-month-old release because server echo masks the loss whenever
online.
- Workaround we shipped: `setValue()` at the child. Also effective:
maintaining any `loadsAllData` listener at the location (with its cost).

## Ask

Either raise the event when the filter can decide membership without the old
value (key-indexed case at minimum), or fail loudly / document prominently
that `updateChildren` under exclusively-filtered listeners does not raise
local events.

Contributor guide

Open the contributing guide

Research direction

Start in firebase-database/core/view/ViewProcessor.java, tracing applyUserOverwrite through WriteTreeCompleteChildSource.getCompleteChild and the SyncTree/SyncPoint/View cache path described in the issue. Compare the corresponding firebase-js-sdk ViewProcessor.ts behavior and run or add coverage for the filtered orderByKey updateChildren reproduction, its setValue control, and the loadsAllData control. Done means the intended local child_added behavior is covered and the chosen key-indexed, conservative, or explicit-failure behavior is consistent.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
databases
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.