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 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}"
@@ -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