Fix LuxonisTracker robustness and add test coverage - #460
Conversation
The tracker was excluded from pyright, pyleft and coverage, and had no
tests at all. Enabling those gates surfaced a set of bugs that break
real training runs:
- An unreachable MLflow server stalled the training loop: the
`experiment` property retried the full init (`set_experiment` +
`start_run`) on every access, costing ~13 s per logged value. Failed
initialization now backs off for a minute.
- Logging raised `KeyError('mlflow')` when mlflow was not importable,
even though initialization had already reported the failure as
handled.
- `log_image` raised `ValueError` for any name without a `/`.
- `close()` never flushed the TensorBoard writer nor finished the WandB
run; finalization only existed in luxonis-train's subclass.
- `save_logs_locally` crashed on NumPy scalars, never cleared the
buffer, overwrote images that shared an index, and assumed every image
had exactly three channels.
- The Cloudflare secret header was sent as a `SecretStr`, i.e. masked to
`**********`.
- `pip install luxonis-ml[tracker]` could not import the package:
`mlflow_plugins` imported mlflow at module scope and `tracker`
imported cv2, neither of which is in the `tracker` extra.
Other changes:
- MLflow calls are dispatched by name instead of by comparing bound
methods, so buffering can no longer silently drop a call, and buffered
calls are replayed in their original order.
- The buffer is bounded (500 calls, at most 50 images) so a long outage
cannot exhaust memory.
- `close()` is idempotent, takes a run status, and reports rather than
raises when a backend fails to shut down. `LuxonisTracker` is now a
context manager.
- `experiment` returns `{}` instead of `None` on non-zero ranks, and
`rank_zero_only` keeps the wrapped signature.
- Non-zero ranks wait for the rank-zero run directory instead of
sleeping for one second and hoping.
- `MLFLOW_HTTP_REQUEST_MAX_RETRIES` is only defaulted, not overwritten,
and only when MLflow is enabled.
- `version` returns the run number instead of a constant `1`.
- WandB logging goes through the `Run` returned by `init` rather than
the module, and `log_matrix` no longer passes an explicit step.
- TensorBoard hyperparameters and matrices are no longer silently
mangled (`None` values, arrays over 1000 elements).
Tests: 46 new tests, 100% coverage of `luxonis_ml/tracker`, including an
integration test that runs against a real `mlflow server` subprocess.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #460 +/- ##
==========================================
+ Coverage 95.27% 95.58% +0.30%
==========================================
Files 181 186 +5
Lines 15015 16106 +1091
==========================================
+ Hits 14306 15395 +1089
- Misses 709 711 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
`test_mlflow_logging` asserted that MLflow system metrics get enabled, which only happens when psutil is installed. psutil is not a luxonis-ml dependency, so the assertion failed on CI while passing locally. Both `find_spec` branches are already covered deterministically by `test_system_metrics_need_psutil` and `test_gpu_metrics_need_pynvml`. `test_logs_survive_a_server_outage` failed on Windows because `mlflow server` serves through a separate worker process: terminating only the launched process left the port open, so the logs that were supposed to be buffered were sent successfully. The server is now started in its own process group and stopped with `taskkill /T` on Windows, and `stop()` waits until the port stops answering. This also stops the fixture from leaking server processes on Windows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A multi-agent review of this branch surfaced 15 verified defects, mostly in the new MLflow buffering and teardown machinery. Every fix below has a regression test that was checked to fail without it. MLflow buffering: - A call MLflow rejected for good stayed at the head of the buffer, where it was retried before - and instead of - every later call, so nothing reached the server until 500 further calls evicted it. Replay now sends the call queued behind a failing one to tell a rejected call apart from an unreachable server, and drops the former. - The buffer evicted strictly the oldest entry, so `log_hyperparams`, normally the first call of a run, was the first thing lost during a long outage. Hyperparameters and artifacts now go last, as no later call repeats them. - Every buffered call pushed the retry backoff another minute out, so the last-chance reconnect in `close` never actually tried. It now waives the backoff. - A buffered artifact was only copied at save time, by which point checkpoint callbacks had already deleted the file. It is now copied when the call is buffered, into `artifacts/<n>/`, which also stops two artifacts sharing a file name from overwriting each other. - `save_logs_locally` truncated the file it had written earlier while also clearing the buffer, so a second call discarded the first one's records. It now appends. - The buffer-full warning was emitted per evicted call, which floods the log for the rest of an outage. It is now warned about once per limit. Initialization and teardown: - `_init_wandb` stored the module before `wandb.init` ran, so a failed init looked like a finished one and every later log raised `KeyError: 'wandb_run'` instead of initializing again. `_init_mlflow` had the same shape and is fixed with it. - `upload_artifact_to_mlflow` was the one public logging method without a rank gate, so a worker buffered artifacts that its rank-gated `close` then never sent nor saved. - `save_logs_locally` was the only teardown step in `close` that could raise, which left the other backends unfinalized and unrecoverable, as `close` refuses to run twice. - Reading `experiment` after `close` re-initialized MLflow, leaving a fresh run that nothing could ever end. - `_mlflow_call` read the whole `experiment` property, so buffering an MLflow call initialized TensorBoard and WandB as a side effect. Run names: - `_get_latest_run_name` returned as soon as any numbered run existed, so it never waited for rank zero and a worker joined the previous training run. It now waits for a run that was not there yet, falling back to the newest existing one after a grace period with a warning to pass `run_name` explicitly. - `isnumeric` was used as an `int` guard, so a directory such as `½-baseline` crashed the constructor. `isdecimal` matches what `int` accepts. Not fixed here: `close(status="success")` clobbers the WandB exit code for luxonis-train's `LuxonisTrackerPL._finalize`, which calls `self.close()` without forwarding its own status. The fix belongs on that side. Tests: 64 tracker tests, 100% coverage of `luxonis_ml/tracker`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`close` ends the MLflow run, but `_mlflow_call` kept sending. MLflow opens a fresh run for a call that finds no active run, and `close` runs only once, so that run stayed open forever. The exported model and the archive of a luxonis-train run landed in it instead of the training run. The tracker now refuses such a call and warns. It does not buffer it either, because no later `close` would flush the buffer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_enforce_buffer_limit` dropped the first expendable entry. Once every older call was a hyperparameter or an artifact, the only expendable entry was the call that had just arrived, so the tracker evicted the newest one. A long outage with one checkpoint per epoch thus discarded every later metric, and the single warning claimed the opposite. The newest call is now excluded from the candidates. Hyperparameters and artifacts still go last, but they give way once nothing older is left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backoff covered a failed initialization only. A server that died after a good start left `mlflow_initialized` set, so every later value took the replay path and paid for two failed round trips: one for the head of the buffer and one for the probe behind it. The module docstring promises one attempt per minute, and the replay path broke that promise. The replay path now honours the backoff, and a failed replay arms it. `close` waives the backoff whether or not the init succeeded, so its last reconnect still runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cv2.imwrite` reports a full disk or a read-only directory through its return value, and raises nothing. `_save_image_locally` discarded that value and returned the path anyway, so `local_logs.json` named PNG files that were never written. The tracker then reported a successful save and the user believed the images survived an MLflow outage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tracker warns once for a full buffer, because an eviction repeats for every further call. It never cleared that record, so a second outage in the same run dropped metrics and images in complete silence. The user saw gaps in the MLflow curves with nothing in the log to explain them. The record now resets whenever the buffer drains, either to the server or to the local fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A buffered artifact went to `artifacts/<n>/`, where `n` came from a counter that every tracker started at zero. A sweep gives each trial its own tracker over one shared run directory, so the second trial copied its checkpoint over the first one's. Both records in `local_logs.json` then named the same file and the first checkpoint was gone. The counter now skips a directory that already exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three preceding commits each edited this paragraph and left one short line behind. Only the line breaks change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test made a directory read-only with `chmod`. Windows ignores that for a directory, so `cv2.imwrite` succeeded there and the test failed on the windows-latest shard. The test now patches `cv2.imwrite` to return False. It checks that `_save_image_locally` honours the return value, which is the behaviour under test, and no longer depends on the permission model of the host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tracker imports `torch`, `wandb`, and `mlflow` only when the matching flag is on, but nothing declared the first two. A user had to know to install them, and pyright could not resolve either import, so both carried a `# pyright: ignore[reportMissingImports]`. `tensorboard` and `wandb` are now extras of their own, and `all` pulls them in. The `tracker` extra stays free of them, because a run only needs the backends it turns on. Both suppressions are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every `Any` in the tracker and in its test doubles is replaced by the type that actually occurs: - `experiment` becomes an `Experiment` TypedDict, so each backend key carries its own handle type. The real `SummaryWriter` and the WandB `Run` come from a `TYPE_CHECKING` import, which keeps both SDKs optional at runtime. - A buffered call carries `MLflowArg`, and `local_logs.json` is a `LocalLogGroups` TypedDict of `LogRecord` entries. - `LogValue` names what the local fallback can hold. `_json_default` serializes an array or a path, so neither is out of contract. - `_finalize_backend` takes the shutdown callable and its arguments. - The fakes and two narrowing helpers give the tests the same treatment. A TypedDict reports every optional key as unsafe to read, and the checker cannot tell that only the enabled backends are read. That one rule is turned off in the pyright configuration, next to the six the project already sets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`experiment` is a mapping whose keys are optional, so every read of a
handle was a possible `KeyError`. The checker said so on 31 lines, and
the previous commit turned that rule off rather than answer it.
One accessor per backend replaces the 14 direct reads. Each one starts
its own backend, returns a real type, and names the backend when it
finds none. A log that follows `close` now says "TensorBoard is not
available" instead of raising `KeyError('tensorboard')`.
`reportTypedDictNotRequiredAccess` is on again, and the module holds no
suppression of any kind.
Two consequences worth naming:
- A logging call now starts only the backends it uses. Reading the
public `experiment` property still starts every enabled one, so
luxonis-train is unaffected.
- `_mlflow` starts nothing. Every caller checks `mlflow_initialized`
first, so an initializing branch there would be dead code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Purpose
luxonis_ml/trackerhad no tests. The module was also excluded from pyright andfrom coverage, so neither tool looked at it. Real defects hid behind those
exclusions. This PR removes both exclusions, adds a test suite, and fixes the
defects they hid.
Specification
Breaking changes
LuxonisRequestHeaderProviderandluxonis_ml.tracker.mlflow_pluginsare removedMLFLOW_CLOUDFLARE_IDandMLFLOW_CLOUDFLARE_SECRETstay inenviron, but no code reads them. Register your own MLflow request-header provider.log_to_mlflowandstore_log_locallyare removedlog_*andupload_*methods. The private_mlflow_callreplaces both.local_logsis nowlist[BufferedCall]metric,metrics,params,images,artifacts, andmatrices. Code that reads the buffer must change.upload_artifact_to_mlflowcallsmlflow.log_artifactnameno longer renames it. The method is rank-gated, and it no longer raisesValueErrorwhen the run is missing.closeis rank-gatedversionreturns the run number1. It now returns the number prefix of the run name, or0for a user-supplied name._get_latest_run_namewaits for a run directory that was not there yet. It falls back to the newest existing run after a 1 s grace period, and it raisesRuntimeErrorafter 30 s. Every worker of a normal DDP launch pays the grace period and logs a warning, because the class cannot identify rank zero. Passrun_nameto avoid both.Bugs fixed
experimentretried the whole MLflow init on every accesslog_metricranset_experimentandstart_runagain. An unreachable server thus stalled the training loop. A failed init now backs off for 60 s.self.experiment["mlflow"].log_metriceagerlyKeyError('mlflow')on the first log when mlflow was not importable.name.rsplit("/", 1)unpacked two namesValueErrorfor an image name without a/, such aslog_image("loss_curve", …).close()only spilled the MLflow bufferjson.dumphad nodefaultnp.float32metric value crashed the fallback and lost the whole buffer.save_logs_locally{idx}.png, assumed 3 channels, and copied artifacts throughread_bytes()._get_latest_run_name()IndexErroron an empty directory. Thetime.sleep(1)DDP hotfix was not a synchronization primitive.isnumeric()guardedint()½-baselineor²-runcrashed the constructor.isdecimal()matches whatintaccepts.MLFLOW_HTTP_REQUEST_MAX_RETRIESwas overwrittensetdefault, and only when MLflow is enabled.Runthatinitreturns.artifact.save()run.log_artifactreplaces it.np.array2stringtruncatedadd_hparamsreceived any valueNoneincluded, to a string.project_namewas set toNoneproject_idwas given. The deadartifacts_dirattribute is also gone.New behaviour
else, so an unknown function silently discarded data.be images. The tracker replays the buffer in order.
and a failed replay both arm that backoff, so an outage cannot stall the
training loop.
Hyperparameters and artifacts go last, because no later call repeats them.
They still give way to the call that has just arrived.
behind a failing one to tell the two cases apart, and it drops only a
rejected call. Two rejected calls in a row still look like an outage, and
the tracker then keeps the whole buffer.
file after they hand it over. Each copy gets its own
artifacts/<n>/, sotwo artifacts that share a file name stay apart.
close(status)is idempotent. It reports a backend that fails to shut downinstead of raising.
Anyand no type suppression.experimentmaps eachbackend to its own handle type, and the buffered calls and the local fallback
carry the types that occur.
close(). MLflow wouldopen a fresh run for it, and no later
close()would end that run. Thetracker warns instead.
LuxonisTrackeris a context manager. It marks the run failed if the blockraises.
local_logs.json, so a second save keeps therecords of the first.
Dependencies & Potential Impact
trackerextra now needsopencv-python~=4.10. The module importscv2at import time, so
pip install luxonis-ml[tracker]failed before.tensorboard(tensorboard,torch) andwandb.Each backend SDK was undeclared before, so a user had to know to install it.
allpulls both in. Thetrackerextra stays free of them, because a runonly needs the backends it turns on, and
luxonis_ml.trackerstill importswith none of them present.
LuxonisFileSystem.luxonis-trainreadsexperiment["mlflow"]and callsclose()inLuxonisTrackerPL._finalize. That code needs a follow-up. See the note below.Downstream note for
luxonis-trainLuxonisTrackerPL._finalizeduplicates whatclose()now does. Two problemsappear once
luxonis-trainmoves off the pinnedluxonis-ml==0.9.1:_finalizecallsself.close()from inside its MLflow branch and does notforward the status.
close()then finishes the WandB run with exit 0, andthe later
finish(1)cannot change it._finalizereadsself.experiment["mlflow"]before it callsclose(). Thenew
_init_mlflowstores that handle only afterstart_runsucceeds, so anunreachable server raises
KeyError.core.pyuploads the ONNX file, the archive, the log, and the config after_finalizehas run.close()now ends the MLflow run, and the trackerrefuses a call that arrives after
close(). Those artifacts therefore reachno run, and the tracker warns for each one.
Drop
_finalizein a follow-up, and upload the artifacts beforeclose().Deployment Plan
None / not applicable. The change is a library change with no rollout step.
Testing & Validation
tests/test_tracker/holds 67 tests. They reach 100 % statement coverage ofluxonis_ml/tracker.test_tracker.pyruns against monkeypatched TensorBoard, WandB, and MLflowdoubles. The doubles record every call and can be told to fail.
test_mlflow_integration.pystarts a realmlflow serversubprocess. It usesan sqlite backend, a local artifact root, and a free port. It checks the
params, the metrics, the image and artifact paths, and the run status through
MlflowClient. A second test kills the server mid-run. It then asserts thatthe buffered logs reach
local_logs.jsonand thatclose()does not raise.Both tests skip if the server does not start.
AI Usage
Assisted-by: Claude:claude-opus-5
Submitted code was reviewed by a human:
The author is taking the responsibility for the contribution: