Skip to content

card_creator: use time.monotonic() for async-process timeout instead of wall-clock time.time() #3224

Description

@alexzhu0

Summary

metaflow/plugins/cards/card_creator.py measures the elapsed runtime of async card-rendering subprocesses using time.time() — a wall-clock that can jump backward (NTP step, manual settimeofday, DST transitions on non-UTC systems). The correct API for monotonically-increasing elapsed-time measurement is time.monotonic().

Locations

In metaflow/plugins/cards/card_creator.py (at HEAD 90ea1d3):

  • L31CardProcessManager._register_card_process() stores the start time:
    cls.async_card_processes[carduuid] = {
        "proc": proc,
        "started": time.time(),
    }
  • L200_wait_for_async_processes_to_finish() reads it:
    if time.time() - _async_started > async_timeout:
        CardProcessManager._remove_card_process(card_uuid)
        break
  • L231_run_command() (async branch) reads it again:
    if time.time() - _async_started > async_timeout:
        CardProcessManager._remove_card_process(card_uuid)

Why this matters

time.time() reflects the system wall-clock; under NTP correction, container clock-skew rebase, or a manual time adjustment, two time.time() calls separated in real-time may differ in either direction. The two failure modes both bite metaflow users running Cards in long-lived environments (Kubernetes, Batch):

  1. Clock jumps backward during a long-running card render → time.time() - _async_started becomes small or negative → the timeout never fires → the card subprocess leaks indefinitely (held in CardProcessManager.async_card_processes and never cleaned up).
  2. Clock jumps forward → the elapsed delta inflates → the subprocess is killed prematurely and the user sees a missing/incomplete card.

time.monotonic() is the documented stdlib API for this case: it cannot go backward and is unaffected by system-time adjustments. Python docs are explicit: "Use this function to monitor differences between calls."

Proposed fix

Three-line change — replace time.time() at L31 / L200 / L231 with time.monotonic(). No other behavioral change; the comparison is still an interval, the stored value is still opaque to all readers.

Test plan

Unit test in test/unit/ that monkeypatches time.time to simulate a backward jump, registers a card process with the patched start time, then verifies the timeout check still fires correctly when the implementation is time.monotonic()-based. (Will include in the PR.)

AI tool disclosure

Bug surfaced by an audit grep for time.time() used in interval contexts (R5 rule in our local PR-audit toolkit). The analysis, fix, and PR will be authored by me — I understand both time.time and time.monotonic semantics and have verified the call sites read as written above.

Happy to take this on if accepted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions