twainmq 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twainmq/__init__.py +24 -0
- twainmq/agent_base.py +44 -0
- twainmq/atomic_append.py +100 -0
- twainmq/consumer.py +357 -0
- twainmq/consumer_groups.py +143 -0
- twainmq/core.py +267 -0
- twainmq/encoding.py +121 -0
- twainmq/errors.py +14 -0
- twainmq/producer.py +141 -0
- twainmq/utils.py +41 -0
- twainmq-0.2.3.dist-info/METADATA +65 -0
- twainmq-0.2.3.dist-info/RECORD +15 -0
- twainmq-0.2.3.dist-info/WHEEL +5 -0
- twainmq-0.2.3.dist-info/licenses/LICENSE +21 -0
- twainmq-0.2.3.dist-info/top_level.txt +1 -0
twainmq/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
TwainMQ is a broker free messaging system. Unlike similar tools like Apache Kafka and RabbitMQ, TwainMQ does not have any running services or brokers to coordinate messages, being run entirely from files. Think of it like SQLite is to traditional database systems.
|
|
4
|
+
|
|
5
|
+
To set up a TwainMQ message queue you simply need to specify a directory for it to use, either locally or on a network share.
|
|
6
|
+
|
|
7
|
+
Messages are limited in size to ensure that they can be written quickly. The size limit is currently set at slightly less than 4kb.
|
|
8
|
+
|
|
9
|
+
Messages can be either plain UTF-8 or raw bytes.
|
|
10
|
+
|
|
11
|
+
Messages are organised by key, partition and topic.
|
|
12
|
+
|
|
13
|
+
## Keys
|
|
14
|
+
|
|
15
|
+
u8
|
|
16
|
+
u16
|
|
17
|
+
u32
|
|
18
|
+
u64
|
|
19
|
+
str
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .core import Twain, TwainMQConsumer, TwainMQProducer
|
|
24
|
+
from .utils import multi_poll
|
twainmq/agent_base.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from twainmq.encoding import find_key_char_width
|
|
6
|
+
from twainmq.errors import ConfigNotFoundError
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .core import Twain
|
|
12
|
+
|
|
13
|
+
class TwainMQBase(ABC):
|
|
14
|
+
def __init__(self, twain: "Twain", topic: str):
|
|
15
|
+
self._topic = topic
|
|
16
|
+
self._twain = twain
|
|
17
|
+
config_path = self._twain._config_path(topic)
|
|
18
|
+
if not config_path.is_file():
|
|
19
|
+
raise ConfigNotFoundError(f"No config for {topic}: cannot find file {config_path}")
|
|
20
|
+
with config_path.open("r") as f:
|
|
21
|
+
config = json.load(f)
|
|
22
|
+
self._key_width = config["key_width"]
|
|
23
|
+
self._message_types = config["message_types"]
|
|
24
|
+
self._message_types_rev = {i: msg_type for msg_type, i in self._message_types.items()}
|
|
25
|
+
self._n_partitions = int(config["partitions"])
|
|
26
|
+
self._key_chars = find_key_char_width(self._key_width)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def topic(self):
|
|
30
|
+
return self._topic
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def _topic_dir(self):
|
|
34
|
+
return self._twain.root_dir / self._topic
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def key_width(self):
|
|
38
|
+
"""The width of the key in bytes. 0 indicates a string key.
|
|
39
|
+
"""
|
|
40
|
+
return self._key_width
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def chunk_str_now(self):
|
|
44
|
+
return f"{datetime.now(timezone.utc):%Y%m%d}"
|
twainmq/atomic_append.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import errno
|
|
4
|
+
import ctypes
|
|
5
|
+
from ctypes import wintypes
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import os
|
|
8
|
+
import errno
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Win32 constants
|
|
12
|
+
FILE_APPEND_DATA = 0x0004
|
|
13
|
+
FILE_SHARE_READ = 0x00000001
|
|
14
|
+
FILE_SHARE_WRITE = 0x00000002
|
|
15
|
+
OPEN_ALWAYS = 4
|
|
16
|
+
FILE_ATTRIBUTE_NORMAL = 0x80
|
|
17
|
+
INVALID_HANDLE_VALUE = wintypes.HANDLE(-1).value
|
|
18
|
+
|
|
19
|
+
if os.name == "nt":
|
|
20
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
21
|
+
|
|
22
|
+
def atomic_append_win(path, data: bytes) -> None:
|
|
23
|
+
"""Atomic appending to a file under Windows.
|
|
24
|
+
|
|
25
|
+
This makes use of the windows FILE_APPEND_DATA mode which doesn't check the file length first or use a cursor
|
|
26
|
+
it simply dumps the data on the end of the file.
|
|
27
|
+
"""
|
|
28
|
+
# Accept Path or str
|
|
29
|
+
if isinstance(path, Path):
|
|
30
|
+
path = str(path)
|
|
31
|
+
|
|
32
|
+
# 1. Open with FILE_APPEND_DATA only (critical)
|
|
33
|
+
handle = kernel32.CreateFileW(
|
|
34
|
+
wintypes.LPCWSTR(path),
|
|
35
|
+
FILE_APPEND_DATA, # <-- atomic append mode
|
|
36
|
+
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
|
37
|
+
None,
|
|
38
|
+
OPEN_ALWAYS,
|
|
39
|
+
FILE_ATTRIBUTE_NORMAL,
|
|
40
|
+
None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if handle == INVALID_HANDLE_VALUE:
|
|
44
|
+
raise OSError(ctypes.get_last_error(), "CreateFileW failed")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# 2. WriteFile (one syscall, atomic append)
|
|
48
|
+
written = wintypes.DWORD(0)
|
|
49
|
+
ok = kernel32.WriteFile(
|
|
50
|
+
handle,
|
|
51
|
+
data,
|
|
52
|
+
len(data),
|
|
53
|
+
ctypes.byref(written),
|
|
54
|
+
None,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if not ok or written.value != len(data):
|
|
58
|
+
raise OSError(ctypes.get_last_error(), "WriteFile failed")
|
|
59
|
+
|
|
60
|
+
finally:
|
|
61
|
+
kernel32.CloseHandle(handle)
|
|
62
|
+
|
|
63
|
+
def atomic_append_posix(path, data: bytes, mode: int = 0o644) -> None:
|
|
64
|
+
"""Atomic append function for POSIX systems.
|
|
65
|
+
|
|
66
|
+
This should work by using O_APPEND but this is not properly tested yet!
|
|
67
|
+
|
|
68
|
+
Short write might in theory happen, but shouldn't, when we can test this better
|
|
69
|
+
we will know if this is a problem and come up with a solution.
|
|
70
|
+
"""
|
|
71
|
+
# Accept Path or str
|
|
72
|
+
if isinstance(path, Path):
|
|
73
|
+
path = os.fspath(path)
|
|
74
|
+
|
|
75
|
+
# Open with O_APPEND so the kernel moves the file offset atomically
|
|
76
|
+
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
|
|
77
|
+
|
|
78
|
+
# Add close-on-exec if available (good hygiene)
|
|
79
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
80
|
+
|
|
81
|
+
fd = os.open(path, flags, mode)
|
|
82
|
+
try:
|
|
83
|
+
# Exactly one write() syscall
|
|
84
|
+
n = os.write(fd, data)
|
|
85
|
+
|
|
86
|
+
# POSIX guarantees full writes to regular files unless an error occurs.
|
|
87
|
+
# If we ever see a short write, treat it as fatal.
|
|
88
|
+
if n != len(data):
|
|
89
|
+
raise OSError(errno.EIO, f"short write: wrote {n} of {len(data)} bytes")
|
|
90
|
+
|
|
91
|
+
finally:
|
|
92
|
+
os.close(fd)
|
|
93
|
+
|
|
94
|
+
def atomic_append(path, data: bytes, mode: int = 0o644) -> None:
|
|
95
|
+
"""Atomic writing used by producers to allow multiple producers to write to the same
|
|
96
|
+
log file at the same time without locks"""
|
|
97
|
+
if os.name == "nt":
|
|
98
|
+
atomic_append_win(path, data)
|
|
99
|
+
else:
|
|
100
|
+
atomic_append_posix(path, data)
|
twainmq/consumer.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
|
|
2
|
+
import base64
|
|
3
|
+
import bisect
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
import zlib
|
|
10
|
+
|
|
11
|
+
from .agent_base import TwainMQBase
|
|
12
|
+
from .encoding import _DATACLASS_MAGIC, _GZIP_MAGIC, Message, base85_to_key, dataclass_from_dict, decode_datetime
|
|
13
|
+
from .errors import TopicCorruptError
|
|
14
|
+
from .consumer_groups import CONSUMER_GROUP_MESSAGE_SET, REBAL_LENGTH, AbortJoin, BeginRebal, Commit, EndRebal, Joined, RebalConfirm, RebalInProgress, RebalOffer, _group_topic_name
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .core import Twain
|
|
18
|
+
|
|
19
|
+
class TwainMQConsumer(TwainMQBase):
|
|
20
|
+
"""
|
|
21
|
+
A TwainMQ consumer.
|
|
22
|
+
|
|
23
|
+
Consumers may operate independently or as part of a consumer group. A
|
|
24
|
+
standalone consumer can read messages but cannot commit progress. A consumer
|
|
25
|
+
that belongs to a group (even a group of one) can commit offsets, allowing
|
|
26
|
+
any consumer joining that group to resume from the last committed position.
|
|
27
|
+
|
|
28
|
+
The `start_from` parameter controls the initial read position only when:
|
|
29
|
+
- the consumer is not part of a group, or
|
|
30
|
+
- the group has no existing commit record.
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, twain: "Twain", topic: str, start_from = "start", group = None):
|
|
33
|
+
"""
|
|
34
|
+
Create a consumer for a given topic.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
twain : Twain
|
|
39
|
+
The TwainMQ instance backing this consumer.
|
|
40
|
+
topic : str
|
|
41
|
+
The topic to consume from.
|
|
42
|
+
start_from : {"start", "now"}, optional
|
|
43
|
+
Initial read position when no committed offset exists. `"start"`
|
|
44
|
+
begins at offset 0; `"now"` begins at the current end of each
|
|
45
|
+
partition.
|
|
46
|
+
group : str or None, optional
|
|
47
|
+
The consumer-group identifier. If `None`, the consumer operates
|
|
48
|
+
independently and cannot commit offsets. If provided, the consumer
|
|
49
|
+
joins the group and participates in consensus-based partition
|
|
50
|
+
assignment and offset commits.
|
|
51
|
+
"""
|
|
52
|
+
super().__init__(twain, topic)
|
|
53
|
+
self._twain = twain
|
|
54
|
+
self._topic = topic
|
|
55
|
+
self._group = group
|
|
56
|
+
self._rebal_ending = None
|
|
57
|
+
self._consumer_id = None
|
|
58
|
+
self._rebal_in_progress = None
|
|
59
|
+
self._commit_record = dict()
|
|
60
|
+
if group is None:
|
|
61
|
+
self._partitions = [i for i in range(self._n_partitions)]
|
|
62
|
+
if start_from == "start":
|
|
63
|
+
self._consumerlets = {p: TwainMQConsumerlet(twain, topic, p, offset=0) for p in self._partitions}
|
|
64
|
+
elif start_from == "now":
|
|
65
|
+
self._consumerlets = {p: TwainMQConsumerlet(twain, topic, p, offset=None) for p in self._partitions}
|
|
66
|
+
else:
|
|
67
|
+
raise ValueError(f'start_from should be either "start" of "now", received: "{start_from}"')
|
|
68
|
+
else:
|
|
69
|
+
self._partitions = []
|
|
70
|
+
self._consumerlets = dict()
|
|
71
|
+
self._join_group(group, start_from)
|
|
72
|
+
self._last_polled = 0
|
|
73
|
+
self.heartbeat()
|
|
74
|
+
|
|
75
|
+
def _create_consumer_group(self, group_topic, start_from):
|
|
76
|
+
self._twain.create_topic(group_topic, key_type = "u32", partitions = 1, message_types = CONSUMER_GROUP_MESSAGE_SET)
|
|
77
|
+
with self._twain.producer(group_topic) as gprod:
|
|
78
|
+
if start_from == "start":
|
|
79
|
+
init_commits = [(i, 0) for i in range(self._n_partitions)]
|
|
80
|
+
elif start_from == "now":
|
|
81
|
+
init_commits = [(i, -1) for i in range(self._n_partitions)]
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(f'start_from should be either "start" of "now", received: "{start_from}"')
|
|
84
|
+
gprod.write_message(0, Commit(init_commits))
|
|
85
|
+
|
|
86
|
+
def _join_group(self, group, start_from, retry=3):
|
|
87
|
+
"""Called when a consumer joins a group to handle id registration with the group"""
|
|
88
|
+
group_topic = _group_topic_name(group)
|
|
89
|
+
if not self._twain.topic_exists(group_topic):
|
|
90
|
+
self._create_consumer_group(group_topic, start_from)
|
|
91
|
+
gprod = self._twain.producer(group_topic)
|
|
92
|
+
gcon = self._twain.consumer(group_topic, start_from = "start", group = None)
|
|
93
|
+
self._group_producer = gprod
|
|
94
|
+
self._group_consumer = gcon
|
|
95
|
+
|
|
96
|
+
keys_taken = set()
|
|
97
|
+
while (msg := gcon.poll()) is not None:
|
|
98
|
+
keys_taken.add(msg.key)
|
|
99
|
+
self._process_group_msg(msg)
|
|
100
|
+
|
|
101
|
+
consumer_id = max(keys_taken) + 1
|
|
102
|
+
join_key = random.getrandbits(64)
|
|
103
|
+
gprod.write_message(consumer_id, Joined(join_key))
|
|
104
|
+
|
|
105
|
+
while True:
|
|
106
|
+
msg = gcon.poll()
|
|
107
|
+
if msg is None:
|
|
108
|
+
time.sleep(0.5)
|
|
109
|
+
elif isinstance(msg.message, Joined):
|
|
110
|
+
key = msg.message.tag
|
|
111
|
+
if join_key == key:
|
|
112
|
+
self._consumer_id = consumer_id
|
|
113
|
+
self._begin_rebal()
|
|
114
|
+
return
|
|
115
|
+
else:
|
|
116
|
+
gprod.write_message(consumer_id, AbortJoin(key))
|
|
117
|
+
if retry > 0:
|
|
118
|
+
time.sleep(random.random() * 0.1)
|
|
119
|
+
return self._join_group(group, start_from, retry = retry - 1)
|
|
120
|
+
else:
|
|
121
|
+
return
|
|
122
|
+
else:
|
|
123
|
+
self._process_group_msg(msg)
|
|
124
|
+
|
|
125
|
+
def trigger_rebal(self):
|
|
126
|
+
"""
|
|
127
|
+
Initiate a consumer-group rebalance.
|
|
128
|
+
|
|
129
|
+
In TwainMQ, consumer-group membership is maintained through periodic
|
|
130
|
+
consensus. Triggering a rebalance is lightweight and safe, and is typically
|
|
131
|
+
done periodically to confirm that all consumers in the group are still
|
|
132
|
+
active.
|
|
133
|
+
|
|
134
|
+
If all consumers respond within the rebalance window, the rebalance becomes
|
|
135
|
+
a no-op and existing partition assignments remain unchanged.
|
|
136
|
+
"""
|
|
137
|
+
self._begin_rebal()
|
|
138
|
+
|
|
139
|
+
def _begin_rebal(self):
|
|
140
|
+
gprod = self._group_producer
|
|
141
|
+
rebal_end_ts = (datetime.now(timezone.utc) + REBAL_LENGTH).timestamp()
|
|
142
|
+
gprod.write_message(self._consumer_id, BeginRebal(rebal_end_ts))
|
|
143
|
+
self.heartbeat()
|
|
144
|
+
|
|
145
|
+
def _rebal_participate(self):
|
|
146
|
+
gprod = self._group_producer
|
|
147
|
+
gprod.write_message(self._consumer_id, RebalOffer(self._partitions, [i for i in range(self._n_partitions)]))
|
|
148
|
+
|
|
149
|
+
def _end_rebal(self):
|
|
150
|
+
gprod = self._group_producer
|
|
151
|
+
gprod.write_message(self._consumer_id, EndRebal())
|
|
152
|
+
self._confirm_rebal()
|
|
153
|
+
|
|
154
|
+
def _confirm_rebal(self):
|
|
155
|
+
gprod = self._group_producer
|
|
156
|
+
assert self._rebal_in_progress is not None
|
|
157
|
+
assignements = self._rebal_in_progress.get_assignments()
|
|
158
|
+
self._partitions = assignements.get(self._consumer_id, [])
|
|
159
|
+
self._consumerlets = {p: self._consumerlets.get(p, TwainMQConsumerlet(self._twain, self._topic, p, offset=self._commit_record.get(p,0))) for p in self._partitions}
|
|
160
|
+
gprod.write_message(self._consumer_id, RebalConfirm(sorted(list(self._partitions))))
|
|
161
|
+
self._rebal_in_progress = None
|
|
162
|
+
|
|
163
|
+
def commit(self, partitions = None):
|
|
164
|
+
if self._group is not None:
|
|
165
|
+
if partitions is None:
|
|
166
|
+
partitions = self._partitions
|
|
167
|
+
commit_list = [(p, self._consumerlets[p]._offset) for p in partitions if p in self._consumerlets]
|
|
168
|
+
print(commit_list)
|
|
169
|
+
gprod = self._group_producer
|
|
170
|
+
gprod.write_message(self._consumer_id, Commit(commit_list))
|
|
171
|
+
|
|
172
|
+
def _handle_commit_msg(self, commit_msg):
|
|
173
|
+
for p,o in commit_msg.partition_offsets:
|
|
174
|
+
self._commit_record[p] = o
|
|
175
|
+
|
|
176
|
+
def _process_group_msg(self, msg):
|
|
177
|
+
if isinstance(msg.message, BeginRebal):
|
|
178
|
+
if self._consumer_id:
|
|
179
|
+
self._rebal_in_progress = RebalInProgress(msg.message.timestamp)
|
|
180
|
+
self._rebal_participate()
|
|
181
|
+
elif isinstance(msg.message, RebalOffer):
|
|
182
|
+
if self._rebal_in_progress:
|
|
183
|
+
self._rebal_in_progress.add_offer(msg.key, msg.message)
|
|
184
|
+
elif isinstance(msg.message, EndRebal):
|
|
185
|
+
if self._rebal_in_progress:
|
|
186
|
+
self._confirm_rebal()
|
|
187
|
+
elif isinstance(msg.message, Commit):
|
|
188
|
+
self._handle_commit_msg(msg.message)
|
|
189
|
+
else:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
def heartbeat(self):
|
|
193
|
+
"""
|
|
194
|
+
Maintain membership in a consumer group and process group-management events.
|
|
195
|
+
|
|
196
|
+
This method must be called periodically when using consumer groups; otherwise
|
|
197
|
+
the consumer may be considered inactive and be removed from the group.
|
|
198
|
+
|
|
199
|
+
Behaviour
|
|
200
|
+
---------
|
|
201
|
+
- Processes any pending group-management messages (rebalance events, etc.).
|
|
202
|
+
- Automatically invoked at the start of `poll()`.
|
|
203
|
+
"""
|
|
204
|
+
if self._group is not None:
|
|
205
|
+
while msg := self._group_consumer.poll():
|
|
206
|
+
self._process_group_msg(msg)
|
|
207
|
+
if self._rebal_in_progress and self._rebal_in_progress.rebal_ending < datetime.now(timezone.utc).timestamp():
|
|
208
|
+
self._end_rebal()
|
|
209
|
+
|
|
210
|
+
def poll(self):
|
|
211
|
+
"""
|
|
212
|
+
Poll partitions in round-robin order and return the next available message.
|
|
213
|
+
|
|
214
|
+
The consumer maintains a list of assigned partitions. Each call polls them
|
|
215
|
+
starting from the partition after the one last successfully polled. The first
|
|
216
|
+
non-`None` message returned by a partition is yielded.
|
|
217
|
+
|
|
218
|
+
This call does not block.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
message or None
|
|
223
|
+
The next available message, or `None` if all partitions return no data.
|
|
224
|
+
"""
|
|
225
|
+
self.heartbeat()
|
|
226
|
+
n = len(self._partitions)
|
|
227
|
+
for p_offset in range(n):
|
|
228
|
+
partition_to_poll = self._partitions[(self._last_polled + p_offset + 1) % n]
|
|
229
|
+
this_consumerlet = self._consumerlets[partition_to_poll]
|
|
230
|
+
msg = this_consumerlet.poll()
|
|
231
|
+
if msg is not None:
|
|
232
|
+
self._last_polled = partition_to_poll
|
|
233
|
+
return msg
|
|
234
|
+
|
|
235
|
+
def poll_many(self, n=10):
|
|
236
|
+
"""
|
|
237
|
+
Poll up to `n` messages and return them as a list.
|
|
238
|
+
|
|
239
|
+
This is a convenience wrapper around `poll()`. Polling stops early if
|
|
240
|
+
`poll()` returns `None`.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
n : int, optional
|
|
245
|
+
Maximum number of messages to retrieve.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
list
|
|
250
|
+
A list of messages, possibly shorter than `n`.
|
|
251
|
+
"""
|
|
252
|
+
msgs = []
|
|
253
|
+
for i in range(n):
|
|
254
|
+
msg = self.poll()
|
|
255
|
+
if msg is None:
|
|
256
|
+
break
|
|
257
|
+
msgs.append(msg)
|
|
258
|
+
return msgs
|
|
259
|
+
|
|
260
|
+
def __str__(self):
|
|
261
|
+
return f"TwainMQConsumer(topic={self._topic}, group={self._group})"
|
|
262
|
+
|
|
263
|
+
class TwainMQConsumerlet(TwainMQBase):
|
|
264
|
+
"""A consumerlet is a simple single partition consumer. Usually you would not use a Consumerlet directly, rather use the Consumer container"""
|
|
265
|
+
def __init__(self, twain, topic, partition, offset=None):
|
|
266
|
+
super().__init__(twain, topic)
|
|
267
|
+
self._partition = partition
|
|
268
|
+
self._seek_active_file(offset)
|
|
269
|
+
|
|
270
|
+
def decode_message(self, message):
|
|
271
|
+
compressed = base64.b85decode(message.encode("utf-8"))
|
|
272
|
+
decoded = zlib.decompress(compressed, wbits=-15)
|
|
273
|
+
if decoded.startswith(_GZIP_MAGIC):
|
|
274
|
+
return decoded[1:]
|
|
275
|
+
elif decoded.startswith(_DATACLASS_MAGIC):
|
|
276
|
+
class_id = int.from_bytes(decoded[1:3], "big", signed=False)
|
|
277
|
+
message_type = self._twain._msg_cls_registry[self._message_types_rev[class_id]]
|
|
278
|
+
data = json.loads(decoded[3:])
|
|
279
|
+
return dataclass_from_dict(message_type, data)
|
|
280
|
+
else:
|
|
281
|
+
return decoded.decode("utf-8")
|
|
282
|
+
|
|
283
|
+
def _seek_active_file(self, offset = None):
|
|
284
|
+
"""Sets the file handle to the active file to read from and seeks to the end.
|
|
285
|
+
|
|
286
|
+
If offset is None this is read from latest
|
|
287
|
+
|
|
288
|
+
If the latest file is missing then it will return None.
|
|
289
|
+
"""
|
|
290
|
+
part_files = self._list_partition_files()
|
|
291
|
+
if offset is None or len(part_files) == 0:
|
|
292
|
+
chunk_str = self.chunk_str_now
|
|
293
|
+
head_file = [f for f in part_files if f.stem.split("-")[1].split("_")[0] == chunk_str]
|
|
294
|
+
if len(head_file) == 0:
|
|
295
|
+
self._current_file_handle = None
|
|
296
|
+
elif len(head_file) == 1:
|
|
297
|
+
offset = int(head_file[0].stem.split("_")[1])
|
|
298
|
+
self._current_file_handle = head_file[0].open("r", encoding = "utf-8")
|
|
299
|
+
while self._current_file_handle.readline():
|
|
300
|
+
offset += 1
|
|
301
|
+
self._offset = offset
|
|
302
|
+
self._chunk_str = chunk_str
|
|
303
|
+
else:
|
|
304
|
+
raise TopicCorruptError(f"Multiple message files for the same chunk partition {self._partition}-{chunk_str}")
|
|
305
|
+
elif offset >= 0:
|
|
306
|
+
offsets_files_chunks = sorted([(int(f.stem.split("_")[1]), f, f.stem.split("_")[0].split("-")[1]) for f in part_files])
|
|
307
|
+
|
|
308
|
+
offsets = [o for o, _, _ in offsets_files_chunks]
|
|
309
|
+
i = bisect.bisect_right(offsets, offset) - 1
|
|
310
|
+
this_offset, current_file, chunk_str = offsets_files_chunks[max(i, 0)]
|
|
311
|
+
|
|
312
|
+
self._current_file_handle = current_file.open("r", encoding = "utf-8")
|
|
313
|
+
self._chunk_str = chunk_str
|
|
314
|
+
self._offset = this_offset
|
|
315
|
+
while self._offset < offset:
|
|
316
|
+
line = self._current_file_handle.readline()
|
|
317
|
+
if line != "":
|
|
318
|
+
self._offset += 1
|
|
319
|
+
else:
|
|
320
|
+
break
|
|
321
|
+
else:
|
|
322
|
+
raise NotImplementedError("Seek back from end not yet implemented")
|
|
323
|
+
|
|
324
|
+
def _list_partition_files(self):
|
|
325
|
+
return [f for f in self._topic_dir.iterdir() if f.stem.split("-")[0] == str(self._partition)]
|
|
326
|
+
|
|
327
|
+
def poll(self):
|
|
328
|
+
"""Polls for the next message without blocking. Returns None if no new message available."""
|
|
329
|
+
if self._current_file_handle is None:
|
|
330
|
+
self._seek_active_file()
|
|
331
|
+
if self._current_file_handle is None:
|
|
332
|
+
return None
|
|
333
|
+
msg_line = self._current_file_handle.readline()[:-1]
|
|
334
|
+
if msg_line == "":
|
|
335
|
+
if self.chunk_str_now == self._chunk_str:
|
|
336
|
+
return None
|
|
337
|
+
else:
|
|
338
|
+
self._seek_active_file(self._offset)
|
|
339
|
+
msg_line = self._current_file_handle.readline()[:-1]
|
|
340
|
+
if msg_line == "":
|
|
341
|
+
return None
|
|
342
|
+
key = base85_to_key(msg_line[:self._key_chars], self._key_width)
|
|
343
|
+
timestamp = decode_datetime(msg_line[self._key_chars:self._key_chars+10])
|
|
344
|
+
message = self.decode_message(msg_line[self._key_chars+10:])
|
|
345
|
+
msg_tuple = Message(
|
|
346
|
+
offset=self._offset,
|
|
347
|
+
key=key,
|
|
348
|
+
timestamp=timestamp,
|
|
349
|
+
message=message
|
|
350
|
+
)
|
|
351
|
+
self._offset += 1
|
|
352
|
+
return msg_tuple
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def partition(self):
|
|
356
|
+
return self._partition
|
|
357
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from dataclasses_jsonschema import JsonSchemaMixin
|
|
4
|
+
|
|
5
|
+
from .encoding import _is_safe
|
|
6
|
+
from .errors import InvalidGroupNameError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
REBAL_LENGTH = timedelta(seconds = 60)
|
|
10
|
+
|
|
11
|
+
## CONSUMER GROUP MESSAGES
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Joined(JsonSchemaMixin):
|
|
15
|
+
tag: int
|
|
16
|
+
__message_type__ = "~~TWAIN~~JOINED"
|
|
17
|
+
|
|
18
|
+
def __init__(self, tag):
|
|
19
|
+
self.tag = tag
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AbortJoin(JsonSchemaMixin):
|
|
23
|
+
tag: int
|
|
24
|
+
__message_type__ = "~~TWAIN~~ABORTJOIN"
|
|
25
|
+
|
|
26
|
+
def __init__(self, tag):
|
|
27
|
+
self.tag = tag
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RebalOffer(JsonSchemaMixin):
|
|
31
|
+
own: list[int]
|
|
32
|
+
partitions: list[int]
|
|
33
|
+
__message_type__ = "~~TWAIN~~REBALOFFER"
|
|
34
|
+
|
|
35
|
+
def __init__(self, own, partitions):
|
|
36
|
+
self.own = own
|
|
37
|
+
self.partitions = partitions
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RebalConfirm(JsonSchemaMixin):
|
|
41
|
+
partitions: list[int]
|
|
42
|
+
__message_type__ = "~~TWAIN~~REBALCONFIRM"
|
|
43
|
+
|
|
44
|
+
def __init__(self, partitions):
|
|
45
|
+
self.partitions = partitions
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class BeginRebal(JsonSchemaMixin):
|
|
49
|
+
timestamp: float
|
|
50
|
+
__message_type__ = "~~TWAIN~~BEGINREBAL"
|
|
51
|
+
|
|
52
|
+
def __init__(self, timestamp):
|
|
53
|
+
self.timestamp = timestamp
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class EndRebal(JsonSchemaMixin):
|
|
57
|
+
__message_type__ = "~~TWAIN~~ENDREBAL"
|
|
58
|
+
|
|
59
|
+
def __init__(self):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Commit(JsonSchemaMixin):
|
|
64
|
+
partition_offsets: list[tuple[int, int]]
|
|
65
|
+
__message_type__ = "~~TWAIN~~COMMIT"
|
|
66
|
+
|
|
67
|
+
def __init__(self, partition_offsets):
|
|
68
|
+
self.partition_offsets = partition_offsets
|
|
69
|
+
|
|
70
|
+
CONSUMER_GROUP_MESSAGE_CLASSES = [Joined, AbortJoin, RebalOffer, RebalConfirm, BeginRebal, EndRebal, Commit]
|
|
71
|
+
CONSUMER_GROUP_MESSAGE_SET = [c.__message_type__ for c in CONSUMER_GROUP_MESSAGE_CLASSES]
|
|
72
|
+
|
|
73
|
+
def _group_topic_name(group):
|
|
74
|
+
if not _is_safe(group):
|
|
75
|
+
raise InvalidGroupNameError(f"Consumer group names must be [a-zA-Z0-9_.], {group} is not valid")
|
|
76
|
+
return f"--group--{group}"
|
|
77
|
+
|
|
78
|
+
class RebalInProgress:
|
|
79
|
+
def __init__(self, rebal_ending):
|
|
80
|
+
self.rebal_ending = rebal_ending
|
|
81
|
+
self._offers = dict()
|
|
82
|
+
|
|
83
|
+
def add_offer(self, cons_id: int, rebal_offer: RebalOffer):
|
|
84
|
+
self._offers[cons_id] = rebal_offer
|
|
85
|
+
|
|
86
|
+
def get_assignments(self):
|
|
87
|
+
# --- Step 0: Clean ownership lists ---
|
|
88
|
+
cleaned = []
|
|
89
|
+
for cid, offer in self._offers.items():
|
|
90
|
+
allowed = set(offer.partitions)
|
|
91
|
+
owned = [p for p in offer.own if p in allowed]
|
|
92
|
+
cleaned.append((cid, allowed, owned))
|
|
93
|
+
|
|
94
|
+
# --- Step 1: Sort each consumer's partition list ---
|
|
95
|
+
# Owned first, then by partition ID
|
|
96
|
+
consumer_lists = {}
|
|
97
|
+
for cid, allowed, owned in cleaned:
|
|
98
|
+
owned_sorted = sorted(owned)
|
|
99
|
+
new_sorted = sorted(p for p in allowed if p not in owned)
|
|
100
|
+
consumer_lists[cid] = owned_sorted + new_sorted
|
|
101
|
+
|
|
102
|
+
# --- Step 2: Sort consumers ---
|
|
103
|
+
# a) number of partitions offered (ascending)
|
|
104
|
+
# b) number of partitions owned (descending)
|
|
105
|
+
# c) consumer id (ascending)
|
|
106
|
+
sorted_consumers = sorted(
|
|
107
|
+
cleaned,
|
|
108
|
+
key=lambda x: (len(x[1]), -len(x[2]), x[0])
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# --- Step 3: Round-robin assignment ---
|
|
112
|
+
assigned = {cid: [] for cid, _, _ in cleaned}
|
|
113
|
+
remaining_lists = {cid: list(consumer_lists[cid]) for cid, _, _ in cleaned}
|
|
114
|
+
|
|
115
|
+
# Set of all partitions that appear anywhere
|
|
116
|
+
all_partitions = set()
|
|
117
|
+
for _, allowed, _ in cleaned:
|
|
118
|
+
all_partitions.update(allowed)
|
|
119
|
+
|
|
120
|
+
assigned_partitions = set()
|
|
121
|
+
|
|
122
|
+
# Continue until all partitions assigned
|
|
123
|
+
while len(assigned_partitions) < len(all_partitions):
|
|
124
|
+
for cid, allowed, owned in sorted_consumers:
|
|
125
|
+
lst = remaining_lists[cid]
|
|
126
|
+
|
|
127
|
+
# Skip already-taken partitions
|
|
128
|
+
while lst and lst[0] in assigned_partitions:
|
|
129
|
+
lst.pop(0)
|
|
130
|
+
|
|
131
|
+
if not lst:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Assign the next partition
|
|
135
|
+
p = lst.pop(0)
|
|
136
|
+
assigned[cid].append(p)
|
|
137
|
+
assigned_partitions.add(p)
|
|
138
|
+
|
|
139
|
+
# Sort results for determinism
|
|
140
|
+
for cid in assigned:
|
|
141
|
+
assigned[cid].sort()
|
|
142
|
+
|
|
143
|
+
return assigned
|