|
4 | 4 | import grpc |
5 | 5 | import pytest |
6 | 6 | from google.protobuf import empty_pb2 as _empty_pb2 |
| 7 | +from google.protobuf import timestamp_pb2 |
7 | 8 |
|
8 | 9 | from pynumaflow import setup_logging |
9 | 10 | from pynumaflow.accumulator import ( |
@@ -87,6 +88,69 @@ def request_generator_mixed(count, request, resetkey: bool = False): |
87 | 88 | yield request |
88 | 89 |
|
89 | 90 |
|
| 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 | + |
90 | 154 | def start_request() -> accumulator_pb2.AccumulatorRequest: |
91 | 155 | event_time_timestamp, watermark_timestamp = get_time_args() |
92 | 156 | window = accumulator_pb2.KeyedWindow( |
@@ -289,6 +353,68 @@ def test_accumulate_with_close(accumulator_stub) -> None: |
289 | 353 | assert 1 == eof_count |
290 | 354 |
|
291 | 355 |
|
| 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 | + |
292 | 418 | def test_accumulate_append_without_open(accumulator_stub) -> None: |
293 | 419 | request = start_request_without_open() |
294 | 420 | generator_response = None |
|
0 commit comments