Skip to content

Commit a98fe95

Browse files
Release v0.2.160
Queues support batching on consume
1 parent 2dcd17f commit a98fe95

6 files changed

Lines changed: 121 additions & 8 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ https://tina4.com/
8787
MIT © 2007 – 2025 Tina4 Stack
8888
https://opensource.org/licenses/MIT
8989

90+
## Testing
91+
92+
uv run pytest --verbose
93+
9094
---
9195

9296
**Tina4** – The framework that keeps out of the way of your coding.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "tina4-python"
3-
version = "0.2.159"
3+
version = "0.2.160"
44
description = "Tina4Python - This is not another framework for Python"
55
authors = [
66
{name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}

tests/test_mongo_queue.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,50 @@ def test_consumer_wrapper(mongo_config):
112112
assert len(collected) == 1
113113
assert collected[0].data == "to consume mongo"
114114

115+
def test_batch_mode():
116+
callback_calls = []
117+
118+
def my_callback(msg: Message):
119+
callback_calls.append(msg.data)
120+
121+
# Create queue with batch_size=5 and callback
122+
queue = Queue(topic="batch-test", batch_size=5, callback=my_callback)
123+
124+
# Produce 12 messages
125+
for i in range(12):
126+
queue.produce(f"Message {i+1}", user_id="tester")
127+
time.sleep(0.2)
128+
129+
# Consume in batch mode
130+
batches = []
131+
total_messages = 0
132+
133+
consumer = Consumer([queue], acknowledge=True)
134+
135+
for item in consumer.messages():
136+
assert isinstance(item, list)
137+
assert all(isinstance(m, Message) for m in item)
138+
batch_size = len(item)
139+
batches.append(batch_size)
140+
total_messages += batch_size
141+
142+
143+
# Extract data for easier checking
144+
batch_data = [msg.data for msg in item]
145+
print("BATCH_DATA", batch_data)
146+
expected_data = [f"Message {total_messages - batch_size + j + 1}" for j in range(batch_size)]
147+
assert batch_data == expected_data
148+
149+
if total_messages >= 12:
150+
break
151+
152+
# Verify we got the expected batch sizes: 5 + 5 + 2
153+
assert batches == [5, 5, 2]
154+
assert total_messages == 12
155+
assert len(callback_calls) == 12 # Callback called once per message
156+
assert sorted(callback_calls) == sorted([f"Message {i+1}" for i in range(12)])
157+
158+
115159
def test_error_handling(mongo_config):
116160
topic = unique_topic("error")
117161
clear_mongo_channel(topic, prefix=mongo_config.prefix)

tests/test_queues.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from tina4_python.Queue import Queue, Config, Message, Producer, Consumer
55

6+
67
# Helper to clean up SQLite files completely
78
def cleanup_db(db_name):
89
for suffix in ["", "-wal", "-shm", "-journal"]:
@@ -17,6 +18,7 @@ def cleanup_db(db_name):
1718
except:
1819
pass # best effort
1920

21+
2022
@pytest.fixture(scope="function")
2123
def litequeue_config():
2224
# Unique DB name per test run to avoid locks
@@ -28,17 +30,20 @@ def litequeue_config():
2830
yield config
2931
cleanup_db(config.litequeue_database_name)
3032

33+
3134
@pytest.fixture(scope="function")
3235
def lite_queue(litequeue_config):
3336
q = Queue(config=litequeue_config, topic="test-topic")
3437
yield q
3538
cleanup_db(litequeue_config.litequeue_database_name)
3639

40+
3741
def test_init_litequeue(lite_queue):
3842
assert lite_queue.producer is not None
3943
assert lite_queue.consumer is not None
4044
assert lite_queue.get_prefix() == "test_"
4145

46+
4247
def test_produce_litequeue(lite_queue):
4348
response = lite_queue.produce("hello world", user_id="user123")
4449
assert isinstance(response, Message)
@@ -47,6 +52,7 @@ def test_produce_litequeue(lite_queue):
4752
assert response.status == 0
4853
assert len(response.message_id) == 36
4954

55+
5056
def test_produce_with_callback(lite_queue):
5157
delivered = [None]
5258

@@ -58,6 +64,7 @@ def delivery_cb(producer, err, msg):
5864
assert isinstance(delivered[0][1], Message)
5965
assert delivered[0][1].data == "callback test"
6066

67+
6168
def test_consume_litequeue(lite_queue):
6269
lite_queue.produce("consume me", user_id="user789")
6370

@@ -69,6 +76,7 @@ def test_consume_litequeue(lite_queue):
6976
assert msg.data == "consume me"
7077
assert msg.status == 2 # Acknowledged
7178

79+
7280
def test_consume_no_ack(lite_queue):
7381
lite_queue.produce("no ack test", user_id="user000")
7482

@@ -77,13 +85,15 @@ def test_consume_no_ack(lite_queue):
7785
msg = messages[0]
7886
assert msg.status == 1 # Not acknowledged
7987

88+
8089
def test_producer_wrapper(litequeue_config):
8190
q = Queue(config=litequeue_config, topic="producer-test")
8291
producer = Producer(q)
8392
response = producer.produce("wrapped produce", user_id="wrapped_user")
8493
assert isinstance(response, Message)
8594
assert response.data == "wrapped produce"
8695

96+
8797
def test_consumer_wrapper(litequeue_config):
8898
q = Queue(config=litequeue_config, topic="consumer-test")
8999
q.produce("to consume", user_id="consume_user")
@@ -100,10 +110,55 @@ def test_consumer_wrapper(litequeue_config):
100110
assert len(collected) >= 1
101111
assert any(m.data == "to consume" for m in collected)
102112

113+
114+
def test_batch_mode():
115+
callback_calls = []
116+
117+
def my_callback(msg: Message):
118+
callback_calls.append(msg.data)
119+
120+
# Create queue with batch_size=5 and callback
121+
queue = Queue(topic="batch-test", batch_size=5, callback=my_callback)
122+
123+
# Produce 12 messages
124+
for i in range(12):
125+
queue.produce(f"Message {i + 1}", user_id="tester")
126+
time.sleep(0.1)
127+
128+
# Consume in batch mode
129+
batches = []
130+
total_messages = 0
131+
132+
consumer = Consumer([queue], acknowledge=True)
133+
134+
for item in consumer.messages():
135+
assert isinstance(item, list)
136+
assert all(isinstance(m, Message) for m in item)
137+
batch_size = len(item)
138+
batches.append(batch_size)
139+
total_messages += batch_size
140+
141+
# Extract data for easier checking
142+
batch_data = [msg.data for msg in item]
143+
144+
expected_data = [f"Message {total_messages - batch_size + j + 1}" for j in range(batch_size)]
145+
print("BATCH DATA", batch_data, expected_data)
146+
assert batch_data == expected_data
147+
148+
if total_messages >= 12:
149+
break
150+
151+
# Verify we got the expected batch sizes: 5 + 5 + 2
152+
assert batches == [5, 5, 2]
153+
assert total_messages == 12
154+
assert len(callback_calls) == 12 # Callback called once per message
155+
assert sorted(callback_calls) == sorted([f"Message {i + 1}" for i in range(12)])
156+
157+
103158
def test_error_handling(lite_queue):
104159
with pytest.raises(Exception):
105160
lite_queue.produce(None)
106161

107162
# Consume on empty queue — should yield nothing, no error
108163
messages = list(lite_queue.consume())
109-
assert len(messages) == 0
164+
assert len(messages) == 0

tina4_python/Queue.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,15 @@ class Message:
5555
delivery_tag: str
5656

5757
class Queue:
58-
def __init__(self, config=None, topic="default-queue", callback=None):
58+
def __init__(self, config=None, topic="default-queue", callback=None, batch_size=1):
5959
if config is None:
6060
config = Config()
6161
self.config = config
6262
self.topic = topic
6363
self.callback = callback
6464
self.producer = None
6565
self.consumer = None
66+
self.batch_size = batch_size
6667
init_method = f"init_{config.queue_type.replace('-', '_')}"
6768
getattr(self, init_method)()
6869

@@ -101,7 +102,7 @@ def kafka_cb(err, kafka_msg):
101102
delivery_callback(self.producer, e, None)
102103
return e
103104

104-
def consume(self, acknowledge: bool = True) -> Generator[Message, None, None]:
105+
def consume(self, acknowledge: bool = True) -> Generator[Message | List[Message], None, None]:
105106
"""
106107
Generator that continuously yields messages from the queue as they arrive.
107108
Use like:
@@ -110,9 +111,12 @@ def consume(self, acknowledge: bool = True) -> Generator[Message, None, None]:
110111
If a callback was provided in __init__, it will also be called for each message.
111112
"""
112113
prefix = self.get_prefix()
114+
is_batch = self.batch_size > 1
115+
count_messages = 0
113116
try:
114117
message_found = True
115-
while message_found:
118+
batch = []
119+
while message_found and count_messages < self.batch_size:
116120
response = None
117121
message_found = False
118122

@@ -153,8 +157,9 @@ def consume(self, acknowledge: bool = True) -> Generator[Message, None, None]:
153157
if acknowledge:
154158
self.consumer.commit()
155159

156-
if response is not None:
157-
yield response
160+
if message_found:
161+
count_messages += 1
162+
batch.append(response)
158163
if self.callback:
159164
try:
160165
self.callback(response)
@@ -164,6 +169,11 @@ def consume(self, acknowledge: bool = True) -> Generator[Message, None, None]:
164169
# No message available right now — brief sleep to avoid busy loop
165170
time.sleep(0.05)
166171

172+
if len(batch) > 0:
173+
if not is_batch:
174+
yield batch[0]
175+
else:
176+
yield batch
167177
except Exception as e:
168178
Debug.error(f"Error consuming {self.topic}: {e}")
169179
raise # Re-raise to stop consumption on fatal error

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)