sentry-arroyo 2.19.5__tar.gz → 2.19.7__tar.gz
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.
- {sentry_arroyo-2.19.5/sentry_arroyo.egg-info → sentry_arroyo-2.19.7}/PKG-INFO +1 -1
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/kafka/commit.py +1 -1
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/kafka/consumer.py +16 -6
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/dlq.py +14 -11
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/processor.py +16 -6
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/clock.py +1 -1
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7/sentry_arroyo.egg-info}/PKG-INFO +1 -1
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/setup.py +1 -1
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/backends/mixins.py +84 -15
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/backends/test_kafka.py +37 -6
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/backends/test_local.py +2 -2
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_produce.py +2 -2
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/test_processor.py +13 -3
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/test_dlq.py +2 -2
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/utils/test_retries.py +6 -6
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/LICENSE +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/MANIFEST.in +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/README.md +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/abstract.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/kafka/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/kafka/configuration.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/local/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/local/backend.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/local/storages/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/local/storages/abstract.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/backends/local/storages/memory.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/commit.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/errors.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/abstract.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/batching.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/buffer.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/commit.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/filter.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/guard.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/healthcheck.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/noop.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/produce.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/reduce.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/run_task.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/run_task_in_threads.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/run_task_with_multiprocessing.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/unfold.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/py.typed +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/types.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/codecs.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/concurrent.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/logging.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/metricDefs.json +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/metric_defs.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/metrics.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/profiler.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/utils/retries.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/examples/transform_and_produce/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/examples/transform_and_produce/batched.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/examples/transform_and_produce/script.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/examples/transform_and_produce/simple.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/requirements.txt +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/sentry_arroyo.egg-info/SOURCES.txt +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/sentry_arroyo.egg-info/dependency_links.txt +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/sentry_arroyo.egg-info/not-zip-safe +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/sentry_arroyo.egg-info/requires.txt +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/sentry_arroyo.egg-info/top_level.txt +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/setup.cfg +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/backends/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/backends/test_commit.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_all.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_batching.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_buffer.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_commit.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_filter.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_guard.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_noop.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_reduce.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_run_task.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_run_task_in_threads.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_run_task_with_multiprocessing.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/processing/strategies/test_unfold.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/test_commit.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/test_types.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/utils/__init__.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/utils/test_concurrent.py +0 -0
- {sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/tests/utils/test_metrics.py +0 -0
|
@@ -97,6 +97,6 @@ class CommitCodec(Codec[KafkaPayload, Commit]):
|
|
|
97
97
|
|
|
98
98
|
if max_times_to_log_legacy_message > 0:
|
|
99
99
|
max_times_to_log_legacy_message -= 1
|
|
100
|
-
logger.
|
|
100
|
+
logger.warning("Legacy commit message found: %s", commit)
|
|
101
101
|
|
|
102
102
|
return commit
|
|
@@ -161,6 +161,10 @@ class KafkaConsumer(Consumer[KafkaPayload]):
|
|
|
161
161
|
)
|
|
162
162
|
|
|
163
163
|
configuration = dict(configuration)
|
|
164
|
+
self.__is_incremental = (
|
|
165
|
+
configuration.get("partition.assignment.strategy") == "cooperative-sticky"
|
|
166
|
+
or configuration.get("group.protocol") == "consumer"
|
|
167
|
+
)
|
|
164
168
|
auto_offset_reset = configuration.get("auto.offset.reset", "largest")
|
|
165
169
|
|
|
166
170
|
# This is a special flag that controls the auto offset behavior for
|
|
@@ -269,6 +273,10 @@ class KafkaConsumer(Consumer[KafkaPayload]):
|
|
|
269
273
|
def assignment_callback(
|
|
270
274
|
consumer: ConfluentConsumer, partitions: Sequence[ConfluentTopicPartition]
|
|
271
275
|
) -> None:
|
|
276
|
+
if not partitions:
|
|
277
|
+
logger.info("skipping empty assignment")
|
|
278
|
+
return
|
|
279
|
+
|
|
272
280
|
self.__state = KafkaConsumerState.ASSIGNING
|
|
273
281
|
|
|
274
282
|
try:
|
|
@@ -451,12 +459,14 @@ class KafkaConsumer(Consumer[KafkaPayload]):
|
|
|
451
459
|
|
|
452
460
|
def __assign(self, offsets: Mapping[Partition, int]) -> None:
|
|
453
461
|
self.__validate_offsets(offsets)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
462
|
+
partitions = [
|
|
463
|
+
ConfluentTopicPartition(partition.topic.name, partition.index, offset)
|
|
464
|
+
for partition, offset in offsets.items()
|
|
465
|
+
]
|
|
466
|
+
if self.__is_incremental:
|
|
467
|
+
self.__consumer.incremental_assign(partitions)
|
|
468
|
+
else:
|
|
469
|
+
self.__consumer.assign(partitions)
|
|
460
470
|
self.__offsets.update(offsets)
|
|
461
471
|
|
|
462
472
|
def seek(self, offsets: Mapping[Partition, int]) -> None:
|
|
@@ -44,11 +44,12 @@ class InvalidMessage(Exception):
|
|
|
44
44
|
"""
|
|
45
45
|
|
|
46
46
|
def __init__(
|
|
47
|
-
self, partition: Partition, offset: int, needs_commit: bool = True
|
|
47
|
+
self, partition: Partition, offset: int, needs_commit: bool = True, reason: Optional[str] = None,
|
|
48
48
|
) -> None:
|
|
49
49
|
self.partition = partition
|
|
50
50
|
self.offset = offset
|
|
51
51
|
self.needs_commit = needs_commit
|
|
52
|
+
self.reason = reason
|
|
52
53
|
|
|
53
54
|
@classmethod
|
|
54
55
|
def from_value(cls, value: BrokerValue[Any]) -> InvalidMessage:
|
|
@@ -177,7 +178,7 @@ class DlqLimitState:
|
|
|
177
178
|
class DlqProducer(ABC, Generic[TStrategyPayload]):
|
|
178
179
|
@abstractmethod
|
|
179
180
|
def produce(
|
|
180
|
-
self, value: BrokerValue[TStrategyPayload]
|
|
181
|
+
self, value: BrokerValue[TStrategyPayload], reason: Optional[str] = None
|
|
181
182
|
) -> Future[BrokerValue[TStrategyPayload]]:
|
|
182
183
|
"""
|
|
183
184
|
Produce a message to DLQ.
|
|
@@ -201,7 +202,7 @@ class NoopDlqProducer(DlqProducer[Any]):
|
|
|
201
202
|
"""
|
|
202
203
|
|
|
203
204
|
def produce(
|
|
204
|
-
self, value: BrokerValue[KafkaPayload]
|
|
205
|
+
self, value: BrokerValue[KafkaPayload], reason: Optional[str] = None,
|
|
205
206
|
) -> Future[BrokerValue[KafkaPayload]]:
|
|
206
207
|
future: Future[BrokerValue[KafkaPayload]] = Future()
|
|
207
208
|
future.set_running_or_notify_cancel()
|
|
@@ -229,7 +230,9 @@ class KafkaDlqProducer(DlqProducer[KafkaPayload]):
|
|
|
229
230
|
self.__topic = topic
|
|
230
231
|
|
|
231
232
|
def produce(
|
|
232
|
-
self,
|
|
233
|
+
self,
|
|
234
|
+
value: BrokerValue[KafkaPayload],
|
|
235
|
+
reason: Optional[str] = None,
|
|
233
236
|
) -> Future[BrokerValue[KafkaPayload]]:
|
|
234
237
|
value.payload.headers.append(
|
|
235
238
|
("original_partition", f"{value.partition.index}".encode("utf-8"))
|
|
@@ -315,11 +318,11 @@ class BufferedMessages(Generic[TStrategyPayload]):
|
|
|
315
318
|
|
|
316
319
|
return None
|
|
317
320
|
|
|
318
|
-
def
|
|
321
|
+
def remove(self, partition: Partition) -> None:
|
|
319
322
|
"""
|
|
320
|
-
|
|
323
|
+
Remove a revoked partition from the buffer.
|
|
321
324
|
"""
|
|
322
|
-
self.__buffered_messages
|
|
325
|
+
self.__buffered_messages.pop(partition, None)
|
|
323
326
|
|
|
324
327
|
|
|
325
328
|
class DlqPolicyWrapper(Generic[TStrategyPayload]):
|
|
@@ -343,9 +346,9 @@ class DlqPolicyWrapper(Generic[TStrategyPayload]):
|
|
|
343
346
|
]
|
|
344
347
|
],
|
|
345
348
|
] = defaultdict(deque)
|
|
346
|
-
self.
|
|
349
|
+
self.reset_dlq_limits({})
|
|
347
350
|
|
|
348
|
-
def
|
|
351
|
+
def reset_dlq_limits(self, assignment: Mapping[Partition, int]) -> None:
|
|
349
352
|
"""
|
|
350
353
|
Called on consumer assignment
|
|
351
354
|
"""
|
|
@@ -353,7 +356,7 @@ class DlqPolicyWrapper(Generic[TStrategyPayload]):
|
|
|
353
356
|
self.__dlq_policy.limit, assignment
|
|
354
357
|
)
|
|
355
358
|
|
|
356
|
-
def produce(self, message: BrokerValue[TStrategyPayload]) -> None:
|
|
359
|
+
def produce(self, message: BrokerValue[TStrategyPayload], reason: Optional[str] = None) -> None:
|
|
357
360
|
"""
|
|
358
361
|
Removes all completed futures, then appends the given future to the list.
|
|
359
362
|
Blocks if the list is full. If the DLQ limit is exceeded, an exception is raised.
|
|
@@ -371,7 +374,7 @@ class DlqPolicyWrapper(Generic[TStrategyPayload]):
|
|
|
371
374
|
|
|
372
375
|
should_accept = self.__dlq_limit_state.record_invalid_message(message)
|
|
373
376
|
if should_accept:
|
|
374
|
-
future = self.__dlq_policy.producer.produce(message)
|
|
377
|
+
future = self.__dlq_policy.producer.produce(message, reason)
|
|
375
378
|
self.__futures[message.partition].append((message, future))
|
|
376
379
|
else:
|
|
377
380
|
raise RuntimeError("Dlq limit exceeded")
|
|
@@ -238,16 +238,23 @@ class StreamProcessor(Generic[TStrategyPayload]):
|
|
|
238
238
|
"arroyo.consumer.partitions_assigned.count", len(partitions)
|
|
239
239
|
)
|
|
240
240
|
|
|
241
|
-
self.
|
|
241
|
+
current_partitions = dict(self.__consumer.tell())
|
|
242
|
+
current_partitions.update(partitions)
|
|
243
|
+
|
|
242
244
|
if self.__dlq_policy:
|
|
243
|
-
self.__dlq_policy.
|
|
244
|
-
if
|
|
245
|
+
self.__dlq_policy.reset_dlq_limits(current_partitions)
|
|
246
|
+
if current_partitions:
|
|
245
247
|
if self.__processing_strategy is not None:
|
|
246
|
-
|
|
248
|
+
# TODO: for cooperative-sticky rebalancing this can happen
|
|
249
|
+
# quite often. we should port the changes to
|
|
250
|
+
# ProcessingStrategyFactory that we made in Rust: Remove
|
|
251
|
+
# create_with_partitions, replace with create +
|
|
252
|
+
# update_partitions
|
|
253
|
+
logger.warning(
|
|
247
254
|
"Partition assignment while processing strategy active"
|
|
248
255
|
)
|
|
249
256
|
_close_strategy()
|
|
250
|
-
_create_strategy(
|
|
257
|
+
_create_strategy(current_partitions)
|
|
251
258
|
|
|
252
259
|
@_rdkafka_callback(metrics=self.__metrics_buffer)
|
|
253
260
|
def on_partitions_revoked(partitions: Sequence[Partition]) -> None:
|
|
@@ -278,6 +285,9 @@ class StreamProcessor(Generic[TStrategyPayload]):
|
|
|
278
285
|
except RuntimeError:
|
|
279
286
|
pass
|
|
280
287
|
|
|
288
|
+
for partition in partitions:
|
|
289
|
+
self.__buffered_messages.remove(partition)
|
|
290
|
+
|
|
281
291
|
# Partition revocation can happen anytime during the consumer lifecycle and happen
|
|
282
292
|
# multiple times. What we want to know is that the consumer is not stuck somewhere.
|
|
283
293
|
# The presence of this message as the last message of a consumer
|
|
@@ -366,7 +376,7 @@ class StreamProcessor(Generic[TStrategyPayload]):
|
|
|
366
376
|
) from None
|
|
367
377
|
|
|
368
378
|
# XXX: This blocks if there are more than MAX_PENDING_FUTURES in the queue.
|
|
369
|
-
self.__dlq_policy.produce(invalid_message)
|
|
379
|
+
self.__dlq_policy.produce(invalid_message, exc.reason)
|
|
370
380
|
|
|
371
381
|
self.__metrics_buffer.incr_timing(
|
|
372
382
|
"arroyo.consumer.dlq.time", time.time() - start_dlq
|
|
@@ -2,7 +2,7 @@ import time
|
|
|
2
2
|
import uuid
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
4
|
from contextlib import closing
|
|
5
|
-
from typing import ContextManager, Generic, Iterator, Mapping, Optional, Sequence
|
|
5
|
+
from typing import Any, ContextManager, Generic, Iterator, Mapping, Optional, Sequence
|
|
6
6
|
from unittest import mock
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
@@ -14,6 +14,8 @@ from tests.assertions import assert_changes, assert_does_not_change
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class StreamsTestMixin(ABC, Generic[TStrategyPayload]):
|
|
17
|
+
cooperative_sticky = False
|
|
18
|
+
|
|
17
19
|
@abstractmethod
|
|
18
20
|
def get_topic(self, partitions: int = 1) -> ContextManager[Topic]:
|
|
19
21
|
raise NotImplementedError
|
|
@@ -397,6 +399,11 @@ class StreamsTestMixin(ABC, Generic[TStrategyPayload]):
|
|
|
397
399
|
def test_pause_resume_rebalancing(self) -> None:
|
|
398
400
|
payloads = self.get_payloads()
|
|
399
401
|
|
|
402
|
+
consumer_a_on_assign = mock.Mock()
|
|
403
|
+
consumer_a_on_revoke = mock.Mock()
|
|
404
|
+
consumer_b_on_assign = mock.Mock()
|
|
405
|
+
consumer_b_on_revoke = mock.Mock()
|
|
406
|
+
|
|
400
407
|
with self.get_topic(2) as topic, closing(
|
|
401
408
|
self.get_producer()
|
|
402
409
|
) as producer, closing(
|
|
@@ -408,10 +415,22 @@ class StreamsTestMixin(ABC, Generic[TStrategyPayload]):
|
|
|
408
415
|
producer.produce(Partition(topic, i), next(payloads)).result(
|
|
409
416
|
timeout=5.0
|
|
410
417
|
)
|
|
411
|
-
for i in
|
|
418
|
+
for i in [0, 1]
|
|
412
419
|
]
|
|
413
420
|
|
|
414
|
-
|
|
421
|
+
def wait_until_rebalancing(
|
|
422
|
+
from_consumer: Consumer[Any], to_consumer: Consumer[Any]
|
|
423
|
+
) -> None:
|
|
424
|
+
for _ in range(10):
|
|
425
|
+
assert from_consumer.poll(0) is None
|
|
426
|
+
if to_consumer.poll(1.0) is not None:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
raise RuntimeError("no rebalancing happened")
|
|
430
|
+
|
|
431
|
+
consumer_a.subscribe(
|
|
432
|
+
[topic], on_assign=consumer_a_on_assign, on_revoke=consumer_a_on_revoke
|
|
433
|
+
)
|
|
415
434
|
|
|
416
435
|
# It doesn't really matter which message is fetched first -- we
|
|
417
436
|
# just want to know the assignment occurred.
|
|
@@ -428,19 +447,69 @@ class StreamsTestMixin(ABC, Generic[TStrategyPayload]):
|
|
|
428
447
|
[Partition(topic, 0), Partition(topic, 1)]
|
|
429
448
|
)
|
|
430
449
|
|
|
431
|
-
consumer_b.subscribe(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
else:
|
|
437
|
-
assert False, "rebalance did not occur"
|
|
450
|
+
consumer_b.subscribe(
|
|
451
|
+
[topic], on_assign=consumer_b_on_assign, on_revoke=consumer_b_on_revoke
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
wait_until_rebalancing(consumer_a, consumer_b)
|
|
438
455
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
456
|
+
if self.cooperative_sticky:
|
|
457
|
+
# within incremental rebalancing, only one partition should have been reassigned to the consumer_b, and consumer_a should remain paused
|
|
458
|
+
assert consumer_a.paused() == [Partition(topic, 1)]
|
|
459
|
+
assert consumer_a.poll(10.0) is None
|
|
460
|
+
else:
|
|
461
|
+
# The first consumer should have had its offsets rolled back, as
|
|
462
|
+
# well as should have had it's partition resumed during
|
|
463
|
+
# rebalancing.
|
|
464
|
+
assert consumer_a.paused() == []
|
|
465
|
+
assert consumer_a.poll(10.0) is not None
|
|
444
466
|
|
|
445
467
|
assert len(consumer_a.tell()) == 1
|
|
446
468
|
assert len(consumer_b.tell()) == 1
|
|
469
|
+
|
|
470
|
+
(consumer_a_partition,) = consumer_a.tell()
|
|
471
|
+
(consumer_b_partition,) = consumer_b.tell()
|
|
472
|
+
|
|
473
|
+
# Pause consumer_a again.
|
|
474
|
+
consumer_a.pause(list(consumer_a.tell()))
|
|
475
|
+
# if we close consumer_a, consumer_b should get all partitions
|
|
476
|
+
producer.produce(next(iter(consumer_a.tell())), next(payloads)).result(
|
|
477
|
+
timeout=5.0
|
|
478
|
+
)
|
|
479
|
+
consumer_a.unsubscribe()
|
|
480
|
+
wait_until_rebalancing(consumer_a, consumer_b)
|
|
481
|
+
|
|
482
|
+
assert len(consumer_b.tell()) == 2
|
|
483
|
+
|
|
484
|
+
if self.cooperative_sticky:
|
|
485
|
+
|
|
486
|
+
assert consumer_a_on_assign.mock_calls == [
|
|
487
|
+
mock.call({Partition(topic, 0): 0, Partition(topic, 1): 0}),
|
|
488
|
+
]
|
|
489
|
+
assert consumer_a_on_revoke.mock_calls == [
|
|
490
|
+
mock.call([Partition(topic, 0)]),
|
|
491
|
+
mock.call([Partition(topic, 1)]),
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
assert consumer_b_on_assign.mock_calls == [
|
|
495
|
+
mock.call({Partition(topic, 0): 0}),
|
|
496
|
+
mock.call({Partition(topic, 1): 0}),
|
|
497
|
+
]
|
|
498
|
+
assert consumer_b_on_revoke.mock_calls == []
|
|
499
|
+
else:
|
|
500
|
+
assert consumer_a_on_assign.mock_calls == [
|
|
501
|
+
mock.call({Partition(topic, 0): 0, Partition(topic, 1): 0}),
|
|
502
|
+
mock.call({consumer_a_partition: 0}),
|
|
503
|
+
]
|
|
504
|
+
assert consumer_a_on_revoke.mock_calls == [
|
|
505
|
+
mock.call([Partition(topic, 0), Partition(topic, 1)]),
|
|
506
|
+
mock.call([consumer_a_partition]),
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
assert consumer_b_on_assign.mock_calls == [
|
|
510
|
+
mock.call({consumer_b_partition: 0}),
|
|
511
|
+
mock.call({Partition(topic, 0): 0, Partition(topic, 1): 0}),
|
|
512
|
+
]
|
|
513
|
+
assert consumer_b_on_revoke.mock_calls == [
|
|
514
|
+
mock.call([consumer_b_partition])
|
|
515
|
+
]
|
|
@@ -14,7 +14,10 @@ from confluent_kafka.admin import AdminClient, NewTopic
|
|
|
14
14
|
|
|
15
15
|
from arroyo.backends.kafka import KafkaConsumer, KafkaPayload, KafkaProducer
|
|
16
16
|
from arroyo.backends.kafka.commit import CommitCodec
|
|
17
|
-
from arroyo.backends.kafka.configuration import
|
|
17
|
+
from arroyo.backends.kafka.configuration import (
|
|
18
|
+
KafkaBrokerConfig,
|
|
19
|
+
build_kafka_configuration,
|
|
20
|
+
)
|
|
18
21
|
from arroyo.backends.kafka.consumer import as_kafka_configuration_bool
|
|
19
22
|
from arroyo.commit import IMMEDIATE, Commit
|
|
20
23
|
from arroyo.errors import ConsumerError, EndOfPartition
|
|
@@ -56,6 +59,7 @@ def get_topic(
|
|
|
56
59
|
configuration: Mapping[str, Any], partitions_count: int
|
|
57
60
|
) -> Iterator[Topic]:
|
|
58
61
|
name = f"test-{uuid.uuid1().hex}"
|
|
62
|
+
configuration = dict(configuration)
|
|
59
63
|
client = AdminClient(configuration)
|
|
60
64
|
[[key, future]] = client.create_topics(
|
|
61
65
|
[NewTopic(name, num_partitions=partitions_count, replication_factor=1)]
|
|
@@ -71,10 +75,15 @@ def get_topic(
|
|
|
71
75
|
|
|
72
76
|
|
|
73
77
|
class TestKafkaStreams(StreamsTestMixin[KafkaPayload]):
|
|
78
|
+
kip_848 = False
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
@property
|
|
81
|
+
def configuration(self) -> KafkaBrokerConfig:
|
|
82
|
+
config = {
|
|
83
|
+
"bootstrap.servers": os.environ.get("DEFAULT_BROKERS", "localhost:9092"),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return build_kafka_configuration(config)
|
|
78
87
|
|
|
79
88
|
@contextlib.contextmanager
|
|
80
89
|
def get_topic(self, partitions: int = 1) -> Iterator[Topic]:
|
|
@@ -90,7 +99,7 @@ class TestKafkaStreams(StreamsTestMixin[KafkaPayload]):
|
|
|
90
99
|
enable_end_of_partition: bool = True,
|
|
91
100
|
auto_offset_reset: str = "earliest",
|
|
92
101
|
strict_offset_reset: Optional[bool] = None,
|
|
93
|
-
max_poll_interval_ms: Optional[int] = None
|
|
102
|
+
max_poll_interval_ms: Optional[int] = None,
|
|
94
103
|
) -> KafkaConsumer:
|
|
95
104
|
configuration = {
|
|
96
105
|
**self.configuration,
|
|
@@ -110,6 +119,16 @@ class TestKafkaStreams(StreamsTestMixin[KafkaPayload]):
|
|
|
110
119
|
if max_poll_interval_ms < 45000:
|
|
111
120
|
configuration["session.timeout.ms"] = max_poll_interval_ms
|
|
112
121
|
|
|
122
|
+
if self.cooperative_sticky:
|
|
123
|
+
configuration["partition.assignment.strategy"] = "cooperative-sticky"
|
|
124
|
+
|
|
125
|
+
if self.kip_848:
|
|
126
|
+
configuration["group.protocol"] = "consumer"
|
|
127
|
+
configuration.pop("session.timeout.ms")
|
|
128
|
+
configuration.pop("max.poll.interval.ms", None)
|
|
129
|
+
assert "group.protocol.type" not in configuration
|
|
130
|
+
assert "heartbeat.interval.ms" not in configuration
|
|
131
|
+
|
|
113
132
|
return KafkaConsumer(configuration)
|
|
114
133
|
|
|
115
134
|
def get_producer(self) -> KafkaProducer:
|
|
@@ -210,7 +229,9 @@ class TestKafkaStreams(StreamsTestMixin[KafkaPayload]):
|
|
|
210
229
|
poll_interval = 6000
|
|
211
230
|
|
|
212
231
|
with self.get_topic() as topic:
|
|
213
|
-
with closing(self.get_producer()) as producer, closing(
|
|
232
|
+
with closing(self.get_producer()) as producer, closing(
|
|
233
|
+
self.get_consumer(max_poll_interval_ms=poll_interval)
|
|
234
|
+
) as consumer:
|
|
214
235
|
producer.produce(topic, next(self.get_payloads())).result(5.0)
|
|
215
236
|
|
|
216
237
|
processor = StreamProcessor(consumer, topic, factory, IMMEDIATE)
|
|
@@ -245,6 +266,16 @@ class TestKafkaStreams(StreamsTestMixin[KafkaPayload]):
|
|
|
245
266
|
assert consumer.paused() == []
|
|
246
267
|
|
|
247
268
|
|
|
269
|
+
class TestKafkaStreamsIncrementalRebalancing(TestKafkaStreams):
|
|
270
|
+
# re-test the kafka consumer with cooperative-sticky rebalancing
|
|
271
|
+
cooperative_sticky = True
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@pytest.mark.skip("kip-848 not functional yet")
|
|
275
|
+
class TestKafkaStreamsKip848(TestKafkaStreams):
|
|
276
|
+
kip_848 = True
|
|
277
|
+
|
|
278
|
+
|
|
248
279
|
def test_commit_codec() -> None:
|
|
249
280
|
commit = Commit(
|
|
250
281
|
"group", Partition(Topic("topic"), 0), 0, time.time(), time.time() - 5
|
|
@@ -18,14 +18,14 @@ from arroyo.backends.local.storages.abstract import (
|
|
|
18
18
|
)
|
|
19
19
|
from arroyo.backends.local.storages.memory import MemoryMessageStorage
|
|
20
20
|
from arroyo.types import Partition, Topic
|
|
21
|
-
from arroyo.utils.clock import
|
|
21
|
+
from arroyo.utils.clock import MockedClock
|
|
22
22
|
from tests.backends.mixins import StreamsTestMixin
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class LocalStreamsTestMixin(StreamsTestMixin[int]):
|
|
26
26
|
def setUp(self) -> None:
|
|
27
27
|
self.storage = self.get_message_storage()
|
|
28
|
-
self.broker = LocalBroker(self.storage,
|
|
28
|
+
self.broker = LocalBroker(self.storage, MockedClock())
|
|
29
29
|
|
|
30
30
|
@abstractmethod
|
|
31
31
|
def get_message_storage(self) -> MessageStorage[int]:
|
|
@@ -9,13 +9,13 @@ from arroyo.backends.local.storages.memory import MemoryMessageStorage
|
|
|
9
9
|
from arroyo.processing.strategies.abstract import MessageRejected
|
|
10
10
|
from arroyo.processing.strategies.produce import Produce
|
|
11
11
|
from arroyo.types import Message, Partition, Topic, Value
|
|
12
|
-
from arroyo.utils.clock import
|
|
12
|
+
from arroyo.utils.clock import MockedClock
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def test_produce() -> None:
|
|
16
16
|
orig_topic = Topic("orig-topic")
|
|
17
17
|
result_topic = Topic("result-topic")
|
|
18
|
-
clock =
|
|
18
|
+
clock = MockedClock()
|
|
19
19
|
broker_storage: MemoryMessageStorage[KafkaPayload] = MemoryMessageStorage()
|
|
20
20
|
broker: LocalBroker[KafkaPayload] = LocalBroker(broker_storage, clock)
|
|
21
21
|
broker.create_topic(result_topic, partitions=1)
|
|
@@ -19,7 +19,7 @@ from arroyo.processing.strategies.abstract import (
|
|
|
19
19
|
ProcessingStrategyFactory,
|
|
20
20
|
)
|
|
21
21
|
from arroyo.types import BrokerValue, Commit, Message, Partition, Topic
|
|
22
|
-
from arroyo.utils.clock import
|
|
22
|
+
from arroyo.utils.clock import MockedClock
|
|
23
23
|
from tests.assertions import assert_changes, assert_does_not_change
|
|
24
24
|
from tests.metrics import Increment, TestingMetricsBackend, Timing
|
|
25
25
|
|
|
@@ -41,6 +41,7 @@ def test_stream_processor_lifecycle() -> None:
|
|
|
41
41
|
|
|
42
42
|
# The processor should accept heartbeat messages without an assignment or
|
|
43
43
|
# active processor.
|
|
44
|
+
consumer.tell.return_value = {}
|
|
44
45
|
consumer.poll.return_value = None
|
|
45
46
|
processor._run_once()
|
|
46
47
|
|
|
@@ -166,6 +167,7 @@ def test_stream_processor_termination_on_error() -> None:
|
|
|
166
167
|
offset = 0
|
|
167
168
|
now = datetime.now()
|
|
168
169
|
|
|
170
|
+
consumer.tell.return_value = {}
|
|
169
171
|
consumer.poll.return_value = BrokerValue(0, partition, offset, now)
|
|
170
172
|
|
|
171
173
|
exception = NotImplementedError("error")
|
|
@@ -199,6 +201,7 @@ def test_stream_processor_invalid_message_from_poll() -> None:
|
|
|
199
201
|
offset = 1
|
|
200
202
|
now = datetime.now()
|
|
201
203
|
|
|
204
|
+
consumer.tell.return_value = {}
|
|
202
205
|
consumer.poll.side_effect = [BrokerValue(0, partition, offset, now)]
|
|
203
206
|
|
|
204
207
|
strategy = mock.Mock()
|
|
@@ -236,6 +239,7 @@ def test_stream_processor_invalid_message_from_submit() -> None:
|
|
|
236
239
|
offset = 1
|
|
237
240
|
now = datetime.now()
|
|
238
241
|
|
|
242
|
+
consumer.tell.return_value = {}
|
|
239
243
|
consumer.poll.side_effect = [
|
|
240
244
|
BrokerValue(0, partition, offset, now),
|
|
241
245
|
BrokerValue(1, partition, offset + 1, now),
|
|
@@ -283,6 +287,7 @@ def test_stream_processor_create_with_partitions() -> None:
|
|
|
283
287
|
topic = Topic("topic")
|
|
284
288
|
|
|
285
289
|
consumer = mock.Mock()
|
|
290
|
+
consumer.tell.return_value = {}
|
|
286
291
|
strategy = mock.Mock()
|
|
287
292
|
factory = mock.Mock()
|
|
288
293
|
factory.create_with_partitions.return_value = strategy
|
|
@@ -306,13 +311,15 @@ def test_stream_processor_create_with_partitions() -> None:
|
|
|
306
311
|
assert factory.create_with_partitions.call_count == 1
|
|
307
312
|
assert create_args[1] == offsets_p0
|
|
308
313
|
|
|
314
|
+
consumer.tell.return_value = {**offsets_p0}
|
|
315
|
+
|
|
309
316
|
# Second partition assigned
|
|
310
317
|
offsets_p1 = {Partition(topic, 1): 0}
|
|
311
318
|
assignment_callback(offsets_p1)
|
|
312
319
|
|
|
313
320
|
create_args, _ = factory.create_with_partitions.call_args
|
|
314
321
|
assert factory.create_with_partitions.call_count == 2
|
|
315
|
-
assert create_args[1] == offsets_p1
|
|
322
|
+
assert create_args[1] == {**offsets_p1, **offsets_p0}
|
|
316
323
|
|
|
317
324
|
processor._run_once()
|
|
318
325
|
|
|
@@ -376,6 +383,7 @@ def run_commit_policy_test(
|
|
|
376
383
|
) -> Sequence[int]:
|
|
377
384
|
commit = mock.Mock()
|
|
378
385
|
consumer = mock.Mock()
|
|
386
|
+
consumer.tell.return_value = {}
|
|
379
387
|
consumer.commit_offsets = commit
|
|
380
388
|
|
|
381
389
|
factory = CommitOffsetsFactory()
|
|
@@ -523,7 +531,7 @@ def test_commit_policy_bench(
|
|
|
523
531
|
storage: MessageStorage[int] = MemoryMessageStorage()
|
|
524
532
|
storage.create_topic(topic, num_partitions)
|
|
525
533
|
|
|
526
|
-
broker = LocalBroker(storage,
|
|
534
|
+
broker = LocalBroker(storage, MockedClock())
|
|
527
535
|
|
|
528
536
|
consumer = broker.get_consumer("test-group", enable_end_of_partition=True)
|
|
529
537
|
|
|
@@ -551,6 +559,7 @@ def test_dlq() -> None:
|
|
|
551
559
|
partition = Partition(topic, 0)
|
|
552
560
|
consumer = mock.Mock()
|
|
553
561
|
consumer.poll.return_value = BrokerValue(0, partition, 1, datetime.now())
|
|
562
|
+
consumer.tell.return_value = {}
|
|
554
563
|
strategy = mock.Mock()
|
|
555
564
|
strategy.submit.side_effect = InvalidMessage(partition, 1)
|
|
556
565
|
factory = mock.Mock()
|
|
@@ -585,6 +594,7 @@ def test_healthcheck(tmpdir: py.path.local) -> None:
|
|
|
585
594
|
consumer = mock.Mock()
|
|
586
595
|
now = datetime.now()
|
|
587
596
|
consumer.poll.return_value = BrokerValue(0, partition, 1, now)
|
|
597
|
+
consumer.tell.return_value = {}
|
|
588
598
|
strategy = mock.Mock()
|
|
589
599
|
strategy.submit.side_effect = InvalidMessage(partition, 1)
|
|
590
600
|
factory = mock.Mock()
|
|
@@ -107,7 +107,7 @@ def test_dlq_policy_wrapper() -> None:
|
|
|
107
107
|
)
|
|
108
108
|
partition = Partition(topic, 0)
|
|
109
109
|
wrapper = DlqPolicyWrapper(dlq_policy)
|
|
110
|
-
wrapper.
|
|
110
|
+
wrapper.reset_dlq_limits({partition: 0})
|
|
111
111
|
wrapper.MAX_PENDING_FUTURES = 1
|
|
112
112
|
for i in range(10):
|
|
113
113
|
message = BrokerValue(KafkaPayload(None, b"", []), partition, i, datetime.now())
|
|
@@ -123,7 +123,7 @@ def test_dlq_policy_wrapper_limit_exceeded() -> None:
|
|
|
123
123
|
)
|
|
124
124
|
partition = Partition(topic, 0)
|
|
125
125
|
wrapper = DlqPolicyWrapper(dlq_policy)
|
|
126
|
-
wrapper.
|
|
126
|
+
wrapper.reset_dlq_limits({partition: 0})
|
|
127
127
|
wrapper.MAX_PENDING_FUTURES = 1
|
|
128
128
|
|
|
129
129
|
message = BrokerValue(KafkaPayload(None, b"", []), partition, 1, datetime.now())
|
|
@@ -3,7 +3,7 @@ from unittest import mock
|
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
|
|
6
|
-
from arroyo.utils.clock import
|
|
6
|
+
from arroyo.utils.clock import MockedClock
|
|
7
7
|
from arroyo.utils.retries import BasicRetryPolicy, RetryException, constant_delay
|
|
8
8
|
|
|
9
9
|
value = object()
|
|
@@ -45,7 +45,7 @@ def setup_function() -> None:
|
|
|
45
45
|
|
|
46
46
|
def test_basic_retry_policy_no_delay() -> None:
|
|
47
47
|
|
|
48
|
-
clock =
|
|
48
|
+
clock = MockedClock()
|
|
49
49
|
|
|
50
50
|
policy = BasicRetryPolicy(3, clock=clock)
|
|
51
51
|
|
|
@@ -68,19 +68,19 @@ def test_basic_retry_policy_no_delay() -> None:
|
|
|
68
68
|
|
|
69
69
|
@pytest.mark.parametrize("delay", [1, constant_delay(1)])
|
|
70
70
|
def test_basic_retry_policy_with_delay(delay: int) -> None:
|
|
71
|
-
clock =
|
|
71
|
+
clock = MockedClock()
|
|
72
72
|
policy = BasicRetryPolicy(3, delay, clock=clock)
|
|
73
73
|
assert policy.call(good_function) is value
|
|
74
74
|
assert good_function.call_count == 1
|
|
75
75
|
assert clock.time() == 0
|
|
76
76
|
|
|
77
|
-
clock =
|
|
77
|
+
clock = MockedClock()
|
|
78
78
|
policy = BasicRetryPolicy(3, delay, clock=clock)
|
|
79
79
|
assert policy.call(flaky_function) is value
|
|
80
80
|
assert flaky_function.call_count == 2
|
|
81
81
|
assert clock.time() == 1 # one retry
|
|
82
82
|
|
|
83
|
-
clock =
|
|
83
|
+
clock = MockedClock()
|
|
84
84
|
policy = BasicRetryPolicy(3, delay, clock=clock)
|
|
85
85
|
try:
|
|
86
86
|
policy.call(bad_function)
|
|
@@ -109,7 +109,7 @@ def test_basic_retry_policy_with_supression() -> None:
|
|
|
109
109
|
def suppression_test(exception: Exception) -> bool:
|
|
110
110
|
return isinstance(exception, ExpectedError)
|
|
111
111
|
|
|
112
|
-
clock =
|
|
112
|
+
clock = MockedClock()
|
|
113
113
|
policy = BasicRetryPolicy(
|
|
114
114
|
3, constant_delay(1), suppression_test=suppression_test, clock=clock
|
|
115
115
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sentry_arroyo-2.19.5 → sentry_arroyo-2.19.7}/arroyo/processing/strategies/run_task_in_threads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|