Skip to content

Fix async batching, timeout detection and time formatting errors - #55

Merged
wolph merged 9 commits into
developfrom
fix/time-and-async-helpers
Oct 2, 2026
Merged

wolph merged 9 commits into
developfrom
fix/time-and-async-helpers

Conversation

@wolph

@wolph wolph commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Found by an adversarial pass over the modules that are unchanged since 4.0.1. Every fix started as a failing test. Only errors whose fix cannot break working code are in here. The rest is listed at the bottom for a decision.

The branch contains #53, so the test hooks pass on any machine. Merge #53 first, or merge this and #53 is included.

abatcher

async for batch in abatcher(source(), interval=1):
    break
# With an item still on its way, one task stays pending. It takes the
# next item from source(), nobody receives it, and source.aclose()
# raises "asynchronous generator is already running".
  • The pending task is cancelled and awaited when the consumer stops early, closes the batcher or is cancelled. An error that the source raises while it is cancelled is reported to the event loop.
  • A full batch starts a new interval. With batch_size=3, interval=10 and an item every 4 seconds the batches were [0, 1, 2], [3], [4, 5, 6], [7].
  • An iterator whose __anext__ returns a future is accepted.

aio_generator_timeout_detector

A plain function works as on_timeout. It raised TypeError: 'NoneType' object can't be awaited after the callback had run.

format_time and timedelta_to_seconds

Call Before After
format_time(1, timedelta(milliseconds=100)) 0:00:00.900000 0:00:01
format_time(float('nan')) ValueError --:--:--
timedelta_to_seconds(timedelta(microseconds=-1)) -9.99993e-07 -1e-06

Output for the default precision is identical to 4.0.1 on 200000 random inputs across four time zones.

Smaller fixes

  • timeout_generator and aio_timeout_generator limit the first sleep to maximum_interval too. The later sleeps are unchanged.
  • acount(10, -2, stop=0) counts down. It yielded nothing.
  • sample logs through its module logger. The module-level logging.debug() installed a root handler, which made a later basicConfig() of the application a no-op.
  • listify keeps the name, docstring and signature of the function.
  • wraps_classmethod no longer changes the annotations of the wrapped function, and keeps the wrapper's own on Python 3.14.

Docstrings corrected, no behaviour change

  • A timeout or maximum_interval of 0 means none.
  • total_timeout is checked between items and does not end the wait for an item that does not arrive.
  • aio_timeout_generator described an interval_exponent that does not exist.
  • listify(allow_empty=False) only rejects a None result.

Compatibility

A second adversarial pass compared this branch with 4.0.1 over 540 abatcher scenarios and 385 detector scenarios. It found that two fixes in the first version changed more than their error, so both were taken back:

  • Flushing an abatcher batch exactly when its interval ends gave a third to a half more batches on a steady stream.
  • Ending the wait at total_timeout ran each step of the wrapped generator in a new task, which loses context variables, and dropped an item that 4.0.1 still delivered.

With those reverted, the released 4.0.1 tests for abatcher, acount, the detector and the decorators pass unchanged against this branch.

What working code can still notice:

  • After leaving an abatcher loop while an item is on its way, the source generator receives CancelledError at its pending await. In 4.0.1 a leaked task took that item. A source with nothing on its way stays usable, as before.
  • timedelta_to_seconds returns a slightly different float for some non-whole durations, equal to total_seconds().
  • A record from sample has the logger name python_utils.decorators instead of root.
  • A function decorated with listify reports its own __name__ instead of __listify.
  • acount with a negative step and a stop above the start yields nothing, like range. It ignored the stop and counted down forever.
  • format_time with a precision that is not a timedelta raises TypeError instead of AttributeError, and raises nothing for None and plain dates, which never use the precision.

Found and left alone

These change what working code sees, so they need a decision:

  • total_timeout does not end the wait for a stalled item. A clean fix needs asyncio.timeout, which Python 3.10 does not have.
  • An abatcher batch can wait almost two intervals before it is flushed.
  • The detector treats a TimeoutError raised inside the wrapped generator as its own timeout.
  • The detector never closes the wrapped generator on a total timeout.
  • format_time prints a timezone-aware datetime as local wall time without the offset, and truncates on UTC boundaries.
  • format_time(datetime.min) and format_time(datetime.max) raise ValueError.
  • batcher and abatcher accept a batch_size of 0 or below and buffer the whole stream.
  • acount sleeps once more after its last value. An existing test asserts that count.
  • The async timeout generator busy-loops on a negative interval where the sync one raises.

wolph added 7 commits October 2, 2026 13:48
The timeout tests counted items against real sleeps and left 10 to 40 ms
of slack. A sleep only promises to take at least as long as requested, so
the counts changed on a busy machine and on a coarse clock:

- Blocking sleeps that overshoot by 40 ms or more made timeout_generator
  yield one item fewer, in five test cases and in its doctest.
