Skip to content

Extract per-device state out of RequestHandler (#11)#74

Merged
ff225 merged 9 commits into
mainfrom
fix/sciot-011-device-state-manager
Jul 23, 2026
Merged

Extract per-device state out of RequestHandler (#11)#74
ff225 merged 9 commits into
mainfrom
fix/sciot-011-device-state-manager

Conversation

@ff225

@ff225 ff225 commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Closes #11. First of three steps toward #45; unblocked now that #9 has landed.

Why

RequestHandler kept every piece of runtime state in class-level dictionaries — device_profiles, variance_detector, offloading_states, _layer_sizes_cache — mutated from both the ASGI thread and the background I/O thread with no locking.

What changed

New src/server/communication/device_state.py. DeviceStateManager owns the device profiles, the EMA tables, the variance detectors, the adaptive-risk state and the layer-sizes cache, all behind one RLock.

Per-device variance detectors. The detector used to be a single class-level instance, so heterogeneous devices reporting the same layer indices polluted each other's variance statistics and skewed offloading decisions. Each device now has its own.

Thread-safety. The debug-JSON snapshot is now taken under the lock before being handed to the background writer; it previously serialized a dict that the ASGI thread could be mutating.

Backwards compatibility. RequestHandler.device_profiles remains a live view of the manager's mapping, since http_server.py:233 and the dashboard read it directly as a plain dict. No caller outside request_handler.py changes.

Drive-by cleanup. The inference-cycle telemetry path recomputed layer_sizes via _load_stats() — data already in scope — behind a hasattr() guard that was always true.

uv.lock

The first commit regenerates uv.lock, which was unparsable by uv 0.11+ and blocked every uv command, including CI's:

Dependency `google-cloud-firestore` has missing `source` field
but has more than one matching package

The package is forked across two versions by platform marker; the analysis extra listed it without a version/source to disambiguate. Regenerated with uv lock (224 packages resolved). Included here because nothing else could be verified without it.

Testing

244 passed, 1 skipped (fast suite + HTTP end-to-end). device_state.py at 100% coverage; new tests/unit/test_device_state.py covers the EMA maths, per-device isolation, snapshot copying, and an 8-thread concurrency test. ruff clean on all touched files.

Notes for the reviewer

ff225 added 3 commits July 17, 2026 14:34
The committed lock listed `google-cloud-firestore` in the `analysis` extra
without a version/source, while the package is forked across two versions by
platform marker. uv 0.11+ refuses to parse it:

    Dependency `google-cloud-firestore` has missing `source` field
    but has more than one matching package

Every uv command failed on this, so the environment could not be synced at all.
Regenerated with `uv lock` (224 packages resolved).
RequestHandler kept every piece of runtime state in class-level dicts mutated
from both the ASGI thread and the background I/O thread, with no locking.

Introduce DeviceStateManager, which owns the device profiles, the EMA tables,
the variance detectors, the adaptive-risk state and the layer-sizes cache
behind a single RLock. RequestHandler.device_profiles stays as a live view of
the manager's mapping, since http_server and the dashboard read it directly.

The variance detector is now scoped per device: it used to be a single
class-level instance, so heterogeneous devices reporting the same layer indices
polluted each other's variance statistics and skewed offloading decisions.

Also drop a redundant _load_stats() call in the inference-cycle telemetry path
that recomputed layer sizes already available in scope, behind a hasattr()
guard that was always true.

First step of #45; unblocked now that #9 has landed.
Two tangled problems in the same code path; the fixes share the same lines, so
they land together.

#72 — Edge per-layer times were smoothed twice. `track_inference_time` applied
an EMA with a hard-coded alpha=0.2 to `self.inference_times` — which is the
very dict `device_profiles[device_id]["edge_inference_times"]`, passed by
reference — and the request handler then applied the configured EMA on top.
Edge times were therefore smoothed twice while device times were smoothed once,
so the two sides of the offloading cost comparison responded to change at
different rates; and the `ema_alpha` setting added in #9 never reached the
first application.

`Edge.run_inference` already measures and returns the raw per-layer times, so
the decorator's copy was redundant. It is now instrumentation only, and
`DeviceStateManager.update_edge_times` is the single place that smooths, with
the configured alpha. The timing there now uses perf_counter rather than
time.time, since it became the authoritative measurement.

#73 — `Edge.variance_detector` was a second, process-global detector fed from
the same decorator. Nothing ever read it: no production code calls
`should_retest_offloading()` on it, so it only burned time on the hot path and
logged re-test warnings that triggered no re-test. Removed; the per-device
detectors from #11 remain the only ones.

Also drops the now-unwritten `inference_times` plumbing from ModelManager,
`save_inference_times` (no callers), and a commented-out __init__ referencing
attributes that no longer exist. `scripts/analysis/variance_analysis.py` moves
to the per-device API.
The simulation CSV handle, its writer and its row counter were class-level
attributes on RequestHandler, opened and closed from ASGI request threads while
the background I/O thread wrote rows through them. Three races followed:

- `close_simulation_csv` cleared `csv_file` while the I/O thread was inside
  `_write_csv_row`, so a queued row could reach a closed file and raise
  "I/O operation on closed file";
- `_write_csv_row` tested `csv_writer` and then used `csv_file`, with nothing
  keeping the two consistent across the gap;
- `inference_counter += 1` ran on concurrent request threads, and
  `set_simulation_csv` reset it underneath them, so ids could collide.

InferenceCycleRecorder owns all of it behind one lock: the check and the write
happen together, a row that arrives after a close is dropped and reported
rather than written, and row ids are handed out atomically. A row that fails to
serialize is now logged and swallowed instead of propagating into the
background I/O worker.

The debug-JSON writing moves here too, since it shares the same background
thread and per-server-start lifecycle.

`RequestHandler.set_simulation_csv` / `close_simulation_csv` stay as delegating
classmethods; `http_server` is unchanged.

Second step of #45.
…t path (#45)

Third and last step of #45.

RequestHandler built the offloading algorithm, ran it, unpicked its candidate
list and published the decision telemetry inline. OffloadingService now owns
that, and returns an OffloadingDecision carrying everything telemetry needs to
explain the choice: the layer, the reason, the candidates, the switch penalty
and the estimated cost.

Deriving the switch penalty and the cost estimate moves onto the decision
object, where they belong — they are properties of the decision, not of the
request. The handler is left orchestrating: it asks for a decision and reports
it.

Building the telemetry *events* stays in the handler on purpose: those need
`message_data`, and moving them would couple the service to the transport
message shape.

The algorithms themselves are untouched: `src/server/offloading_algo/` has no
changes. Only the caller moved.

Adds tests for handle_device_inference_result, which #45 flagged as the most
critical component with no dedicated tests: the EMA updates for device and edge
layers, the variance recording, the edge-inference skip conditions, the
offloading decision (including forced-local, and that the decision sees the
freshly smoothed times), the background I/O scheduling for every output, and
that the debug snapshot is immune to later mutation of the live tables.

request_handler.py: 1158 -> 933 lines; coverage 48% -> 61%.
ff225 and others added 4 commits July 23, 2026 09:37
Extract OffloadingService and test the inference hot path (#45)
Serialize simulation CSV writes behind a recorder (#10)
…wner

Single owner for edge timings and variance (#72, #73)
@ff225
ff225 merged commit c9a413f into main Jul 23, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Review and reduce class-level mutable state in RequestHandler

1 participant