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

未关闭
#8,561 3 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
5/5
预计耗时
一周以上
新手友好度
48/100
Issue 类型
缺陷
描述清晰度
基本清楚
活跃度
活跃
技术栈
java
领域
databases

调研方向

从 firebase-database/core/view/ViewProcessor.java 开始,跟踪 applyUserOverwrite 经由 WriteTreeCompleteChildSource.getCompleteChild 以及 issue 中描述的 SyncTree/SyncPoint/View 缓存路径的执行过程。对比 firebase-js-sdk ViewProcessor.ts 中对应的行为,并针对经过过滤的 orderByKey updateChildren reproduction、其 setValue control 和 loadsAllData control 运行或补充 coverage。完成的标准是:预期的本地 child_added 行为已得到覆盖,并且所选择的按 key 索引、保守或显式失败行为保持一致。

由索引模型根据 Issue 内容生成。

描述

api: database

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.

主要语言
Java
星标
2.6k
派生
733
平均合并
2 天 15 小时
30 天内合并 PR
72

环境准备

  • 没有 Dockerfile 或 Docker Compose 文件
  • 没有 Pull Request 模板
  • 阅读贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

firebase/firebase-android-sdk 的其他 Issue

查看 firebase/firebase-android-sdk 的全部 Issue

相似的 Issue

更多 Java Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。