Skip to content

[BUG] Two GET handlers in hub.py switch the shared storage's brain, and the background loops read it #152

Description

@RobertSigmundsson

Description

This one predates 3.x entirely and is latent rather than burning — we are not affected today, and
we mention it only because it is the kind of thing that is much cheaper to fix before someone
trips over it than after.

server/routes/hub.py calls storage.set_brain(...) from two GET handlers. The storage
object is the process-wide singleton, and the consolidation and decay loops read
storage.brain_id off that same singleton when they wake up. So a read-only request can decide
which brain the next scheduled maintenance pass operates on.

To Reproduce

  1. Start the server with consolidation enabled and at least two brains, A (active) and B.
  2. curl http://127.0.0.1:8000/hub/status/B — a GET, no body, nothing documented as mutating.
  3. Wait for the next consolidation tick.
  4. It runs against B.

Without a running server, the wiring is visible statically:

$ grep -n "set_brain" src/surreal_memory/server/routes/hub.py
139:        storage.set_brain(body.brain_id)      # POST /register   — expected
214:        storage.set_brain(body.brain_id)      # POST /sync       — expected
269:        storage.set_brain(brain_id)           # GET  /status/{brain_id}
296:        storage.set_brain(brain_id)           # GET  /devices/{brain_id}
350:        storage.set_brain(body.brain_id)      # POST /sync/merkle — expected

$ grep -n "brain_id = storage.brain_id" src/surreal_memory/server/app.py
136:  203:  237:  270:  338:

_consolidation_loop and _decay_loop are started at server/app.py:103 and :110 with that
same storage instance.

Expected Behavior

A GET handler answers a question about brain_id without changing what the process considers
the current brain. Either resolve the brain per request (a scoped context or a per-request
storage view), or restore the previous brain before returning.

Actual Behavior

The brain switch outlives the request. The get_storage override in server/app.py:505-537
does not reset the context for requests that arrive without an X-Brain-ID header, so nothing
puts it back.

Code Sample

# src/surreal_memory/server/routes/hub.py — inside the GET handler at line 261
@router.get(...)
async def hub_status(brain_id: str, ...):
    ...
    storage.set_brain(brain_id)      # line 269 — mutates the shared singleton

Environment

  • OS: Linux (x86-64)
  • Python version: 3.12.13
  • Surreal-Memory version: 3.0.3 (ac20df41)
  • Installation method: source

Additional Context

The file is byte-identical across v2.20.1 and v3.0.3, so this is long-standing rather than a
regression:

$ git diff v2.20.1 v3.0.3 -- src/surreal_memory/server/routes/hub.py
(empty)

We have not sent a patch because the fix is a design choice — per-request brain context versus
save/restore around the handler — and that is yours to make. Say which shape you want and we
will write it.

On our own install the path is latent rather than live: consolidation is enabled on a 24-hour
schedule, and nothing here calls /hub/*. It becomes live for anyone who does.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions