v0.10.5.1: fix inactive_threshold unit (seconds, not nanoseconds) — silent verification failure

v0.10.5 passed int(30e9) to ConsumerConfig(inactive_threshold=...)
thinking the value was already in nanoseconds. nats-py treats the field
as float SECONDS and multiplies by 1e9 internally before sending to the
server. The server received 30e18 -- out of int64 range -- and rejected
the consumer creation with err_code=10025 ("invalid JSON: json: cannot
unmarshal number 30000000000000000000 into Go struct field
ConsumerConfig.config.inactive_threshold of type time.Duration").

preview_resend caught the per-stream exception and returned 0 across
every stream. The operator saw "0 events" in the modal regardless of
window. No data corruption, no error popup, just a silent feature
failure caught during the v0.10.5 live verification.

Fix: rename _INACTIVE_THRESHOLD_NS -> _INACTIVE_THRESHOLD_S and set
value to 30.0 (float seconds). One callsite updated. Module comment
explains the unit confusion for future maintainers.

Regression guard: tests/test_resend.py gains
test_pull_subscribe_inactive_threshold_within_nats_range -- captures the
config kwarg passed to pull_subscribe on a real resend call, asserts the
threshold is in [1, 3600] seconds AND its nanosecond-equivalent fits in
int64. The exact v0.10.5 bug (int(30e9)) would trip both assertions.
This was the test gap that let v0.10.5 ship -- _mk_js's MagicMock
swallowed any config shape without validating it.

Full suite: 1011 passed / 1 skipped (was 1010; +1 regression guard).
Ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zvx-echo6 2026-06-07 01:50:35 +00:00
commit 822d4c93e8
2 changed files with 58 additions and 2 deletions

View file

@ -42,9 +42,15 @@ logger = logging.getLogger(__name__)
# Pull-fetch tuning. The ephemeral consumer's inactive_threshold guarantees
# JetStream auto-cleans the temp consumer if anything kills our iterator.
# v0.10.5.1 fix: ``inactive_threshold`` is expected as float SECONDS by
# nats-py (which then multiplies by 1e9 internally to form the nanosecond
# value sent to the server). v0.10.5 passed ``int(30e9)`` thinking it was
# already in ns, which got re-multiplied to 30e18 -- out of int64 range,
# rejected by the server with err_code=10025. Use the documented float-
# seconds API and let the library handle the unit conversion.
_FETCH_BATCH = 200
_FETCH_TIMEOUT_S = 2.0
_INACTIVE_THRESHOLD_NS = int(30e9)
_INACTIVE_THRESHOLD_S = 30.0
# Hard cap per stream per operation. 24h * worst-case CENTRAL_TRAFFIC_FLOW
# volume is still well under this; bump if a legitimate operator action
@ -103,7 +109,7 @@ async def _iter_window(
deliver_policy=DeliverPolicy.BY_START_TIME,
opt_start_time=cutoff.isoformat(),
ack_policy=AckPolicy.NONE,
inactive_threshold=_INACTIVE_THRESHOLD_NS,
inactive_threshold=_INACTIVE_THRESHOLD_S,
filter_subject=subject_filter,
)
try:

View file

@ -250,6 +250,56 @@ async def test_execute_rejects_invalid_window():
nc.publish.assert_not_called()
# --- ConsumerConfig regression guard (v0.10.5.1) -----------------------------
@pytest.mark.asyncio
async def test_pull_subscribe_inactive_threshold_within_nats_range():
"""v0.10.5.1 regression guard: ``inactive_threshold`` on the ephemeral
consumer must be a number nats-py can serialise as a Go ``time.Duration``.
v0.10.5 passed ``int(30e9)`` thinking it was nanoseconds. nats-py treats
the value as float SECONDS and multiplies by 1e9 internally, so the
server received 30e18 -- out of int64 range. NATS rejected the consumer
with ``err_code=10025``; preview_resend caught the exception per-stream
and returned 0 events across the board (silent verification failure).
Assert the captured config has:
- ``inactive_threshold`` in [1, 3600] seconds (operator sanity range)
- the nanosecond-equivalent (value * 1e9) fits within int64
The ``int64`` ceiling is 9_223_372_036_854_775_807 -- anything above that
triggers the same JSON unmarshal error that broke v0.10.5.
"""
captured_configs: list = []
async def _capture_config(filter_subj, durable=None, stream=None, config=None):
captured_configs.append(config)
sub = MagicMock()
sub.fetch = AsyncMock(return_value=[])
sub.unsubscribe = AsyncMock()
return sub
js = MagicMock()
js.pull_subscribe = AsyncMock(side_effect=_capture_config)
await preview_resend(js, minutes=60)
INT64_MAX_NS = 9_223_372_036_854_775_807
assert captured_configs, "expected at least one pull_subscribe call"
for cfg in captured_configs:
threshold = cfg.inactive_threshold
assert threshold is not None, "inactive_threshold must be set"
assert 1 <= threshold <= 3600, (
f"inactive_threshold={threshold!r}s outside [1, 3600] sanity range"
)
ns = threshold * 1_000_000_000
assert ns < INT64_MAX_NS, (
f"inactive_threshold={threshold!r} would produce ns={ns}, "
f"overflowing int64 ({INT64_MAX_NS}). This is the exact v0.10.5 "
f"bug -- a unit confusion that produced 30e18 and triggered "
f"err_code=10025 from NATS."
)
# --- stream-set safety -------------------------------------------------------