Skip to content

LRUCache byte cap can starve tile loading into a silent, permanent livelock (and discards fully parsed tiles while full) #1689

Description

@stephent

Environment

  • 3d-tiles-renderer 0.5.1, three.js r170, React wrapper (/r3f)
  • Google Photorealistic 3D Tiles via GoogleCloudAuthPlugin (also using
    GLTFExtensionsPlugin + Draco, TileCompressionPlugin,
    UpdateOnChangePlugin, TilesFadePlugin, LoadRegionPlugin)
  • Chrome on macOS (Apple M1 Pro), narrow-FOV ground-level camera
    (photography-planning app: the camera FOV follows real lens focal
    lengths, so ~7° vertical FOV views are routine)

Summary

When the set of tiles demanded by the current view (everything the
traversal marks used at the configured errorTarget) exceeds
lruCache.maxBytesSize, loading does not degrade — it stops permanently
and silently:

  1. Admission is hard-gated on !lruCache.isFull()
    (TilesRendererBase.js:857,
    :1302–1306),
    so no new tile can enter the download queue while the cache is at its
    byte cap.
  2. A tile that finishes downloading and parsing while the cache is
    full is discarded outright
    (TilesRendererBase.js:1689–1695).
    The code comment says "it will be loaded again later from the disk
    cache if needed" — but the network transfer may be re-paid (Google
    serves private, max-age=14400, so it can come from disk, though
    other servers may not allow caching) and the parse cost is always
    re-paid
    . Worse, in the situation below it is never loaded again at
    all.
  3. LRUCache.unloadUnusedContent only evicts tiles that are not in the
    used set. When the demanded working set itself is what exceeds
    maxBytesSize, everything resident is used, nothing can be evicted,
    and points 1–2 hold forever: every in-flight parse discards, the
    queues drain, tiles-load-end fires, and the renderer sits idle at a
    partially refined scene that will never improve. There is no event,
    flag, or stat that reports this state — from the outside it is
    indistinguishable from a completed load.

A milder version of the same root cause appears while the demanded set is
near (not over) the cap: loading completes but with heavy churn, because
tiles discarded at point 2 are re-requested and re-parsed on later frames.

Observed numbers (Google Photorealistic 3D Tiles)

Churn (demanded set near the cap). errorTarget: 8,
maxBytesSize: 600 MB, rural/suburban area, wide FOV. The load settled
only after 13,848 tile requests for 5,684 loaded models (2.4×), with
cachedBytes pinned within 0.03% of maxBytesSize throughout and
download admission visibly throttled. Raising maxBytesSize to 1 GB made
the identical view load with zero churn (requests == cached items) and a
working set of ~777 MB — i.e. all of the excess traffic was
discard-and-refetch.

Livelock (demanded set over the cap). Same site, errorTarget: 8,
maxBytesSize: 1 GB, camera zoomed to a ~7.5° vertical FOV (SSE demands
~7× finer geometric error, and the narrow frustum reaches deep into the
distance). Downloads (30 parallel) outran parsing (parseQueue.maxJobs
6, main thread), the cache crossed the byte cap mid-drain, and every
parse completing after that point — ~5,500 tiles of bandwidth and decode
— was discarded. Two diagnostics snapshots taken 9 minutes apart then
show:

  • loaded-model count byte-identical (5,850 → 5,850),
  • cachedBytes parked just above maxBytesSize,
  • all queues empty, tiles-load-end long since fired,
  • camera translation (which re-runs the traversal) unable to admit a
    single tile — queueTileForDownload exits at the isFull() gate.

The scene stays frozen at whatever refinement level the cap caught, with
no error and no signal.

Suggestions

Any of these would help, roughly in order of preference:

  1. Backpressure instead of discard. Gate download admission on
    projected bytes (in-flight + resident) rather than discarding
    completed parses. A completed parse is the most expensive artifact in
    the pipeline; dropping it to enforce a cap that admission already
    overshot pays the cost twice (or, in the livelock, infinitely often —
    the same tiles are demanded, admitted when momentarily under cap,
    parsed, and discarded again).
  2. Detect and surface starvation. When isFull() is true, the used
    set alone exceeds maxBytesSize, and the queues are empty, the
    renderer is wedged. An event (e.g. cache-starved) and/or a stat
    would let applications respond — ours now inflates errorTarget
    until demand fits, which un-wedges it within a few seconds.
  3. Automatic error-target pressure. The renderer itself could treat
    "used set > maxBytesSize" as implicit error-target inflation
    (analogous to how EnforceNonZeroErrorPlugin handles a different
    degenerate case), guaranteeing the cap can never freeze refinement —
    a coarser scene instead of a stuck one.
  4. At minimum, a docs note on maxBytesSize: it is a hard admission
    gate, not a soft target; if a view's demanded working set exceeds it,
    loading stalls permanently, and tiles finishing while full are
    re-downloaded/re-parsed later.

Workaround we shipped

An application-side watchdog: once per second, if lruCache.isFull() and
the download queue is idle for >2.5 s, multiply errorTarget by 1.5
(compounding, capped ×4) and dispatch needs-update; the coarser demand
marks deep tiles unused, the unloader frees them, and admission resumes.
The multiplier is held (not decayed on a byte threshold — that oscillates
and re-downloads the evicted set each cycle) until the view/preset
changes. This works, but it reconstructs state the renderer already knows
internally — hence suggestion 2.

Happy to provide the full diagnostics snapshots or test against a branch.


Issue details drafted with Claude Code/Fable

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions