Skip to content

Commit 030ded1

Browse files
fix: use the closed window in the eof response (#360)
Signed-off-by: Vaibhav Tiwari <vaibhav.tiwari33@gmail.com>
1 parent 65be64f commit 030ded1

5 files changed

Lines changed: 237 additions & 23 deletions

File tree

packages/pynumaflow/pynumaflow/accumulator/_dtypes.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from abc import ABCMeta, abstractmethod
22
from asyncio import Task
3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
44
from datetime import datetime
55
from enum import IntEnum
66
from typing import TypeAlias, TypeVar
@@ -223,25 +223,19 @@ def keys(self) -> list[str]:
223223
return self._keys
224224

225225

226-
@dataclass
226+
@dataclass(slots=True)
227227
class AccumulatorResult:
228228
"""Defines the object to hold the result of accumulator computation."""
229229

230-
__slots__ = (
231-
"_future",
232-
"_iterator",
233-
"_key",
234-
"_result_queue",
235-
"_consumer_future",
236-
"_latest_watermark",
237-
)
238-
239230
_future: Task
240231
_iterator: NonBlockingIterator
241232
_key: list[str]
242233
_result_queue: NonBlockingIterator
243234
_consumer_future: Task
244235
_latest_watermark: datetime
236+
# The CLOSE request's keyed window is only known when the task is closed, so it is set
237+
# later (via the close_window setter) rather than passed to the constructor.
238+
_close_window: KeyedWindow | None = field(default=None, init=False)
245239

246240
@property
247241
def future(self) -> Task:
@@ -310,6 +304,30 @@ def update_watermark(self, new_watermark: datetime):
310304
raise TypeError("new_watermark must be a datetime object")
311305
self._latest_watermark = new_watermark
312306

307+
@property
308+
def close_window(self) -> KeyedWindow | None:
309+
"""Returns the keyed window from the CLOSE request, if the task was closed.
310+
311+
Returns:
312+
KeyedWindow | None: The window carried by the CLOSE request, echoed back in
313+
the EOF response; None if the task has not received a CLOSE.
314+
"""
315+
return self._close_window
316+
317+
@close_window.setter
318+
def close_window(self, window: KeyedWindow):
319+
"""Stashes the CLOSE request's keyed window so the EOF response can echo it.
320+
321+
Args:
322+
window (KeyedWindow): The keyed window from the CLOSE request.
323+
324+
Raises:
325+
TypeError: If window is not a KeyedWindow object.
326+
"""
327+
if not isinstance(window, KeyedWindow):
328+
raise TypeError("window must be a KeyedWindow object")
329+
self._close_window = window
330+
313331

314332
@dataclass
315333
class AccumulatorRequest:

packages/pynumaflow/pynumaflow/accumulator/servicer/async_servicer.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
from collections.abc import AsyncIterable
3+
from datetime import datetime
34

45
from google.protobuf import empty_pb2 as _empty_pb2
56

@@ -16,6 +17,25 @@
1617
from pynumaflow.shared.server import update_context_err
1718
from pynumaflow.types import NumaflowServicerContext
1819

20+
# protobuf Timestamp.ToDatetime() is only valid for years 1..9999, i.e. seconds in
21+
# [-62135596800, 253402300799]. Accumulator windows are global per key, so core sends the
22+
# window end as an "infinite" sentinel (chrono DateTime::<Utc>::MAX_UTC, ~year 262137) whose
23+
# seconds exceed that range and make ToDatetime() raise. Clamp out-of-range timestamps to the
24+
# representable datetime bounds instead of crashing the UDF.
25+
_TIMESTAMP_MAX_SECONDS = 253402300799
26+
_TIMESTAMP_MIN_SECONDS = -62135596800
27+
28+
29+
def _to_datetime(ts) -> datetime:
30+
"""Convert a protobuf Timestamp to a datetime, clamping values outside the representable
31+
range (year 1..9999) to datetime.max / datetime.min so an infinite window bound does not
32+
crash decoding."""
33+
if ts.seconds > _TIMESTAMP_MAX_SECONDS:
34+
return datetime.max
35+
if ts.seconds < _TIMESTAMP_MIN_SECONDS:
36+
return datetime.min
37+
return ts.ToDatetime()
38+
1939

2040
async def datum_generator(
2141
request_iterator: AsyncIterable[accumulator_pb2.AccumulatorRequest],
@@ -24,8 +44,8 @@ async def datum_generator(
2444
async for d in request_iterator:
2545
# Convert protobuf KeyedWindow to our KeyedWindow dataclass
2646
keyed_window = KeyedWindow(
27-
start=d.operation.keyedWindow.start.ToDatetime(),
28-
end=d.operation.keyedWindow.end.ToDatetime(),
47+
start=_to_datetime(d.operation.keyedWindow.start),
48+
end=_to_datetime(d.operation.keyedWindow.end),
2949
slot=d.operation.keyedWindow.slot,
3050
keys=list(d.operation.keyedWindow.keys),
3151
)

packages/pynumaflow/pynumaflow/accumulator/servicer/task_manager.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
22
from collections.abc import AsyncIterable
3-
from datetime import datetime
3+
from datetime import datetime, timezone
44

55
from google.protobuf import timestamp_pb2
66
from pynumaflow._constants import (
@@ -112,6 +112,10 @@ async def close_task(self, req: AccumulatorRequest):
112112
curr_task = self.tasks.get(unified_key, None)
113113

114114
if curr_task:
115+
# Stash the CLOSE request's keyed window BEFORE signalling EOF so the task's
116+
# consumer (write_to_global_queue) can echo it back in the EOF response. Core
117+
# uses the echoed window to identify and garbage-collect the closed window.
118+
curr_task.close_window = req.keyed_window
115119
await self.tasks[unified_key].iterator.put(STREAM_EOF)
116120
await curr_task.future
117121
await curr_task.consumer_future
@@ -318,7 +322,7 @@ async def write_to_global_queue(
318322
watermark_pb.FromDatetime(msg.watermark)
319323

320324
start_dt_pb = timestamp_pb2.Timestamp()
321-
start_dt_pb.FromDatetime(datetime.fromtimestamp(0))
325+
start_dt_pb.FromDatetime(datetime.fromtimestamp(0, tz=timezone.utc))
322326

323327
end_dt_pb = timestamp_pb2.Timestamp()
324328
end_dt_pb.FromDatetime(wm)
@@ -339,17 +343,39 @@ async def write_to_global_queue(
339343
tags=msg.tags,
340344
)
341345
await output_queue.put(res)
342-
# send EOF
343-
start_eof_pb = timestamp_pb2.Timestamp()
344-
start_eof_pb.FromDatetime(datetime.fromtimestamp(0))
346+
# Send EOF. Echo the CLOSE request's keyed window (start/end/slot/keys) so core can
347+
# match the EOF to the window it is closing and garbage-collect it. Mirrors the
348+
# numaflow-rs accumulator behavior (PR #177).
349+
close_window = task.close_window
350+
if close_window is not None:
351+
start_eof_pb = timestamp_pb2.Timestamp()
352+
start_eof_pb.FromDatetime(close_window.start)
353+
354+
end_eof_pb = timestamp_pb2.Timestamp()
355+
end_eof_pb.FromDatetime(close_window.end)
356+
357+
eof_window = accumulator_pb2.KeyedWindow(
358+
start=start_eof_pb,
359+
end=end_eof_pb,
360+
slot=close_window.slot,
361+
keys=close_window.keys,
362+
)
363+
else:
364+
# Fallback for the stream-close/shutdown path (no CLOSE request, e.g.
365+
# stream_send_eof on SIGTERM): synthesize the window from epoch(0) and the
366+
# latest watermark, preserving prior behavior.
367+
start_eof_pb = timestamp_pb2.Timestamp()
368+
start_eof_pb.FromDatetime(datetime.fromtimestamp(0, tz=timezone.utc))
345369

346-
end_eof_pb = timestamp_pb2.Timestamp()
347-
end_eof_pb.FromDatetime(wm)
370+
end_eof_pb = timestamp_pb2.Timestamp()
371+
end_eof_pb.FromDatetime(wm)
348372

349-
res = accumulator_pb2.AccumulatorResponse(
350-
window=accumulator_pb2.KeyedWindow(
373+
eof_window = accumulator_pb2.KeyedWindow(
351374
start=start_eof_pb, end=end_eof_pb, slot="slot-0", keys=task.keys
352-
),
375+
)
376+
377+
res = accumulator_pb2.AccumulatorResponse(
378+
window=eof_window,
353379
EOF=True,
354380
)
355381
await output_queue.put(res)

packages/pynumaflow/tests/accumulator/test_async_accumulator.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import grpc
55
import pytest
66
from google.protobuf import empty_pb2 as _empty_pb2
7+
from google.protobuf import timestamp_pb2
78

89
from pynumaflow import setup_logging
910
from pynumaflow.accumulator import (
@@ -87,6 +88,69 @@ def request_generator_mixed(count, request, resetkey: bool = False):
8788
yield request
8889

8990

91+
# Distinct, recognizable close-window values used to prove the EOF echoes the
92+
# CLOSE request's window rather than the synthesized fallback window.
93+
CLOSE_WINDOW_START_SECONDS = 1000000000
94+
CLOSE_WINDOW_END_SECONDS = 2000000000
95+
CLOSE_WINDOW_SLOT = "slot-7"
96+
97+
# The accumulator's global window carries an "infinite" end (chrono MAX_UTC, ~year 262137)
98+
# whose seconds exceed Python datetime's range. Core sends this on OPEN/APPEND.
99+
GLOBAL_WINDOW_END_SECONDS = 8210266876799
100+
101+
102+
def request_generator_custom_close(count, request):
103+
"""Yields OPEN + APPEND requests, then a CLOSE whose keyed window carries
104+
distinct start/end/slot values (mirroring core sending a real
105+
max_event_time + timeout window on close)."""
106+
for i in range(count):
107+
if i == 0:
108+
request.operation.event = accumulator_pb2.AccumulatorRequest.WindowOperation.Event.OPEN
109+
else:
110+
request.operation.event = (
111+
accumulator_pb2.AccumulatorRequest.WindowOperation.Event.APPEND
112+
)
113+
yield request
114+
115+
# CLOSE carrying a distinct keyed window to be echoed back in the EOF response.
116+
request.operation.event = accumulator_pb2.AccumulatorRequest.WindowOperation.Event.CLOSE
117+
request.operation.keyedWindow.start.CopyFrom(
118+
timestamp_pb2.Timestamp(seconds=CLOSE_WINDOW_START_SECONDS)
119+
)
120+
request.operation.keyedWindow.end.CopyFrom(
121+
timestamp_pb2.Timestamp(seconds=CLOSE_WINDOW_END_SECONDS)
122+
)
123+
request.operation.keyedWindow.slot = CLOSE_WINDOW_SLOT
124+
yield request
125+
126+
127+
def request_generator_infinite_then_close(count, request):
128+
"""OPEN + APPEND requests whose window end is the global 'infinite' sentinel
129+
(chrono MAX_UTC, out of Python datetime range), then a CLOSE carrying a concrete,
130+
representable window. Mirrors what real core sends to an accumulator."""
131+
request.operation.keyedWindow.end.CopyFrom(
132+
timestamp_pb2.Timestamp(seconds=GLOBAL_WINDOW_END_SECONDS)
133+
)
134+
for i in range(count):
135+
if i == 0:
136+
request.operation.event = accumulator_pb2.AccumulatorRequest.WindowOperation.Event.OPEN
137+
else:
138+
request.operation.event = (
139+
accumulator_pb2.AccumulatorRequest.WindowOperation.Event.APPEND
140+
)
141+
yield request
142+
143+
request.operation.event = accumulator_pb2.AccumulatorRequest.WindowOperation.Event.CLOSE
144+
request.operation.keyedWindow.start.CopyFrom(
145+
timestamp_pb2.Timestamp(seconds=CLOSE_WINDOW_START_SECONDS)
146+
)
147+
request.operation.keyedWindow.end.CopyFrom(
148+
timestamp_pb2.Timestamp(seconds=CLOSE_WINDOW_END_SECONDS)
149+
)
150+
request.operation.keyedWindow.slot = CLOSE_WINDOW_SLOT
151+
yield request
152+
153+
90154
def start_request() -> accumulator_pb2.AccumulatorRequest:
91155
event_time_timestamp, watermark_timestamp = get_time_args()
92156
window = accumulator_pb2.KeyedWindow(
@@ -289,6 +353,68 @@ def test_accumulate_with_close(accumulator_stub) -> None:
289353
assert 1 == eof_count
290354

291355

356+
def test_accumulate_close_echoes_eof_window(accumulator_stub) -> None:
357+
"""The EOF response must echo the exact KeyedWindow from the CLOSE request."""
358+
request = start_request()
359+
generator_response = accumulator_stub.AccumulateFn(
360+
request_iterator=request_generator_custom_close(count=5, request=request)
361+
)
362+
363+
eof_count = 0
364+
for r in generator_response:
365+
if r.EOF:
366+
eof_count += 1
367+
assert r.window.start.seconds == CLOSE_WINDOW_START_SECONDS
368+
assert r.window.end.seconds == CLOSE_WINDOW_END_SECONDS
369+
assert r.window.slot == CLOSE_WINDOW_SLOT
370+
assert list(r.window.keys) == ["test_key"]
371+
372+
assert 1 == eof_count
373+
374+
375+
def test_accumulate_infinite_window_end_does_not_crash(accumulator_stub) -> None:
376+
"""A global window with an 'infinite' end (out of Python datetime range) on OPEN/APPEND
377+
must not crash decoding; the stream completes and the EOF echoes the CLOSE window."""
378+
request = start_request()
379+
generator_response = accumulator_stub.AccumulateFn(
380+
request_iterator=request_generator_infinite_then_close(count=5, request=request)
381+
)
382+
383+
count = 0
384+
eof_count = 0
385+
for r in generator_response:
386+
if r.EOF:
387+
eof_count += 1
388+
assert r.window.start.seconds == CLOSE_WINDOW_START_SECONDS
389+
assert r.window.end.seconds == CLOSE_WINDOW_END_SECONDS
390+
assert r.window.slot == CLOSE_WINDOW_SLOT
391+
elif r.payload.value:
392+
count += 1
393+
394+
# All 5 datums were processed and exactly one EOF was emitted (no crash).
395+
assert 5 == count
396+
assert 1 == eof_count
397+
398+
399+
def test_accumulate_eof_window_fallback_without_close(accumulator_stub) -> None:
400+
"""When the stream closes without a CLOSE (e.g. shutdown), the EOF window falls
401+
back to the synthesized window (start=epoch(0), slot='slot-0')."""
402+
request = start_request()
403+
generator_response = accumulator_stub.AccumulateFn(
404+
request_iterator=request_generator(count=5, request=request, send_close=False)
405+
)
406+
407+
eof_count = 0
408+
for r in generator_response:
409+
if r.EOF:
410+
eof_count += 1
411+
assert r.window.start.seconds == 0
412+
assert r.window.slot == "slot-0"
413+
assert list(r.window.keys) == ["test_key"]
414+
415+
assert 1 == eof_count
416+
417+
292418
def test_accumulate_append_without_open(accumulator_stub) -> None:
293419
request = start_request_without_open()
294420
generator_response = None

packages/pynumaflow/tests/accumulator/test_datatypes.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,30 @@ def test_accumulator_result_update_watermark_invalid_type():
176176
result.update_watermark("not a datetime")
177177

178178

179+
def test_accumulator_result_close_window_setter():
180+
result = AccumulatorResult(
181+
None, None, [], None, None, datetime.fromtimestamp(1662998400, timezone.utc)
182+
)
183+
# Not set until a CLOSE arrives.
184+
assert result.close_window is None
185+
window = KeyedWindow(
186+
start=datetime.fromtimestamp(1662998400, timezone.utc),
187+
end=datetime.fromtimestamp(1662998460, timezone.utc),
188+
slot="slot-0",
189+
keys=["key1"],
190+
)
191+
result.close_window = window
192+
assert result.close_window is window
193+
194+
195+
def test_accumulator_result_close_window_setter_invalid_type():
196+
result = AccumulatorResult(
197+
None, None, [], None, None, datetime.fromtimestamp(1662998400, timezone.utc)
198+
)
199+
with pytest.raises(TypeError):
200+
result.close_window = "not a keyed window"
201+
202+
179203
# --- TestAccumulatorRequest ---
180204

181205

0 commit comments

Comments
 (0)