Skip to content

Fix LuxonisTracker robustness and add test coverage - #460

Draft
kozlov721 wants to merge 18 commits into
mainfrom
fix/tracker-robustness
Draft

Fix LuxonisTracker robustness and add test coverage#460
kozlov721 wants to merge 18 commits into
mainfrom
fix/tracker-robustness

Conversation

@kozlov721

@kozlov721 kozlov721 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Purpose

luxonis_ml/tracker had no tests. The module was also excluded from pyright and
from 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

Change What a caller must do
LuxonisRequestHeaderProvider and luxonis_ml.tracker.mlflow_plugins are removed The tracker no longer sends Cloudflare Access headers. MLFLOW_CLOUDFLARE_ID and MLFLOW_CLOUDFLARE_SECRET stay in environ, but no code reads them. Register your own MLflow request-header provider.
The public methods log_to_mlflow and store_log_locally are removed Use the public log_* and upload_* methods. The private _mlflow_call replaces both.
local_logs is now list[BufferedCall] It was a dict of the groups metric, metrics, params, images, artifacts, and matrices. Code that reads the buffer must change.
upload_artifact_to_mlflow calls mlflow.log_artifact MLflow stores the artifact under its own file name. name no longer renames it. The method is rank-gated, and it no longer raises ValueError when the run is missing.
close is rank-gated Only rank 0 finalizes the backends and saves the buffer.
version returns the run number It returned the constant 1. It now returns the number prefix of the run name, or 0 for a user-supplied name.
A non-zero rank can fail to find the run _get_latest_run_name waits 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 raises RuntimeError after 30 s. Every worker of a normal DDP launch pays the grace period and logs a warning, because the class cannot identify rank zero. Pass run_name to avoid both.

Bugs fixed

Bug Effect
experiment retried the whole MLflow init on every access Every log_metric ran set_experiment and start_run again. An unreachable server thus stalled the training loop. A failed init now backs off for 60 s.
Call sites evaluated self.experiment["mlflow"].log_metric eagerly KeyError('mlflow') on the first log when mlflow was not importable.
name.rsplit("/", 1) unpacked two names ValueError for an image name without a /, such as log_image("loss_curve", …).
close() only spilled the MLflow buffer The TensorBoard writer never flushed and the WandB run never finished.
json.dump had no default A np.float32 metric value crashed the fallback and lost the whole buffer.
save_logs_locally It never cleared the buffer, named images {idx}.png, assumed 3 channels, and copied artifacts through read_bytes().
_get_latest_run_name() IndexError on an empty directory. The time.sleep(1) DDP hotfix was not a synchronization primitive.
isnumeric() guarded int() A directory named ½-baseline or ²-run crashed the constructor. isdecimal() matches what int accepts.
MLFLOW_HTTP_REQUEST_MAX_RETRIES was overwritten The tracker now uses setdefault, and only when MLflow is enabled.
WandB logged through the module Global state broke with two trackers in one process. Logging now goes through the Run that init returns.
artifact.save() Deprecated, and it left the artifact unlinked. run.log_artifact replaces it.
np.array2string truncated TensorBoard received an abbreviated matrix above 1000 elements.
add_hparams received any value TensorBoard accepts only scalar hyperparameters. The tracker now converts every other value, None included, to a string.
project_name was set to None MLflow init cleared it when project_id was given. The dead artifacts_dir attribute is also gone.

New behaviour

  • MLflow dispatch works by call name. The old bound-method comparison had no
    else, so an unknown function silently discarded data.
  • A failed MLflow call goes into a bounded buffer of 500 calls, of which 50 can
    be images. The tracker replays the buffer in order.
  • The tracker retries an unreachable server only once a minute. A failed init
    and a failed replay both arm that backoff, so an outage cannot stall the
    training loop.
  • A full buffer drops the oldest call and warns once for each outage.
    Hyperparameters and artifacts go last, because no later call repeats them.
    They still give way to the call that has just arrived.
  • MLflow can reject one call and accept the next. The tracker sends the call
    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.
  • The tracker copies a buffered artifact at once, because callers delete the
    file after they hand it over. Each copy gets its own artifacts/<n>/, so
    two artifacts that share a file name stay apart.
  • close(status) is idempotent. It reports a backend that fails to shut down
    instead of raising.
  • The module carries no Any and no type suppression. experiment maps each
    backend to its own handle type, and the buffered calls and the local fallback
    carry the types that occur.
  • The tracker ignores an MLflow call that arrives after close(). MLflow would
    open a fresh run for it, and no later close() would end that run. The
    tracker warns instead.
  • LuxonisTracker is a context manager. It marks the run failed if the block
    raises.
  • The local fallback appends to local_logs.json, so a second save keeps the
    records of the first.

Dependencies & Potential Impact

  • The tracker extra now needs opencv-python~=4.10. The module imports cv2
    at import time, so pip install luxonis-ml[tracker] failed before.
  • Two extras are new: tensorboard (tensorboard, torch) and wandb.
    Each backend SDK was undeclared before, so a user had to know to install it.
    all pulls both in. The tracker extra stays free of them, because a run
    only needs the backends it turns on, and luxonis_ml.tracker still imports
    with none of them present.
  • The tracker no longer imports LuxonisFileSystem.
  • luxonis-train reads experiment["mlflow"] and calls close() in
    LuxonisTrackerPL._finalize. That code needs a follow-up. See the note below.

Downstream note for luxonis-train

LuxonisTrackerPL._finalize duplicates what close() now does. Two problems
appear once luxonis-train moves off the pinned luxonis-ml==0.9.1:

  1. _finalize calls self.close() from inside its MLflow branch and does not
    forward the status. close() then finishes the WandB run with exit 0, and
    the later finish(1) cannot change it.
  2. _finalize reads self.experiment["mlflow"] before it calls close(). The
    new _init_mlflow stores that handle only after start_run succeeds, so an
    unreachable server raises KeyError.
  3. core.py uploads the ONNX file, the archive, the log, and the config after
    _finalize has run. close() now ends the MLflow run, and the tracker
    refuses a call that arrives after close(). Those artifacts therefore reach
    no run, and the tracker warns for each one.

Drop _finalize in a follow-up, and upload the artifacts before close().

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 of
luxonis_ml/tracker.

  • test_tracker.py runs against monkeypatched TensorBoard, WandB, and MLflow
    doubles. The doubles record every call and can be told to fail.
  • test_mlflow_integration.py starts a real mlflow server subprocess. It uses
    an 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 that
    the buffered logs reach local_logs.json and that close() 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:

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>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f246fc35-e82d-49c6-90c8-66341ca7a145

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation fix Fixing a bug tracker Changes affecting luxonis_ml.tracker subpackage labels Jul 30, 2026
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.80159% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.58%. Comparing base (1567e6b) to head (45b9ab0).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
tests/test_tracker/test_mlflow_integration.py 97.77% 2 Missing ⚠️
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     
Flag Coverage Δ
pytest-ubuntu-latest 95.58% <99.80%> (+0.30%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

kozlov721 and others added 17 commits July 30, 2026 09:04
`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation fix Fixing a bug tracker Changes affecting luxonis_ml.tracker subpackage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant