tim-smart / tim-smart/effect-atom

Bug: nested `Atom.family` can silently lose its dependency edge to GC

Open
#426 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
792
Forks
49
PR merge metrics
No merged PRs in 30d

Description

Summary

Atom.family's cache uses WeakRef + FinalizationRegistry so that an atom
nobody ever touches via the Registry can be garbage collected instead of
being pinned forever. That works correctly for the case family originally
shipped with (T extends Atom<any>, pre-a642c93):
once something calls get/set/subscribe on the returned atom, the
Registry's own nodes map holds a strong reference to it, which
transitively keeps the WeakRef alive too. Whether that constraint still
reflects the intended usage today is exactly what we'd like input on — see
below.

family's type signature is T extends object, not T extends Atom<any>
(widened from the latter in a642c93,
"allow Rx.family to return anything"). This makes a nested family —
Atom.family(group => Atom.family(key => Atom.make(...))) — type-check with
no cast, because the inner Atom.family(...) call itself returns
(arg) => Atom<A>, a function, which satisfies object.

For a nested family, outer(group) returns the middle-level family accessor
function
, not an atom. That function is never passed to get/set/
subscribe, so the Registry never holds a strong reference to it — nothing
anchors it. If the JS engine collects it during a period where it's otherwise
unreferenced, the outer family's WeakRef cache entry for group goes
stale. The next call to outer(group) re-runs f(group), minting a new
middle-level function with an empty cache — so outer(group)(key) now
returns a brand-new, disconnected leaf atom: same logical key, different
object identity. Anything that had already established a dependency edge on
the old leaf atom object (e.g. a derived atom's get(...) call) keeps
pointing at the orphaned original, which nothing will ever write to again.

Minimal reproduction
import { Atom, Registry } from "@effect-atom/atom"

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

// CONTROL — single-level family: survives the GC gap.
{
  const source = Atom.family((_key: string) => Atom.make(0))
  const derived = Atom.make((get) => get(source("k")))

  const registry = Registry.make({ defaultIdleTTL: 400 })
  registry.subscribe(derived, () => {})

  registry.set(source("k"), 1)
  await delay(30)
  ;(globalThis as any).gc?.()

  console.log(registry.get(derived)) // 1 — correct
}

// BUG — nested family: can go stale across the same GC gap.
{
  const source = Atom.family((_group: string) =>
    Atom.family((_key: string) => Atom.make(0)),
  )
  const derived = Atom.make((get) => get(source("g")("k")))

  const registry = Registry.make({ defaultIdleTTL: 400 })
  registry.subscribe(derived, () => {})

  registry.set(source("g")("k"), 1)
  await delay(30)
  ;(globalThis as any).gc?.()

  console.log(registry.get(derived)) // 0 under `node --expose-gc` — stale
}

Run with NODE_OPTIONS=--expose-gc for a deterministic repro; without it,
it's timing-dependent on natural GC and only intermittently reproduces.

The mechanism confirms directly: holding an ordinary strong reference to the
middle-level function across the same gap (const middle = source("g"); registry.set(middle("k"), 1), instead of registry.set(source("g")("k"), 1))
prevents the failure entirely, which is consistent with the intermediate
accessor being the unprotected object.

Where this bit us

Atom.family(courseId => Atom.family(userId => Atom.make(...))) — a natural
way to express a two-key family given the API and types as they stand today —
is what a production member-management table used to store a per-member edit
overlay. Rows intermittently reverted to stale server data after a
block/unblock/role-change, non-deterministically, worse the longer a real
user session ran (more time for GC to run).

The workaround was to flatten the nested family to a single level — either
one Atom.family keyed by a composite Data.tuple, or one atom per outer
key holding a map for the inner key — which avoids the multi-level
Atom.family shape entirely.

Why I think this is a library issue, not app misuse
  • The type signature (T extends object) explicitly permits f to return
    a non-Atom object — including another family accessor function — with
    no cast or suppression needed. Nesting is not an undocumented edge case;
    it's a pattern the types describe.
  • The commit that introduced this ("allow Rx.family to return anything")
    suggests a deliberate widening.
  • The failure mode is silent: no exception, no rejected promise, just a
    stale read that's indistinguishable from a value nobody ever changed.
Possible fixes (tradeoffs, not a strong opinion on which)
  1. Narrow the type back: family: <Arg, T extends Atom<any>>(...).
    Makes nested Atom.family calls a compile error. Zero runtime change,
    keeps the WeakRef guarantee intact for the case it was built for.
    Breaking change for any consumer currently nesting families.
  2. Detect non-atom returns at runtime (e.g. AtomTypeId in newAtom) and
    fall back to a strongly-referenced cache entry for those. Keeps nested
    families working, but reintroduces unbounded cache growth at whichever
    level isn't a real atom — proportional to the outer key's cardinality,
    which could be large depending on how a consumer nests (e.g.
    Atom.family(userId => Atom.family(commentId => ...)) for a busy feed).
  3. Document the hazard and recommend a composite-key single-level family
    (Atom.family((key: readonly [A, B]) => ...), using Data.tuple for
    correct Equal/Hash-based deduplication) as the supported pattern for
    multi-key families, without changing the type or runtime.

Happy to send a PR for whichever direction you'd prefer.

Environment
  • @effect-atom/atom / @effect-atom/atom-react 0.7.0
  • Node.js v22.22.1 (repro run under both plain and --expose-gc)

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the Atom.family cache and Registry interaction described in the issue, then run the nested-family reproduction under Node with NODE_OPTIONS=--expose-gc. Compare the nested and single-level controls across the GC gap, and confirm that the chosen behavior preserves dependency identity and avoids stale reads.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.