remoulade 7.0.0.dev0__tar.gz → 7.0.0.dev2__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.
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/PKG-INFO +1 -1
- remoulade-7.0.0.dev2/remoulade/api/main.py +29 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/__init__.py +20 -2
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/pgmq.py +164 -44
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/PKG-INFO +1 -1
- remoulade-7.0.0.dev0/remoulade/api/main.py +0 -175
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/CONTRIBUTING.md +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/CONTRIBUTORS.md +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/COPYING +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/COPYING.LESSER +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/MANIFEST.in +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/README.md +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/bin/remoulade-gevent +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/local_pgmq_broker.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/local_pgmq_consumer.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/pyproject.toml +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/__main__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/actor.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/api/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/api/apispec.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/api/scheduler.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/api/state.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/broker.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/local.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/rabbitmq.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/brokers/stub.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/backend.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/backends/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/backends/redis.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/backends/stub.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/errors.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cancel/middleware.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cli/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cli/remoulade_ls.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cli/remoulade_run.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/cli/remoulade_scheduler.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/collection_results.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/common.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/composition.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/backend.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/backends/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/backends/redis.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/backends/stub.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/errors.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/lease.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/concurrent/middleware.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/encoder.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/errors.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/generic.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/actor_arguments.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/backoff.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/queues.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/redis_client.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/helpers/reduce.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/logging.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/message.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/age_limit.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/catch_error.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/current_message.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/heartbeat.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/logging_metadata.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/max_memory.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/max_tasks.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/middleware.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/pipelines.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/prometheus.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/retries.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/shutdown.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/threading.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/time_limit.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/tracing.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/middleware/worker_thread_logging.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/py.typed +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backend.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backends/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backends/redis.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backends/stub.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/backends/utils.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/errors.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/rate_limits/middleware.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/result.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backend.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backends/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backends/local.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backends/redis.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/backends/stub.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/errors.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/results/middleware.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/scheduler/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/scheduler/scheduler.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backend.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backends/__init__.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backends/postgres.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backends/redis.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/backends/stub.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/errors.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/state/middleware.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/utils.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade/worker.py +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/SOURCES.txt +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/dependency_links.txt +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/entry_points.txt +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/requires.txt +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/remoulade.egg-info/top_level.txt +0 -0
- {remoulade-7.0.0.dev0 → remoulade-7.0.0.dev2}/setup.cfg +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""This file describe the API to get the state of messages"""
|
|
2
|
+
|
|
3
|
+
from flask import Flask
|
|
4
|
+
from marshmallow import ValidationError
|
|
5
|
+
from werkzeug.exceptions import HTTPException
|
|
6
|
+
|
|
7
|
+
from remoulade.errors import RemouladeError
|
|
8
|
+
|
|
9
|
+
from .apispec import add_swagger
|
|
10
|
+
|
|
11
|
+
app = Flask(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.errorhandler(RemouladeError)
|
|
15
|
+
def remoulade_exception(e):
|
|
16
|
+
return {"error": str(e)}, 500
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.errorhandler(HTTPException)
|
|
20
|
+
def http_exception(e):
|
|
21
|
+
return {"error": str(e)}, e.code
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.errorhandler(ValidationError)
|
|
25
|
+
def validation_error(e):
|
|
26
|
+
return {"error": e.normalized_messages()}, 400
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
add_swagger(app, {"main": []})
|
|
@@ -15,9 +15,27 @@
|
|
|
15
15
|
# You should have received a copy of the GNU Lesser General Public License
|
|
16
16
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
+
import warnings
|
|
19
|
+
|
|
18
20
|
from .local import LocalBroker
|
|
19
|
-
from .pgmq import PgmqBroker
|
|
20
|
-
from .rabbitmq import RabbitmqBroker
|
|
21
21
|
from .stub import StubBroker
|
|
22
22
|
|
|
23
23
|
__all__ = ["LocalBroker", "PgmqBroker", "RabbitmqBroker", "StubBroker"]
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from .pgmq import PgmqBroker
|
|
27
|
+
except ImportError: # pragma: no cover
|
|
28
|
+
warnings.warn(
|
|
29
|
+
"PgmqBroker is not available. Run `pip install remoulade[pgmq]` to add support for that broker.",
|
|
30
|
+
ImportWarning,
|
|
31
|
+
stacklevel=2,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from .rabbitmq import RabbitmqBroker
|
|
36
|
+
except ImportError: # pragma: no cover
|
|
37
|
+
warnings.warn(
|
|
38
|
+
"RabbitmqBroker is not available. Run `pip install remoulade[rabbitmq]` to add support for that broker.",
|
|
39
|
+
ImportWarning,
|
|
40
|
+
stacklevel=2,
|
|
41
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# This file is a part of Remoulade.
|
|
2
2
|
#
|
|
3
|
-
# Copyright (C) 2017,2018
|
|
3
|
+
# Copyright (C) 2017,2018 WIREMIND SAS <dev@wiremind.fr>
|
|
4
4
|
#
|
|
5
5
|
# Remoulade is free software; you can redistribute it and/or modify it
|
|
6
6
|
# under the terms of the GNU Lesser General Public License as published by
|
|
@@ -15,40 +15,31 @@
|
|
|
15
15
|
# You should have received a copy of the GNU Lesser General Public License
|
|
16
16
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
17
|
import json
|
|
18
|
+
import time
|
|
18
19
|
from collections import deque
|
|
19
20
|
from collections.abc import Iterable, Iterator
|
|
20
21
|
from contextlib import contextmanager
|
|
21
22
|
from datetime import UTC, datetime, timedelta
|
|
22
|
-
from threading import Event, Thread, local
|
|
23
|
+
from threading import Event, Lock, Thread, local
|
|
23
24
|
from typing import TYPE_CHECKING, Any
|
|
24
25
|
|
|
25
26
|
import psycopg
|
|
26
27
|
from pgmq import SQLAlchemyPGMQueue
|
|
28
|
+
from pgmq.messages import Message as PgmqQueueMessage
|
|
27
29
|
from psycopg import sql as psycopg_sql
|
|
28
|
-
from
|
|
29
|
-
from sqlalchemy import bindparam, create_engine, text
|
|
30
|
+
from sqlalchemy import Column, Integer, MetaData, Table, bindparam, create_engine, func, select, text
|
|
30
31
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
31
|
-
from sqlalchemy.engine import make_url
|
|
32
32
|
from sqlalchemy.orm import sessionmaker as sqlalchemy_sessionmaker
|
|
33
33
|
|
|
34
34
|
from ..broker import Broker, Consumer, MessageProxy
|
|
35
|
-
from ..errors import QueueNotFound, UnsupportedMessageEncoding
|
|
35
|
+
from ..errors import QueueJoinTimeout, QueueNotFound, UnsupportedMessageEncoding
|
|
36
36
|
from ..message import Message
|
|
37
37
|
|
|
38
38
|
if TYPE_CHECKING:
|
|
39
39
|
from ..middleware import Middleware
|
|
40
40
|
|
|
41
|
-
|
|
42
41
|
PgmqPayload = dict[str, Any]
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
class _PgmqQueueMessage(BaseModel):
|
|
46
|
-
model_config = ConfigDict(extra="ignore", from_attributes=True)
|
|
47
|
-
|
|
48
|
-
msg_id: int
|
|
49
|
-
message: PgmqPayload
|
|
50
|
-
|
|
51
|
-
|
|
52
43
|
DELAYED_SEND_SQL = text(
|
|
53
44
|
"""
|
|
54
45
|
SELECT *
|
|
@@ -75,8 +66,31 @@ class PgmqBroker(Broker):
|
|
|
75
66
|
archive_partition_interval: int | str = "1 day",
|
|
76
67
|
archive_retention_interval: int | str = "7 days",
|
|
77
68
|
listen_notify_enabled: bool = True,
|
|
69
|
+
visibility_timeout: int = 30,
|
|
70
|
+
heartbeat_interval: float = 10.0,
|
|
78
71
|
) -> None:
|
|
72
|
+
"""Initialize a PostgreSQL-backed broker using the PGMQ extension.
|
|
73
|
+
|
|
74
|
+
Parameters:
|
|
75
|
+
url(str): PostgreSQL URL in plain format (`postgresql://...`), used both by SQLAlchemy and psycopg.
|
|
76
|
+
middleware(list[Middleware] | None): Middleware stack applied to this broker.
|
|
77
|
+
group_transaction(bool): If True, wraps group and pipeline operations in a single transaction.
|
|
78
|
+
partition_archive_on_queue_init(bool): If True, creates partitioned queues when declaring queues.
|
|
79
|
+
archive_partition_interval(int | str): Partition interval passed to PGMQ when creating partitioned queues.
|
|
80
|
+
archive_retention_interval(int | str): Retention interval passed to PGMQ for archive partitions.
|
|
81
|
+
listen_notify_enabled(bool): If True, enables LISTEN/NOTIFY to wake consumers when new messages arrive.
|
|
82
|
+
visibility_timeout(int): Message visibility timeout in seconds after read; must be greater than 0.
|
|
83
|
+
heartbeat_interval(float): Heartbeat interval in seconds used to extend in-flight message visibility;
|
|
84
|
+
must be greater than 0 and lower than visibility_timeout.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If visibility_timeout or heartbeat_interval values are invalid.
|
|
88
|
+
"""
|
|
79
89
|
super().__init__(middleware=middleware)
|
|
90
|
+
if visibility_timeout <= 0:
|
|
91
|
+
raise ValueError("visibility_timeout must be greater than 0")
|
|
92
|
+
if heartbeat_interval <= 0 or heartbeat_interval >= visibility_timeout:
|
|
93
|
+
raise ValueError("heartbeat_interval must be greater than 0 and lower than visibility_timeout")
|
|
80
94
|
|
|
81
95
|
self.url = url
|
|
82
96
|
self.state = local()
|
|
@@ -85,12 +99,14 @@ class PgmqBroker(Broker):
|
|
|
85
99
|
self.archive_partition_interval = archive_partition_interval
|
|
86
100
|
self.archive_retention_interval = archive_retention_interval
|
|
87
101
|
self.listen_notify_enabled = listen_notify_enabled
|
|
102
|
+
self.visibility_timeout = visibility_timeout
|
|
103
|
+
self.heartbeat_interval = heartbeat_interval
|
|
88
104
|
|
|
89
105
|
self.engine = create_engine(self.url, pool_pre_ping=True)
|
|
90
106
|
self.sessionmaker = sqlalchemy_sessionmaker(bind=self.engine)
|
|
91
107
|
self.supports_native_delay = True
|
|
92
108
|
|
|
93
|
-
self.client = SQLAlchemyPGMQueue(engine=self.engine, init_extension=
|
|
109
|
+
self.client = SQLAlchemyPGMQueue(engine=self.engine, init_extension=True, vt=self.visibility_timeout)
|
|
94
110
|
|
|
95
111
|
@contextmanager
|
|
96
112
|
def tx(self) -> Iterator[None]:
|
|
@@ -107,10 +123,6 @@ class PgmqBroker(Broker):
|
|
|
107
123
|
def _current_connection(self) -> Any | None:
|
|
108
124
|
return getattr(self.state, "transaction_connection", None)
|
|
109
125
|
|
|
110
|
-
def _build_listener_conninfo(self) -> str:
|
|
111
|
-
# psycopg expects a plain PostgreSQL URL and rejects SQLAlchemy-style driver suffixes.
|
|
112
|
-
return make_url(self.url).set(drivername="postgresql").render_as_string(hide_password=False)
|
|
113
|
-
|
|
114
126
|
def _try_enable_notify(self, queue_name: str) -> None:
|
|
115
127
|
if not self.listen_notify_enabled:
|
|
116
128
|
return
|
|
@@ -208,26 +220,92 @@ class PgmqBroker(Broker):
|
|
|
208
220
|
for queue_name in self.queues:
|
|
209
221
|
self.flush(queue_name)
|
|
210
222
|
|
|
211
|
-
def
|
|
212
|
-
|
|
223
|
+
def _count_enqueued_messages(self, queue_name: str) -> int:
|
|
224
|
+
queue_table = Table(f"q_{queue_name}", MetaData(), Column("msg_id", Integer), schema="pgmq")
|
|
225
|
+
count_query = select(func.count()).select_from(queue_table)
|
|
226
|
+
|
|
227
|
+
with self.sessionmaker.begin() as session:
|
|
228
|
+
return session.execute(count_query).scalar_one()
|
|
229
|
+
|
|
230
|
+
def join(
|
|
231
|
+
self,
|
|
232
|
+
queue_name: str,
|
|
233
|
+
min_successes: int = 10,
|
|
234
|
+
idle_time: int = 100,
|
|
235
|
+
*,
|
|
236
|
+
timeout: int | None = None,
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Wait for all the messages on the given queue to be processed.
|
|
239
|
+
|
|
240
|
+
This method checks the full PGMQ queue table and therefore waits for
|
|
241
|
+
all states: visible messages, invisible in-flight messages and native
|
|
242
|
+
delayed messages.
|
|
243
|
+
|
|
244
|
+
Parameters:
|
|
245
|
+
queue_name(str): The queue to wait on.
|
|
246
|
+
min_successes(int): The minimum number of times the queue should be
|
|
247
|
+
observed as empty.
|
|
248
|
+
idle_time(int): The number of milliseconds to wait between checks.
|
|
249
|
+
timeout(Optional[int]): The max amount of time, in milliseconds, to
|
|
250
|
+
wait on this queue.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
QueueNotFound: If the given queue was never declared.
|
|
254
|
+
QueueJoinTimeout: When the timeout elapses.
|
|
255
|
+
"""
|
|
256
|
+
if queue_name not in self.queues:
|
|
257
|
+
raise QueueNotFound(queue_name)
|
|
258
|
+
|
|
259
|
+
deadline = time.monotonic() + timeout / 1000 if timeout is not None else None
|
|
260
|
+
successes = 0
|
|
261
|
+
|
|
262
|
+
while successes < min_successes:
|
|
263
|
+
if deadline and time.monotonic() >= deadline:
|
|
264
|
+
raise QueueJoinTimeout(queue_name)
|
|
265
|
+
|
|
266
|
+
total_messages = self._count_enqueued_messages(queue_name)
|
|
267
|
+
if total_messages == 0:
|
|
268
|
+
successes += 1
|
|
269
|
+
if successes >= min_successes:
|
|
270
|
+
return
|
|
271
|
+
else:
|
|
272
|
+
successes = 0
|
|
273
|
+
|
|
274
|
+
time.sleep(idle_time / 1000)
|
|
213
275
|
|
|
214
276
|
|
|
215
277
|
class _PgmqConsumer(Consumer):
|
|
216
278
|
def __init__(self, broker: PgmqBroker, *, queue_name: str, prefetch: int, timeout: int) -> None:
|
|
279
|
+
"""Initialize a consumer for a PGMQ queue.
|
|
280
|
+
|
|
281
|
+
Parameters:
|
|
282
|
+
broker(PgmqBroker): Broker instance that owns the queue and database client.
|
|
283
|
+
queue_name(str): Name of the declared queue to consume from.
|
|
284
|
+
prefetch(int): Maximum number of messages fetched per read call; values lower than 1 are coerced to 1.
|
|
285
|
+
timeout(int): Idle wait timeout in milliseconds when polling for messages; values lower than 0 are coerced to
|
|
286
|
+
0. A value of 0 performs non-blocking reads.
|
|
287
|
+
"""
|
|
217
288
|
self.broker = broker
|
|
218
289
|
self.client = broker.client
|
|
219
290
|
self.queue_name = queue_name
|
|
220
291
|
self.prefetch = max(prefetch, 1)
|
|
221
292
|
self.timeout = max(timeout, 0)
|
|
222
|
-
self.
|
|
293
|
+
self.visibility_timeout = broker.visibility_timeout
|
|
294
|
+
self.heartbeat_interval = broker.heartbeat_interval
|
|
295
|
+
self.messages: deque[PgmqQueueMessage] = deque()
|
|
223
296
|
self._notify_event = Event()
|
|
224
297
|
self._listener_stop = Event()
|
|
225
298
|
self._listener_thread: Thread | None = None
|
|
226
299
|
self._listener_connection: psycopg.Connection[Any] | None = None
|
|
227
300
|
self._listener_available = False
|
|
301
|
+
self._heartbeat_stop = Event()
|
|
302
|
+
self._heartbeat_thread: Thread | None = None
|
|
303
|
+
self._heartbeat_msg_ids_lock = Lock()
|
|
304
|
+
self._heartbeat_msg_ids: set[int] = set()
|
|
228
305
|
|
|
229
306
|
if self.broker.listen_notify_enabled:
|
|
230
307
|
self._start_listener()
|
|
308
|
+
self._start_heartbeat()
|
|
231
309
|
|
|
232
310
|
@property
|
|
233
311
|
def max_poll_seconds(self) -> int:
|
|
@@ -241,38 +319,66 @@ class _PgmqConsumer(Consumer):
|
|
|
241
319
|
def wait_timeout_seconds(self) -> float:
|
|
242
320
|
return self.timeout / 1000 if self.timeout > 0 else 0
|
|
243
321
|
|
|
244
|
-
def _normalize_messages(self, messages:
|
|
322
|
+
def _normalize_messages(self, messages: PgmqQueueMessage | list[PgmqQueueMessage] | None) -> list[PgmqQueueMessage]:
|
|
245
323
|
if messages is None:
|
|
246
324
|
return []
|
|
247
|
-
|
|
248
|
-
normalized_messages: list[_PgmqQueueMessage] = []
|
|
249
|
-
for message in raw_messages:
|
|
250
|
-
try:
|
|
251
|
-
normalized_messages.append(_PgmqQueueMessage.model_validate(message, from_attributes=True))
|
|
252
|
-
except ValidationError as exc:
|
|
253
|
-
raise UnsupportedMessageEncoding(
|
|
254
|
-
"PGMQ messages must expose 'msg_id' and a JSONB-serializable object in 'message'."
|
|
255
|
-
) from exc
|
|
256
|
-
return normalized_messages
|
|
325
|
+
return [messages] if not isinstance(messages, list) else messages
|
|
257
326
|
|
|
258
|
-
def _read_immediate(self) -> list[
|
|
259
|
-
return self._normalize_messages(
|
|
327
|
+
def _read_immediate(self) -> list[PgmqQueueMessage]:
|
|
328
|
+
return self._normalize_messages(
|
|
329
|
+
self.client.read(
|
|
330
|
+
self.queue_name,
|
|
331
|
+
vt=self.visibility_timeout,
|
|
332
|
+
qty=self.prefetch,
|
|
333
|
+
)
|
|
334
|
+
)
|
|
260
335
|
|
|
261
|
-
def _read_with_poll(self) -> list[
|
|
336
|
+
def _read_with_poll(self) -> list[PgmqQueueMessage]:
|
|
262
337
|
return self._normalize_messages(
|
|
263
338
|
self.client.read_with_poll(
|
|
264
339
|
self.queue_name,
|
|
340
|
+
vt=self.visibility_timeout,
|
|
265
341
|
qty=self.prefetch,
|
|
266
342
|
max_poll_seconds=self.max_poll_seconds,
|
|
267
343
|
poll_interval_ms=self.poll_interval_ms,
|
|
268
344
|
)
|
|
269
345
|
)
|
|
270
346
|
|
|
347
|
+
def _start_heartbeat(self) -> None:
|
|
348
|
+
self._heartbeat_thread = Thread(
|
|
349
|
+
target=self._run_heartbeat,
|
|
350
|
+
name=f"pgmq-heartbeat-{self.queue_name}",
|
|
351
|
+
daemon=True,
|
|
352
|
+
)
|
|
353
|
+
self._heartbeat_thread.start()
|
|
354
|
+
|
|
355
|
+
def _run_heartbeat(self) -> None:
|
|
356
|
+
while not self._heartbeat_stop.wait(self.heartbeat_interval):
|
|
357
|
+
with self._heartbeat_msg_ids_lock:
|
|
358
|
+
msg_ids = list(self._heartbeat_msg_ids)
|
|
359
|
+
if not msg_ids:
|
|
360
|
+
continue
|
|
361
|
+
try:
|
|
362
|
+
self.client.set_vt(self.queue_name, msg_ids, self.visibility_timeout)
|
|
363
|
+
except Exception:
|
|
364
|
+
self.broker.logger.warning(
|
|
365
|
+
"Failed to extend visibility timeout heartbeat for queue %s.",
|
|
366
|
+
self.queue_name,
|
|
367
|
+
exc_info=True,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def _register_heartbeat_msg(self, message: "_PgmqMessage") -> None:
|
|
371
|
+
with self._heartbeat_msg_ids_lock:
|
|
372
|
+
self._heartbeat_msg_ids.add(message._pgmq_message.msg_id)
|
|
373
|
+
|
|
374
|
+
def _unregister_heartbeat_msg_id(self, msg_id: int) -> None:
|
|
375
|
+
with self._heartbeat_msg_ids_lock:
|
|
376
|
+
self._heartbeat_msg_ids.discard(msg_id)
|
|
377
|
+
|
|
271
378
|
def _start_listener(self) -> None:
|
|
272
379
|
channel = f"pgmq.q_{self.queue_name}.INSERT"
|
|
273
380
|
try:
|
|
274
|
-
|
|
275
|
-
self._listener_connection = psycopg.connect(conninfo, autocommit=True)
|
|
381
|
+
self._listener_connection = psycopg.connect(self.broker.url, autocommit=True)
|
|
276
382
|
self._listener_connection.execute(psycopg_sql.SQL("LISTEN {}").format(psycopg_sql.Identifier(channel)))
|
|
277
383
|
self._listener_available = True
|
|
278
384
|
except Exception:
|
|
@@ -316,21 +422,30 @@ class _PgmqConsumer(Consumer):
|
|
|
316
422
|
def ack(self, message: "MessageProxy") -> None:
|
|
317
423
|
if not isinstance(message, _PgmqMessage):
|
|
318
424
|
raise ValueError("It must be a PgmqMessage")
|
|
319
|
-
self.
|
|
425
|
+
self._unregister_heartbeat_msg_id(message._pgmq_message.msg_id)
|
|
426
|
+
self.client.archive(self.queue_name, message._pgmq_message.msg_id)
|
|
320
427
|
|
|
321
428
|
def nack(self, message: "MessageProxy") -> None:
|
|
322
429
|
if not isinstance(message, _PgmqMessage):
|
|
323
430
|
raise ValueError("It must be a PgmqMessage")
|
|
431
|
+
self._unregister_heartbeat_msg_id(message._pgmq_message.msg_id)
|
|
324
432
|
self.client.archive(self.queue_name, message._pgmq_message.msg_id)
|
|
325
433
|
|
|
326
434
|
def requeue(self, messages: Iterable["MessageProxy"]) -> None:
|
|
327
435
|
msg_ids = [message._pgmq_message.msg_id for message in messages if isinstance(message, _PgmqMessage)]
|
|
328
436
|
if msg_ids:
|
|
437
|
+
for msg_id in msg_ids:
|
|
438
|
+
self._unregister_heartbeat_msg_id(msg_id)
|
|
329
439
|
self.client.set_vt(self.queue_name, msg_ids, 0)
|
|
330
440
|
|
|
441
|
+
def _build_message(self, pgmq_message: PgmqQueueMessage) -> "_PgmqMessage":
|
|
442
|
+
message = _PgmqMessage(pgmq_message)
|
|
443
|
+
self._register_heartbeat_msg(message)
|
|
444
|
+
return message
|
|
445
|
+
|
|
331
446
|
def __next__(self) -> "_PgmqMessage | None":
|
|
332
447
|
if self.messages:
|
|
333
|
-
return
|
|
448
|
+
return self._build_message(self.messages.popleft())
|
|
334
449
|
|
|
335
450
|
if self._listener_available:
|
|
336
451
|
self._notify_event.clear()
|
|
@@ -345,10 +460,11 @@ class _PgmqConsumer(Consumer):
|
|
|
345
460
|
return None
|
|
346
461
|
|
|
347
462
|
self.messages.extend(messages)
|
|
348
|
-
return
|
|
463
|
+
return self._build_message(self.messages.popleft())
|
|
349
464
|
|
|
350
465
|
def close(self) -> None:
|
|
351
466
|
self._listener_stop.set()
|
|
467
|
+
self._heartbeat_stop.set()
|
|
352
468
|
self._notify_event.set()
|
|
353
469
|
if self._listener_connection is not None:
|
|
354
470
|
try:
|
|
@@ -359,17 +475,21 @@ class _PgmqConsumer(Consumer):
|
|
|
359
475
|
self._listener_connection = None
|
|
360
476
|
if self._listener_thread is not None:
|
|
361
477
|
self._listener_thread.join(timeout=1)
|
|
478
|
+
if self._heartbeat_thread is not None:
|
|
479
|
+
self._heartbeat_thread.join(timeout=1)
|
|
362
480
|
return
|
|
363
481
|
|
|
364
482
|
|
|
365
483
|
class _PgmqMessage(MessageProxy):
|
|
366
|
-
def __init__(self, pgmq_message:
|
|
484
|
+
def __init__(self, pgmq_message: PgmqQueueMessage) -> None:
|
|
367
485
|
payload = pgmq_message.message
|
|
368
486
|
if not isinstance(payload, dict):
|
|
369
487
|
raise UnsupportedMessageEncoding("PGMQ messages must contain JSON objects.")
|
|
370
488
|
|
|
371
489
|
try:
|
|
372
|
-
message
|
|
490
|
+
# Re-run the global message decoder so custom encoders (e.g. PydanticEncoder)
|
|
491
|
+
# can rehydrate actor args/kwargs to their typed schemas.
|
|
492
|
+
message = Message.decode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
|
|
373
493
|
except TypeError as exc:
|
|
374
494
|
raise UnsupportedMessageEncoding("PGMQ message payload is not a valid Remoulade message envelope.") from exc
|
|
375
495
|
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
"""This file describe the API to get the state of messages"""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
from typing import Any, TypedDict
|
|
5
|
-
|
|
6
|
-
from flask import Flask
|
|
7
|
-
from flask_apispec import marshal_with
|
|
8
|
-
from marshmallow import Schema, ValidationError, fields, validate, validates_schema
|
|
9
|
-
from werkzeug.exceptions import HTTPException
|
|
10
|
-
|
|
11
|
-
import remoulade
|
|
12
|
-
from remoulade import get_broker
|
|
13
|
-
from remoulade.errors import NoResultBackend, NoStateBackend, RemouladeError
|
|
14
|
-
from remoulade.result import Result
|
|
15
|
-
from remoulade.results import ResultMissing
|
|
16
|
-
|
|
17
|
-
from .apispec import add_swagger, validate_schema
|
|
18
|
-
from .scheduler import scheduler_bp, scheduler_routes
|
|
19
|
-
from .state import messages_bp, messages_routes
|
|
20
|
-
|
|
21
|
-
app = Flask(__name__)
|
|
22
|
-
app.register_blueprint(scheduler_bp)
|
|
23
|
-
app.register_blueprint(messages_bp)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class MessageSchema(Schema):
|
|
27
|
-
"""
|
|
28
|
-
Class to validate post data in /messages
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
actor_name = fields.Str(validate=validate.Length(min=1), required=True)
|
|
32
|
-
args = fields.List(fields.Raw(), allow_none=True)
|
|
33
|
-
kwargs = fields.Dict(allow_none=True)
|
|
34
|
-
options = fields.Dict(allow_none=True)
|
|
35
|
-
delay = fields.Float(validate=validate.Range(min=1), allow_none=True)
|
|
36
|
-
|
|
37
|
-
@validates_schema
|
|
38
|
-
def validate_actor_name(self, data, **kwargs):
|
|
39
|
-
actor_name = data.get("actor_name")
|
|
40
|
-
if actor_name not in remoulade.get_broker().actors:
|
|
41
|
-
raise ValidationError(f"No actor named {actor_name} exists")
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class ResponseSchema(Schema):
|
|
45
|
-
result = fields.Raw(allow_none=True)
|
|
46
|
-
error = fields.Str(allow_none=True)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class ArgsSchema(Schema):
|
|
50
|
-
name = fields.Str()
|
|
51
|
-
type = fields.Str()
|
|
52
|
-
default = fields.Str(allow_none=True)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class ActorSchema(Schema):
|
|
56
|
-
name = fields.Str()
|
|
57
|
-
queue_name = fields.Str()
|
|
58
|
-
alternative_queues = fields.List(fields.Str())
|
|
59
|
-
priority = fields.Int()
|
|
60
|
-
args = fields.List(fields.Nested(ArgsSchema))
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class ActorResponseSchema(Schema):
|
|
64
|
-
result = fields.List(fields.Nested(ActorSchema))
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class OptionsResponseSchema(Schema):
|
|
68
|
-
options = fields.List(fields.Str())
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@app.route("/messages/cancel/<message_id>", methods=["POST"])
|
|
72
|
-
@marshal_with(ResponseSchema)
|
|
73
|
-
def cancel_message(message_id):
|
|
74
|
-
broker = get_broker()
|
|
75
|
-
cancel_backend = broker.get_cancel_backend()
|
|
76
|
-
try:
|
|
77
|
-
# If a single message in a composition is canceled, we cancel the whole composition
|
|
78
|
-
state_backend = broker.get_state_backend()
|
|
79
|
-
states_count = state_backend.get_states_count(selected_composition_ids=[message_id])
|
|
80
|
-
state = state_backend.get_state(message_id)
|
|
81
|
-
if states_count == 0 and state is None:
|
|
82
|
-
raise ValidationError(f"This message id or composition id {message_id} does not exist.")
|
|
83
|
-
if state and state.composition_id:
|
|
84
|
-
message_id = state.composition_id
|
|
85
|
-
except NoStateBackend:
|
|
86
|
-
pass
|
|
87
|
-
cancel_backend.cancel([message_id])
|
|
88
|
-
return {"result": "ok"}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@app.route("/messages/requeue/<message_id>", methods=["POST"])
|
|
92
|
-
@marshal_with(ResponseSchema)
|
|
93
|
-
def requeue_message(message_id):
|
|
94
|
-
broker = get_broker()
|
|
95
|
-
backend = broker.get_state_backend()
|
|
96
|
-
state = backend.get_state(message_id)
|
|
97
|
-
if state is None:
|
|
98
|
-
raise ValidationError(f"No message with id {message_id} exists")
|
|
99
|
-
actor = broker.get_actor(state.actor_name)
|
|
100
|
-
payload = {"args": state.args, "kwargs": state.kwargs}
|
|
101
|
-
pipe_target = state.options.get("pipe_target")
|
|
102
|
-
if pipe_target is None:
|
|
103
|
-
actor.send_with_options(**payload, **state.options)
|
|
104
|
-
return {"result": "ok"}
|
|
105
|
-
else:
|
|
106
|
-
return {"error": "requeue message in a pipeline not supported"}, 400
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
@app.route("/messages/result/<message_id>")
|
|
110
|
-
@marshal_with(ResponseSchema)
|
|
111
|
-
def get_results(message_id):
|
|
112
|
-
from ..message import get_encoder
|
|
113
|
-
|
|
114
|
-
max_size = 1e4
|
|
115
|
-
try:
|
|
116
|
-
result = Result[Any](message_id=message_id).get()
|
|
117
|
-
encoded_result = get_encoder().encode(result).decode("utf-8")
|
|
118
|
-
size_result = sys.getsizeof(encoded_result)
|
|
119
|
-
if size_result >= max_size:
|
|
120
|
-
encoded_result = f"The result is too big {size_result / 1e6}M"
|
|
121
|
-
return {"result": encoded_result}
|
|
122
|
-
except ResultMissing:
|
|
123
|
-
return {"result": "result is missing"}
|
|
124
|
-
except NoResultBackend:
|
|
125
|
-
return {"result": "no result backend"}
|
|
126
|
-
except (UnicodeDecodeError, TypeError):
|
|
127
|
-
return {"result": "non serializable result"}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
@app.route("/messages", methods=["POST"])
|
|
131
|
-
@marshal_with(ResponseSchema)
|
|
132
|
-
@validate_schema(MessageSchema)
|
|
133
|
-
def enqueue_message(**kwargs):
|
|
134
|
-
actor = get_broker().get_actor(kwargs.pop("actor_name"))
|
|
135
|
-
options = kwargs.pop("options") or {}
|
|
136
|
-
actor.send_with_options(**kwargs, **options)
|
|
137
|
-
return {"result": "ok"}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
class GroupMessagesT(TypedDict):
|
|
141
|
-
group_id: str
|
|
142
|
-
messages: list[dict]
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
@app.route("/actors")
|
|
146
|
-
@marshal_with(ActorResponseSchema)
|
|
147
|
-
def get_actors():
|
|
148
|
-
return {"result": [actor.as_dict() for actor in get_broker().actors.values()]}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
@app.route("/options")
|
|
152
|
-
@marshal_with(OptionsResponseSchema)
|
|
153
|
-
def get_options():
|
|
154
|
-
broker = get_broker()
|
|
155
|
-
return {"options": list(broker.actor_options)}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
@app.errorhandler(RemouladeError)
|
|
159
|
-
def remoulade_exception(e):
|
|
160
|
-
return {"error": str(e)}, 500
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
@app.errorhandler(HTTPException)
|
|
164
|
-
def http_exception(e):
|
|
165
|
-
return {"error": str(e)}, e.code
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
@app.errorhandler(ValidationError)
|
|
169
|
-
def validation_error(e):
|
|
170
|
-
return {"error": e.normalized_messages()}, 400
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
routes = [cancel_message, requeue_message, get_results, enqueue_message, get_actors, get_options]
|
|
174
|
-
|
|
175
|
-
add_swagger(app, {"main": routes, "scheduler": scheduler_routes, "messages": messages_routes})
|
|
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
|
|
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
|
|
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
|