- A 15.6 ms event loop clock resolution, the Windows default, let the
  0.05 s timeout fire together with a 0.04 s sleep, so the detector tests
  stopped at 3 instead of 4.

The sync tests and the doctest now run on a fake clock that only moves
when it is slept on, and they check the requested sleeps as well. The
total timeout tests advance the same clock. The per-item timeout tests
yield without waiting and then stall for 10 s against a 0.05 s timeout.
One test stays on the real clock and only checks what holds for any sleep
accuracy.

The fixtures are loaded from a conftest.py in the repository root so the
doctests can use them, and the sdist ships that file.
test_aio_timeout_generator still counted items against real sleeps. The
case with five sleeps of 0.06 s against a 0.3 s timeout ends one item
short as soon as the sleeps run 15 ms late in total. It failed 3 of 25
runs on a busy machine, and fails every time when asyncio.sleep is made
20 ms late.

The test now lets asyncio.sleep advance the fake clock. The default
iterable test in test_lazy_imports uses the fake clock too, so its 0.05 s
timeout cannot end the loop before the second item.
- The task that waits for the next item is cancelled and awaited when the
  consumer stops early, closes the batcher or is cancelled. It was left
  running, took the next item from the source and nobody received it.
- A full batch starts a new interval. The old interval kept running, so
  the next item was flushed on its own.
- A batch that is waiting is flushed when its interval ends. Each wakeup
  waited a full interval again, which could hold a batch for almost twice
  as long.
- An iterator whose __anext__ returns a future is accepted.
aio_generator_timeout_detector:
- A plain function works as on_timeout. Its result was awaited, which
  raised TypeError for anything but a coroutine function.
- total_timeout ends the wait for an item that does not arrive. It was
  only checked between items, so a stalled generator outlived it.

format_time:
- Truncation to the precision is exact. With float seconds 1.0 % 0.1 is
  just under 0.1, so one second at 100 ms precision printed 0.9 seconds.
- nan prints the placeholder, like infinity does.

timeout_generator and aio_timeout_generator apply maximum_interval to
the first sleep as well.

timedelta_to_seconds divides the whole microseconds once, the way
total_seconds() does. Whole seconds still give an int.

The docstrings say that a timeout or maximum_interval of 0 means none,
and the aio_timeout_generator text describes interval_multiplier instead
of a parameter that does not exist.
With a negative step the stop is a lower bound, the way range reads it.
acount(10, -2, stop=0) yielded nothing.
- sample logs through the logger of its module. The module-level
  logging.debug() installs a handler on the root logger when it has none,
  which made a later basicConfig() of the application a no-op.
- listify keeps the name, docstring and signature of the function.
- wraps_classmethod copies the annotations before it drops self. It
  changed the wrapped function, and on Python 3.14 the wrapper lost its
  own annotations.

The listify docstring describes what allow_empty looks at.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

await started.wait()
consumer.cancel()
with pytest.raises(asyncio.CancelledError):
await consumer
Comment thread conftest.py
root because the doctests in ``python_utils`` need them as well.
"""

pytest_plugins: tuple[str, ...] = ('_python_utils_tests.clock',)
Comment thread python_utils/time.py
# A coroutine function hands back something to await, a
# plain function has already done its work by now.
if isinstance(result, collections.abc.Awaitable):
await result
wolph added 2 commits October 2, 2026 15:49
An adversarial pass compared the fixes on this branch with 4.0.1. Two of
them changed more than the error they were for, so they are taken back.

- abatcher waits a full interval per wakeup again and flushes when the
  clock has passed the interval. Waiting only for the rest of the
  interval gave a third to a half more batches on a steady stream. It
  also left an item in flight at nearly every yield, so that stopping
  early ended the source generator where 4.0.1 left it usable.
- The detector checks total_timeout between items again. Bounding the
  wait ran every step of the wrapped generator in a new task, which lost
  its context variables and task-bound timeouts, could swallow a
  cancellation on Python 3.10 and 3.11, and no longer delivered an item
  that arrived just after the deadline. A clean bound needs
  asyncio.timeout, which Python 3.10 does not have. The docstring says
  what the total timeout covers.

Kept, and adjusted:
- maximum_interval limits the first sleep without changing the later
  ones. Clamping the interval itself shifted the whole sequence when the
  multiplier is below 1.
- An error that the source raises while abatcher cancels its pending
  item is reported to the event loop instead of dropped.
- With nothing on its way the cleanup does nothing. An empty gather
  looks up an event loop, which failed when the garbage collector closed
  an unfinished batcher after its loop was gone.
- A source that returns when it is cancelled is no longer reported to the
  event loop as a failure. Its item ends with StopAsyncIteration.

The docstring says what stopping early does to the source.
@wolph
wolph merged commit 44b54a9 into develop Oct 2, 2026
17 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.

2 participants