diff --git a/src/confluent_kafka/src/Consumer.c b/src/confluent_kafka/src/Consumer.c index d71af1243..67f00d4ea 100644 --- a/src/confluent_kafka/src/Consumer.c +++ b/src/confluent_kafka/src/Consumer.c @@ -1090,15 +1090,14 @@ Consumer_memberid(Handle *self, PyObject *args, PyObject *kwargs) { } /** - * @brief Consume a batch of messages from the subscribed topics. + * @brief Consume a batch of up to num_messages messages from the subscribed + * topics. * - * Instead of a single blocking call to rd_kafka_consume_batch_queue() with the - * full timeout, this function: - * 1. Splits the timeout into 200ms chunks - * 2. Calls rd_kafka_consume_batch_queue() with chunk timeout - * 3. Between chunks, re-acquires GIL and calls PyErr_CheckSignals() - * 4. If signal detected, returns NULL (raises KeyboardInterrupt) - * 5. Continues until messages received, timeout expired, or signal detected. + * This makes a single blocking call to rd_kafka_consume_batch_queue(), which + * gathers up to num_messages messages, blocking for up to the full timeout. + * The call is not interruptible while it is blocked: a pending signal such as + * Ctrl+C is only observed once the call returns, so with the default infinite + * timeout an idle consume() can block until a message arrives. * * @param self Consumer handle * @param args Positional arguments (unused) @@ -1107,8 +1106,9 @@ Consumer_memberid(Handle *self, PyObject *args, PyObject *kwargs) { * consume per call. Default: 1. Maximum: 1000000. * - timeout (float, optional): Timeout in seconds. * Default: -1.0 (infinite timeout) - * @return PyObject* List of Message objects, empty list if timeout, or NULL on - * error (raises KeyboardInterrupt if signal detected) + * @return PyObject* List of up to num_messages Message objects, empty list if + * the timeout elapses with none available, or NULL on error or when a pending + * signal (e.g. KeyboardInterrupt) is raised after the call returns. */ static PyObject * Consumer_consume(Handle *self, PyObject *args, PyObject *kwargs) { @@ -1119,11 +1119,7 @@ Consumer_consume(Handle *self, PyObject *args, PyObject *kwargs) { PyObject *msglist; rd_kafka_queue_t *rkqu = self->u.Consumer.rkqu; CallState cs; - Py_ssize_t i, n = 0; - const int CHUNK_TIMEOUT_MS = 200; /* 200ms chunks for signal checking */ - int total_timeout_ms; - int chunk_timeout_ms; - int chunk_count = 0; + Py_ssize_t i, n; if (!self->rk) { PyErr_SetString(PyExc_RuntimeError, ERR_MSG_CONSUMER_CLOSED); @@ -1141,8 +1137,6 @@ Consumer_consume(Handle *self, PyObject *args, PyObject *kwargs) { return NULL; } - total_timeout_ms = cfl_timeout_ms(tmout); - rkmessages = malloc(num_messages * sizeof(rd_kafka_message_t *)); if (!rkmessages) { PyErr_NoMemory(); @@ -1151,64 +1145,9 @@ Consumer_consume(Handle *self, PyObject *args, PyObject *kwargs) { CallState_begin(self, &cs); - /* Skip wakeable poll pattern for non-blocking or very short timeouts. - * This avoids unnecessary GIL re-acquisition that can interfere with - * ThreadPool. Only use wakeable poll for - * blocking calls that need to be interruptible. */ - if (total_timeout_ms >= 0 && total_timeout_ms < CHUNK_TIMEOUT_MS) { - n = (Py_ssize_t)rd_kafka_consume_batch_queue( - rkqu, total_timeout_ms, rkmessages, num_messages); + n = (Py_ssize_t)rd_kafka_consume_batch_queue( + rkqu, cfl_timeout_ms(tmout), rkmessages, num_messages); - if (n < 0) { - /* Error - need to restore GIL before setting error */ - PyEval_RestoreThread(cs.thread_state); - free(rkmessages); - cfl_PyErr_Format( - rd_kafka_last_error(), "%s", - rd_kafka_err2str(rd_kafka_last_error())); - return NULL; - } - } else { - while (1) { - /* Calculate timeout for this chunk */ - chunk_timeout_ms = calculate_chunk_timeout( - total_timeout_ms, chunk_count, CHUNK_TIMEOUT_MS); - if (chunk_timeout_ms == 0) { - /* Timeout expired */ - break; - } - - /* Consume with chunk timeout */ - n = (Py_ssize_t)rd_kafka_consume_batch_queue( - rkqu, chunk_timeout_ms, rkmessages, num_messages); - - if (n < 0) { - /* Error - need to restore GIL before setting - * error */ - PyEval_RestoreThread(cs.thread_state); - free(rkmessages); - cfl_PyErr_Format( - rd_kafka_last_error(), "%s", - rd_kafka_err2str(rd_kafka_last_error())); - return NULL; - } - - /* If we got messages, exit the loop */ - if (n > 0) { - break; - } - - chunk_count++; - - /* Check for signals between chunks */ - if (check_signals_between_chunks(self, &cs)) { - free(rkmessages); - return NULL; - } - } - } - - /* Final GIL restore and signal check */ if (!CallState_end(self, &cs)) { for (i = 0; i < n; i++) { rd_kafka_message_destroy(rkmessages[i]); @@ -1217,7 +1156,13 @@ Consumer_consume(Handle *self, PyObject *args, PyObject *kwargs) { return NULL; } - /* Create Python list from messages */ + if (n < 0) { + free(rkmessages); + cfl_PyErr_Format(rd_kafka_last_error(), "%s", + rd_kafka_err2str(rd_kafka_last_error())); + return NULL; + } + msglist = PyList_New(n); for (i = 0; i < n; i++) { @@ -1393,6 +1338,13 @@ static PyMethodDef Consumer_methods[] = { " .. note: Callbacks may be called from this method, " "such as ``on_assign``, ``on_revoke``, et.al.\n" "\n" + " .. note:: This is a blocking call and does not respond to signals " + "(e.g. Ctrl+C / SIGINT) while it is waiting; a pending signal is only " + "delivered once the call returns. With the default infinite timeout an " + "idle ``consume()`` can block indefinitely. Applications that need to " + "stay responsive to interrupts should pass a short ``timeout`` and call " + "``consume()`` in a loop, handling signals between calls.\n" + "\n" " :param int num_messages: The maximum number of messages to return " "(default: 1).\n" " :param float timeout: The maximum time to block waiting for message, " diff --git a/src/confluent_kafka/src/Producer.c b/src/confluent_kafka/src/Producer.c index 0bfc810b8..726262fb4 100644 --- a/src/confluent_kafka/src/Producer.c +++ b/src/confluent_kafka/src/Producer.c @@ -353,7 +353,8 @@ Producer_produce(Handle *self, PyObject *args, PyObject *kwargs) { * 2. Calls rd_kafka_poll() with chunk timeout * 3. Between chunks, re-acquires GIL and calls PyErr_CheckSignals() * 4. If signal detected, returns -1 (raises KeyboardInterrupt) - * 5. Continues until events processed, timeout expired, or signal detected + * 5. Returns as soon as a chunk serves any events, the timeout expires, or a + * signal is detected * * @param self Producer handle * @param tmout Timeout in milliseconds (-1 for infinite) @@ -394,7 +395,10 @@ static int Producer_poll0(Handle *self, int tmout) { r = chunk_result; break; } - r += chunk_result; /* Accumulate events processed */ + r = chunk_result; + + if (chunk_result > 0) + break; chunk_count++; diff --git a/tests/integration/consumer/test_consumer_wakeable_poll_consume.py b/tests/integration/consumer/test_consumer_wakeable_poll_consume.py index 8ca5aea86..2ad3a359e 100644 --- a/tests/integration/consumer/test_consumer_wakeable_poll_consume.py +++ b/tests/integration/consumer/test_consumer_wakeable_poll_consume.py @@ -142,3 +142,139 @@ def test_consume_message_delivery_with_wakeable_pattern(kafka_cluster): assert msg.value() == expected_value, expected_msg consumer.close() + + +def test_consume_accumulates_messages_up_to_num_messages(kafka_cluster): + """Test that consume() gathers up to num_messages within the timeout, + rather than returning as soon as the first messages become available. + """ + topic = kafka_cluster.create_topic_and_wait_propogation('test-consume-accumulate-chunks') + + # Produce 10 messages + producer = kafka_cluster.cimpl_producer() + num_produced = 10 + for i in range(num_produced): + producer.produce(topic, value=f'msg-{i}'.encode()) + producer.flush(timeout=5.0) + + # Create consumer + consumer_conf = kafka_cluster.client_conf( + { + 'group.id': 'test-consume-accumulate', + 'socket.timeout.ms': 100, + 'session.timeout.ms': 6000, + 'auto.offset.reset': 'earliest', + } + ) + consumer = TestConsumer(consumer_conf) + consumer.subscribe([topic]) + + # Wait for subscription and partition assignment + time.sleep(2.0) + + # Consume num_messages=10 with a generous timeout; the batch call gathers + # all 10 within the timeout rather than returning on the first few. + msglist = consumer.consume(num_messages=num_produced, timeout=10.0) + + assert len(msglist) == num_produced, ( + f"Expected {num_produced} messages but got {len(msglist)}. " + f"consume() did not gather the full batch within the timeout." + ) + + for i, msg in enumerate(msglist): + assert not msg.error(), f"Message {i} has error: {msg.error()}" + + consumer.close() + + +def test_consume_returns_partial_on_timeout(kafka_cluster): + """Test that consume() returns partial results when timeout expires + before num_messages is reached.""" + topic = kafka_cluster.create_topic_and_wait_propogation('test-consume-partial-timeout') + + # Produce only 3 messages, but request 100 + producer = kafka_cluster.cimpl_producer() + num_produced = 3 + for i in range(num_produced): + producer.produce(topic, value=f'partial-{i}'.encode()) + producer.flush(timeout=5.0) + + consumer_conf = kafka_cluster.client_conf( + { + 'group.id': 'test-consume-partial', + 'socket.timeout.ms': 100, + 'session.timeout.ms': 6000, + 'auto.offset.reset': 'earliest', + } + ) + consumer = TestConsumer(consumer_conf) + consumer.subscribe([topic]) + + time.sleep(2.0) + + # Request 100 messages but only 3 exist — should return 3 after timeout + start = time.time() + msglist = consumer.consume(num_messages=100, timeout=3.0) + elapsed = time.time() - start + + assert len(msglist) == num_produced, f"Expected {num_produced} messages (partial), got {len(msglist)}" + # Should have waited close to the full timeout since num_messages wasn't reached + assert elapsed >= 2.0, f"Should wait near full timeout for more messages, but returned in {elapsed:.2f}s" + + for i, msg in enumerate(msglist): + assert not msg.error(), f"Message {i} has error: {msg.error()}" + assert msg.value() == f'partial-{i}'.encode() + + consumer.close() + + +def test_consume_accumulates_messages_produced_in_waves(kafka_cluster): + """Test that consume() accumulates messages that arrive in multiple waves, + rather than returning as soon as the first wave becomes available. + """ + import threading + + topic = kafka_cluster.create_topic_and_wait_propogation('test-consume-waves') + + producer = kafka_cluster.cimpl_producer() + + def produce_in_waves(): + """Produce 3 waves of 4 messages each, with 1s gaps.""" + for wave in range(3): + time.sleep(1.0) + for i in range(4): + msg_num = wave * 4 + i + producer.produce(topic, value=f'wave-{msg_num}'.encode()) + producer.flush(timeout=5.0) + + consumer_conf = kafka_cluster.client_conf( + { + 'group.id': 'test-consume-waves', + 'socket.timeout.ms': 100, + 'session.timeout.ms': 6000, + 'auto.offset.reset': 'earliest', + } + ) + consumer = TestConsumer(consumer_conf) + consumer.subscribe([topic]) + + time.sleep(2.0) + + # Start producing in background + producer_thread = threading.Thread(target=produce_in_waves, daemon=True) + producer_thread.start() + + # Request 10 messages with a long timeout (waves take ~3s to complete) + msglist = consumer.consume(num_messages=10, timeout=10.0) + + # Should have accumulated messages across multiple waves + assert len(msglist) == 10, ( + f"Expected exactly 10 messages accumulated across waves, got {len(msglist)}. " + f"consume() may be returning early on the first wave." + ) + + for msg in msglist: + assert not msg.error(), f"Message has error: {msg.error()}" + + producer_thread.join(timeout=5.0) + consumer.close() diff --git a/tests/integration/producer/test_producer_wakeable_poll_flush.py b/tests/integration/producer/test_producer_wakeable_poll_flush.py index 49d1d47d0..b72f00eed 100644 --- a/tests/integration/producer/test_producer_wakeable_poll_flush.py +++ b/tests/integration/producer/test_producer_wakeable_poll_flush.py @@ -119,6 +119,41 @@ def delivery_callback(err, msg): consumer.close() +def test_poll_returns_early_after_delivery_callback(kafka_cluster): + """Test that poll() returns early after delivery callback fires.""" + topic = kafka_cluster.create_topic_and_wait_propogation('test-poll-early-return') + + delivery_called = [] + + def delivery_callback(err, msg): + delivery_called.append(time.time()) + + producer_conf = kafka_cluster.client_conf( + { + 'socket.timeout.ms': 100, + 'message.timeout.ms': 10000, + } + ) + producer = kafka_cluster.cimpl_producer(producer_conf) + + producer.produce(topic, value=b'early-return-test', on_delivery=delivery_callback) + + # Poll with a long timeout — should return early once callback fires + poll_timeout = 5.0 + start = time.time() + events = producer.poll(timeout=poll_timeout) + elapsed = time.time() - start + + assert len(delivery_called) == 1, "Expected delivery callback to fire" + assert events > 0, "Expected at least 1 event served" + assert elapsed < poll_timeout - 1.0, ( + f"poll({poll_timeout}) took {elapsed:.2f}s — should have returned " + f"early after delivery callback, not blocked for full timeout" + ) + + producer.close() + + def test_flush_message_delivery_with_wakeable_pattern(kafka_cluster): """Test that flush() correctly delivers messages when using wakeable pattern. diff --git a/tests/test_Wakeable.py b/tests/test_Wakeable.py index ea0947976..088da078f 100644 --- a/tests/test_Wakeable.py +++ b/tests/test_Wakeable.py @@ -47,7 +47,10 @@ # # Consumer Implementation (Consumer.c): # - Consumer.poll() uses wakeable pattern for timeouts >= 200ms -# - Consumer.consume() uses wakeable pattern for timeouts >= 200ms +# - Consumer.consume() is intentionally NOT wakeable: it makes a single +# blocking batch call so offset storage stays atomic with the batch the +# caller actually receives. It is therefore not interruptible mid-call and +# has no wakeability/interruptibility tests here. # # How We Test Wakeability: # ------------------------ @@ -783,88 +786,17 @@ def test_consumer_wakeable_poll_edge_cases(): consumer4.close() -def test_consumer_wakeable_consume_interruptibility_and_messages(): - """Test consume() interruptibility (main fix) and message handling.""" - topic = 'test-consume-interrupt-topic' +def test_consumer_consume_timeout_and_message_handling(): + """Test consume() batch timeout behavior and message handling. - # Assertion 1: Infinite timeout can be interrupted immediately - consumer1 = TestConsumer( - { - 'group.id': 'test-consume-infinite-immediate', - 'socket.timeout.ms': 100, - 'session.timeout.ms': 1000, - 'auto.offset.reset': 'latest', - } - ) - consumer1.subscribe([topic]) - - interrupt_thread = threading.Thread(target=lambda: TestUtils.send_sigint_after_delay(0.1)) - interrupt_thread.daemon = True - interrupt_thread.start() - - interrupted = False - try: - consumer1.consume() # Infinite timeout, default num_messages=1 - except KeyboardInterrupt: - interrupted = True - finally: - consumer1.close() - - assert interrupted, "Assertion 1 failed: Should have raised KeyboardInterrupt" - - # Assertion 2: Finite timeout can be interrupted before timeout expires - consumer2 = TestConsumer( - { - 'group.id': 'test-consume-finite-interrupt', - 'socket.timeout.ms': 100, - 'session.timeout.ms': 1000, - 'auto.offset.reset': 'latest', - } - ) - consumer2.subscribe([topic]) - - interrupt_thread = threading.Thread(target=lambda: TestUtils.send_sigint_after_delay(0.3)) - interrupt_thread.daemon = True - interrupt_thread.start() - - interrupted = False - timeout_value = WAKEABLE_POLL_TIMEOUT_MAX # Use constant instead of hardcoded 2.0 - try: - consumer2.consume(num_messages=10, timeout=timeout_value) # Use constant for timeout - except KeyboardInterrupt: - interrupted = True - finally: - consumer2.close() - - assert interrupted, "Assertion 2 failed: Should have raised KeyboardInterrupt" - - # Assertion 3: Signal sent after multiple chunks still interrupts quickly - consumer3 = TestConsumer( - { - 'group.id': 'test-consume-multiple-chunks', - 'socket.timeout.ms': 100, - 'session.timeout.ms': 1000, - 'auto.offset.reset': 'latest', - } - ) - consumer3.subscribe([topic]) - - interrupt_thread = threading.Thread(target=lambda: TestUtils.send_sigint_after_delay(0.6)) - interrupt_thread.daemon = True - interrupt_thread.start() - - interrupted = False - try: - consumer3.consume(num_messages=5) # Infinite timeout - except KeyboardInterrupt: - interrupted = True - finally: - consumer3.close() - - assert interrupted, "Assertion 3 failed: Should have raised KeyboardInterrupt" + consume() is not wakeable/interruptible (single blocking batch call), so + this only covers the non-signal behavior: honoring the timeout and the + num_messages=0 short-circuit. + """ + topic = 'test-consume-topic' - # Assertion 4: No signal - timeout works normally, returns empty list - consumer4 = TestConsumer( + # Assertion 1: No signal - timeout works normally, returns empty list + consumer1 = TestConsumer( { 'group.id': 'test-consume-timeout-normal', 'socket.timeout.ms': 100, @@ -872,21 +804,21 @@ def test_consumer_wakeable_consume_interruptibility_and_messages(): 'auto.offset.reset': 'latest', } ) - consumer4.subscribe([topic]) + consumer1.subscribe([topic]) start = time.time() - msglist = consumer4.consume(num_messages=10, timeout=0.5) # 500ms, no signal + msglist = consumer1.consume(num_messages=10, timeout=0.5) # 500ms, no signal elapsed = time.time() - start - assert isinstance(msglist, list), "Assertion 4 failed: consume() should return a list" - assert len(msglist) == 0, f"Assertion 4 failed: Expected empty list (timeout), got {len(msglist)} messages" + assert isinstance(msglist, list), "Assertion 1 failed: consume() should return a list" + assert len(msglist) == 0, f"Assertion 1 failed: Expected empty list (timeout), got {len(msglist)} messages" assert ( WAKEABLE_POLL_TIMEOUT_MIN <= elapsed <= WAKEABLE_POLL_TIMEOUT_MAX - ), f"Assertion 4 failed: Normal timeout took {elapsed:.2f}s, expected ~0.5s" - consumer4.close() + ), f"Assertion 1 failed: Normal timeout took {elapsed:.2f}s, expected ~0.5s" + consumer1.close() - # Assertion 5: num_messages=0 returns empty list immediately - consumer5 = TestConsumer( + # Assertion 2: num_messages=0 returns empty list immediately + consumer2 = TestConsumer( { 'group.id': 'test-consume-zero-messages', 'socket.timeout.ms': 100, @@ -894,18 +826,18 @@ def test_consumer_wakeable_consume_interruptibility_and_messages(): 'auto.offset.reset': 'latest', } ) - consumer5.subscribe([topic]) + consumer2.subscribe([topic]) start = time.time() - msglist = consumer5.consume(num_messages=0, timeout=1.0) + msglist = consumer2.consume(num_messages=0, timeout=1.0) elapsed = time.time() - start - assert isinstance(msglist, list), "Assertion 5 failed: consume() should return a list" - assert len(msglist) == 0, "Assertion 5 failed: num_messages=0 should return empty list" + assert isinstance(msglist, list), "Assertion 2 failed: consume() should return a list" + assert len(msglist) == 0, "Assertion 2 failed: num_messages=0 should return empty list" assert ( elapsed < WAKEABLE_POLL_TIMEOUT_MAX - ), f"Assertion 5 failed: num_messages=0 took {elapsed:.2f}s, expected < {WAKEABLE_POLL_TIMEOUT_MAX}s" - consumer5.close() + ), f"Assertion 2 failed: num_messages=0 took {elapsed:.2f}s, expected < {WAKEABLE_POLL_TIMEOUT_MAX}s" + consumer2.close() def test_consumer_wakeable_consume_edge_cases(): @@ -1317,25 +1249,20 @@ def blocking_call(t): "api_type,method", [ ("producer", "poll"), - ("producer", "flush"), ("consumer", "poll"), - ("consumer", "consume"), ], ) def test_can_be_interrupted(api_type, method): - """Test that blocking operations can be interrupted.""" + """Test that blocking operations can be interrupted. + + consumer.consume() is intentionally excluded: it is a single blocking batch + call and is not interruptible. + """ if api_type == "producer": obj = Producer({'bootstrap.servers': 'localhost:9092', 'socket.timeout.ms': 100, 'message.timeout.ms': 10}) - if method == "poll": - - def blocking_call(): - return obj.poll() - - else: # flush - obj.produce('test-topic', value='test', callback=lambda err, msg: None) - def blocking_call(): - return obj.flush() + def blocking_call(): + return obj.poll() else: # consumer obj = TestConsumer( @@ -1347,15 +1274,9 @@ def blocking_call(): } ) obj.subscribe(['test-topic']) - if method == "poll": - - def blocking_call(): - return obj.poll() - - else: # consume - def blocking_call(): - return obj.consume() + def blocking_call(): + return obj.poll() interrupt_thread = threading.Thread(target=lambda: TestUtils.send_sigint_after_delay(0.1)) interrupt_thread.daemon = True @@ -1367,8 +1288,12 @@ def blocking_call(): except KeyboardInterrupt: interrupted = True finally: - # Wait for signal thread to complete - time.sleep(0.2) + # A SIGINT delivered after the blocking call already returned must not + # escape cleanup and abort the whole pytest session. + try: + time.sleep(0.2) + except KeyboardInterrupt: + interrupted = True obj.close() # Key assertion: operation was interruptible @@ -1438,3 +1363,101 @@ def test_flush_empty_queue_returns_immediately(): # Key assertion: empty flush is fast assert qlen == 0, "Empty queue should return 0" assert elapsed < WAKEABLE_POLL_TIMEOUT_MAX, f"Empty flush should return quickly, took {elapsed:.2f}s" + + +# These tests verify consume()'s single blocking batch call: it honors the +# timeout and returns up to num_messages messages (empty here, no broker). + + +def test_consume_no_messages_waits_full_timeout(): + """consume() with no messages available should wait the full timeout + and return an empty list.""" + consumer = TestConsumer( + { + 'group.id': 'test-accumulate-no-msgs', + 'socket.timeout.ms': 100, + 'session.timeout.ms': 1000, + 'auto.offset.reset': 'latest', + } + ) + consumer.subscribe(['test-accumulate-topic']) + + start = time.time() + msglist = consumer.consume(num_messages=10, timeout=0.5) + elapsed = time.time() - start + + assert isinstance(msglist, list), "consume() should return a list" + assert len(msglist) == 0, "Expected empty list when no messages available" + assert ( + WAKEABLE_POLL_TIMEOUT_MIN <= elapsed <= WAKEABLE_POLL_TIMEOUT_MAX + ), f"Should wait ~0.5s for timeout, took {elapsed:.2f}s" + consumer.close() + + +def test_consume_accumulation_timeout_returns_partial(): + """consume() should return whatever messages it has when timeout expires, + even if fewer than num_messages. With no broker, this means empty list.""" + consumer = TestConsumer( + { + 'group.id': 'test-accumulate-partial', + 'socket.timeout.ms': 100, + 'session.timeout.ms': 1000, + 'auto.offset.reset': 'latest', + } + ) + consumer.subscribe(['test-accumulate-topic']) + + start = time.time() + msglist = consumer.consume(num_messages=1000, timeout=0.5) + elapsed = time.time() - start + + assert isinstance(msglist, list), "consume() should return a list" + assert len(msglist) == 0, "Expected empty list with no broker" + assert ( + WAKEABLE_POLL_TIMEOUT_MIN <= elapsed <= WAKEABLE_POLL_TIMEOUT_MAX + ), f"Should wait full timeout, took {elapsed:.2f}s" + consumer.close() + + +def test_consume_short_timeout_returns_quickly(): + """consume() with a short timeout should return promptly with an empty list.""" + consumer = TestConsumer( + { + 'group.id': 'test-accumulate-short', + 'socket.timeout.ms': 100, + 'session.timeout.ms': 1000, + 'auto.offset.reset': 'latest', + } + ) + consumer.subscribe(['test-accumulate-topic']) + + start = time.time() + msglist = consumer.consume(num_messages=10, timeout=0.05) + elapsed = time.time() - start + + assert isinstance(msglist, list), "consume() should return a list" + assert len(msglist) == 0, "Expected empty list" + assert elapsed <= WAKEABLE_POLL_TIMEOUT_MAX, f"Short timeout should return quickly, took {elapsed:.2f}s" + consumer.close() + + +def test_consume_accumulation_zero_timeout_nonblocking(): + """consume() with timeout=0 should return immediately.""" + consumer = TestConsumer( + { + 'group.id': 'test-accumulate-zero', + 'socket.timeout.ms': 100, + 'session.timeout.ms': 1000, + 'auto.offset.reset': 'latest', + } + ) + consumer.subscribe(['test-accumulate-topic']) + + start = time.time() + msglist = consumer.consume(num_messages=10, timeout=0.0) + elapsed = time.time() - start + + assert isinstance(msglist, list), "consume() should return a list" + assert len(msglist) == 0, "Expected empty list" + assert elapsed <= WAKEABLE_POLL_TIMEOUT_MAX, f"Zero timeout should return immediately, took {elapsed:.2f}s" + consumer.close()