taskflow-meter 1.0.0__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.
- taskflow_meter/__init__.py +47 -0
- taskflow_meter/_version.py +24 -0
- taskflow_meter/api/__init__.py +35 -0
- taskflow_meter/api/asgi.py +236 -0
- taskflow_meter/api/dispatch.py +128 -0
- taskflow_meter/api/http.py +210 -0
- taskflow_meter/api/router.py +113 -0
- taskflow_meter/api/routes.py +53 -0
- taskflow_meter/api/serializers.py +137 -0
- taskflow_meter/api/service.py +189 -0
- taskflow_meter/api/sse.py +222 -0
- taskflow_meter/api/wsgi.py +145 -0
- taskflow_meter/cli.py +287 -0
- taskflow_meter/collect/__init__.py +31 -0
- taskflow_meter/collect/attachment.py +208 -0
- taskflow_meter/collect/listener.py +161 -0
- taskflow_meter/collect/pipeline.py +229 -0
- taskflow_meter/collect/progress.py +170 -0
- taskflow_meter/conf.py +173 -0
- taskflow_meter/contrib/__init__.py +18 -0
- taskflow_meter/contrib/django.py +160 -0
- taskflow_meter/contrib/fastapi.py +149 -0
- taskflow_meter/contrib/flask.py +140 -0
- taskflow_meter/contrib/paste.py +96 -0
- taskflow_meter/contrib/pecan.py +84 -0
- taskflow_meter/datasource/__init__.py +33 -0
- taskflow_meter/datasource/base.py +154 -0
- taskflow_meter/datasource/memory.py +232 -0
- taskflow_meter/datasource/persistence.py +311 -0
- taskflow_meter/datasource/sqlalchemy/__init__.py +21 -0
- taskflow_meter/datasource/sqlalchemy/migrations/env.py +68 -0
- taskflow_meter/datasource/sqlalchemy/migrations/script.py.mako +25 -0
- taskflow_meter/datasource/sqlalchemy/migrations/versions/0001_initial.py +71 -0
- taskflow_meter/datasource/sqlalchemy/models.py +63 -0
- taskflow_meter/datasource/sqlalchemy/source.py +367 -0
- taskflow_meter/diff.py +223 -0
- taskflow_meter/events.py +129 -0
- taskflow_meter/fold.py +137 -0
- taskflow_meter/meter.py +255 -0
- taskflow_meter/models.py +143 -0
- taskflow_meter/poller.py +191 -0
- taskflow_meter/py.typed +0 -0
- taskflow_meter/states.py +60 -0
- taskflow_meter/transports/__init__.py +21 -0
- taskflow_meter/transports/amqp.py +204 -0
- taskflow_meter/transports/base.py +105 -0
- taskflow_meter/transports/http.py +97 -0
- taskflow_meter/transports/memory.py +83 -0
- taskflow_meter-1.0.0.dist-info/METADATA +258 -0
- taskflow_meter-1.0.0.dist-info/RECORD +53 -0
- taskflow_meter-1.0.0.dist-info/WHEEL +4 -0
- taskflow_meter-1.0.0.dist-info/entry_points.txt +19 -0
- taskflow_meter-1.0.0.dist-info/licenses/LICENSE +176 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""Register the meter's routes in a Flask application's own blueprint.
|
|
14
|
+
|
|
15
|
+
Mounting the WSGI callable with ``DispatcherMiddleware`` is simpler and
|
|
16
|
+
works everywhere; reach for this when the routes need to be *inside*
|
|
17
|
+
the host app, so its ``before_request`` hooks, error handlers and
|
|
18
|
+
authentication apply to them::
|
|
19
|
+
|
|
20
|
+
from taskflow_meter.contrib.flask import meter_blueprint
|
|
21
|
+
|
|
22
|
+
app.register_blueprint(meter_blueprint(meter), url_prefix="/taskflow")
|
|
23
|
+
|
|
24
|
+
Flask's rule syntax differs from ours -- ``<run_id>`` rather than
|
|
25
|
+
``{run_id}`` -- so the templates are translated on the way in, from the
|
|
26
|
+
same table the router matches on.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import flask
|
|
36
|
+
from flask import Blueprint
|
|
37
|
+
from flask import Response
|
|
38
|
+
|
|
39
|
+
from taskflow_meter.api import routes as route_table
|
|
40
|
+
from taskflow_meter.api.dispatch import Dispatcher
|
|
41
|
+
from taskflow_meter.api.http import MeterRequest
|
|
42
|
+
from taskflow_meter.api.http import MeterResponse
|
|
43
|
+
from taskflow_meter.api.http import mount_prefix
|
|
44
|
+
from taskflow_meter.api.router import Route
|
|
45
|
+
from taskflow_meter.api.service import MeterService
|
|
46
|
+
from taskflow_meter.api.sse import StreamResponse
|
|
47
|
+
from taskflow_meter.api.sse import iter_frames
|
|
48
|
+
from taskflow_meter.meter import Meter
|
|
49
|
+
|
|
50
|
+
DEFAULT_STREAM_INTERVAL = 1.0
|
|
51
|
+
DEFAULT_HEARTBEAT = 15.0
|
|
52
|
+
|
|
53
|
+
_PARAM = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def meter_blueprint(
|
|
57
|
+
meter: Meter,
|
|
58
|
+
*,
|
|
59
|
+
service: MeterService | None = None,
|
|
60
|
+
name: str = "taskflow_meter",
|
|
61
|
+
stream_interval: float = DEFAULT_STREAM_INTERVAL,
|
|
62
|
+
heartbeat: float = DEFAULT_HEARTBEAT,
|
|
63
|
+
) -> Blueprint:
|
|
64
|
+
"""Build a blueprint serving every endpoint the callables serve."""
|
|
65
|
+
resolved = service or MeterService(meter)
|
|
66
|
+
dispatcher = Dispatcher(resolved)
|
|
67
|
+
blueprint = Blueprint(name, __name__)
|
|
68
|
+
|
|
69
|
+
for route in route_table.build_routes(resolved):
|
|
70
|
+
blueprint.add_url_rule(
|
|
71
|
+
to_flask_rule(route.template),
|
|
72
|
+
endpoint=route.name,
|
|
73
|
+
view_func=_view(
|
|
74
|
+
meter, dispatcher, route, stream_interval, heartbeat
|
|
75
|
+
),
|
|
76
|
+
methods=[route.method],
|
|
77
|
+
)
|
|
78
|
+
return blueprint
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def to_flask_rule(template: str) -> str:
|
|
82
|
+
"""``/flows/{run_id}`` -> ``/flows/<run_id>``."""
|
|
83
|
+
return _PARAM.sub(r"<\1>", template)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _view(
|
|
87
|
+
meter: Meter,
|
|
88
|
+
dispatcher: Dispatcher,
|
|
89
|
+
route: Route,
|
|
90
|
+
stream_interval: float,
|
|
91
|
+
heartbeat: float,
|
|
92
|
+
) -> Callable[..., Response]:
|
|
93
|
+
def view(**params: Any) -> Response:
|
|
94
|
+
# Flask has no lifespan either, so the first request is where a
|
|
95
|
+
# meter nobody started gets started.
|
|
96
|
+
meter.ensure_started()
|
|
97
|
+
|
|
98
|
+
result = dispatcher.run(route, _build_request(route, params))
|
|
99
|
+
if isinstance(result, StreamResponse):
|
|
100
|
+
return Response(
|
|
101
|
+
iter_frames(
|
|
102
|
+
result.cursor,
|
|
103
|
+
interval=stream_interval,
|
|
104
|
+
heartbeat=heartbeat,
|
|
105
|
+
),
|
|
106
|
+
status=result.status,
|
|
107
|
+
headers=list(result.headers),
|
|
108
|
+
)
|
|
109
|
+
return _response(result)
|
|
110
|
+
|
|
111
|
+
return view
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_request(route: Route, params: dict[str, Any]) -> MeterRequest:
|
|
115
|
+
"""Translate the active Flask request, recovering the url_prefix.
|
|
116
|
+
|
|
117
|
+
``request.path`` excludes ``SCRIPT_NAME`` but includes whatever
|
|
118
|
+
prefix the blueprint was registered under, so the prefix is what is
|
|
119
|
+
left when our own template is taken off the end.
|
|
120
|
+
"""
|
|
121
|
+
request = flask.request
|
|
122
|
+
text_params = {key: str(value) for key, value in params.items()}
|
|
123
|
+
sub_path = route.template.format(**text_params)
|
|
124
|
+
|
|
125
|
+
return MeterRequest(
|
|
126
|
+
method=request.method,
|
|
127
|
+
path=sub_path,
|
|
128
|
+
prefix=request.script_root + mount_prefix(request.path, sub_path),
|
|
129
|
+
query={key: request.args.getlist(key) for key in request.args},
|
|
130
|
+
headers={key.lower(): value for key, value in request.headers.items()},
|
|
131
|
+
path_params=text_params,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _response(result: MeterResponse) -> Response:
|
|
136
|
+
return Response(
|
|
137
|
+
result.body,
|
|
138
|
+
status=result.status,
|
|
139
|
+
headers=list(result.headers),
|
|
140
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""Host the meter in a paste pipeline.
|
|
14
|
+
|
|
15
|
+
A service that composes its WSGI stack from ``api-paste.ini`` wants a
|
|
16
|
+
``paste.app_factory``. Dispatch a prefix to the app and it is served
|
|
17
|
+
alongside the API it is monitoring, on the same port, behind the same
|
|
18
|
+
middleware::
|
|
19
|
+
|
|
20
|
+
[composite:main]
|
|
21
|
+
use = egg:Paste#urlmap
|
|
22
|
+
/: your_api
|
|
23
|
+
/taskflow-meter: taskflow_meter
|
|
24
|
+
|
|
25
|
+
[app:taskflow_meter]
|
|
26
|
+
paste.app_factory = taskflow_meter.contrib.paste:app_factory
|
|
27
|
+
|
|
28
|
+
Configuration comes from the ``[taskflow_meter]`` group in the
|
|
29
|
+
service's own oslo.config file, so there is no second config file to
|
|
30
|
+
deploy. Anything in the paste stanza
|
|
31
|
+
overrides it, for the deployments that would rather keep it all in
|
|
32
|
+
``api-paste.ini``::
|
|
33
|
+
|
|
34
|
+
[app:taskflow_meter]
|
|
35
|
+
paste.app_factory = taskflow_meter.contrib.paste:app_factory
|
|
36
|
+
connection = mysql+pymysql://user:pass@host/taskflow
|
|
37
|
+
poll_interval = 5
|
|
38
|
+
|
|
39
|
+
urlmap gives the app a ``SCRIPT_NAME`` of the prefix it was mounted at,
|
|
40
|
+
which is exactly what the WSGI callable builds its links from, so the
|
|
41
|
+
mount point needs no configuring.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from oslo_config import cfg
|
|
49
|
+
|
|
50
|
+
from taskflow_meter.api.wsgi import WSGIApp
|
|
51
|
+
from taskflow_meter.conf import GROUP_NAME
|
|
52
|
+
from taskflow_meter.conf import meter_from_config
|
|
53
|
+
from taskflow_meter.conf import register_opts
|
|
54
|
+
|
|
55
|
+
#: Settings a paste stanza may override, and how to read them.
|
|
56
|
+
_OVERRIDES: dict[str, Any] = {
|
|
57
|
+
"connection": str,
|
|
58
|
+
"poll": lambda value: str(value).lower() in {"1", "true", "yes", "on"},
|
|
59
|
+
"poll_interval": float,
|
|
60
|
+
"max_events_per_run": int,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def app_factory(
|
|
65
|
+
global_config: dict[str, Any] | None = None, # noqa: ARG001
|
|
66
|
+
**local_conf: str,
|
|
67
|
+
) -> WSGIApp:
|
|
68
|
+
"""Build the WSGI app. Signature fixed by paste.
|
|
69
|
+
|
|
70
|
+
``global_config`` is paste's ``[DEFAULT]`` section, which we do not
|
|
71
|
+
read: the service's oslo.config is the source of truth, and the
|
|
72
|
+
paste stanza is the override.
|
|
73
|
+
"""
|
|
74
|
+
conf = register_opts()
|
|
75
|
+
apply_overrides(conf, local_conf)
|
|
76
|
+
return WSGIApp(meter_from_config(conf))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def apply_overrides(conf: cfg.ConfigOpts, local_conf: dict[str, str]) -> None:
|
|
80
|
+
"""Fold ``api-paste.ini`` values into the config.
|
|
81
|
+
|
|
82
|
+
Paste hands everything over as a string, so each one is coerced by
|
|
83
|
+
the type its option expects. An unusable value is rejected here,
|
|
84
|
+
naming the setting, rather than surfacing later as an odd failure
|
|
85
|
+
from somewhere else.
|
|
86
|
+
"""
|
|
87
|
+
for name, coerce in _OVERRIDES.items():
|
|
88
|
+
if name not in local_conf:
|
|
89
|
+
continue
|
|
90
|
+
raw = local_conf[name]
|
|
91
|
+
try:
|
|
92
|
+
value = coerce(raw)
|
|
93
|
+
except (TypeError, ValueError) as exc:
|
|
94
|
+
msg = f"{name}={raw!r} in the paste stanza is not usable"
|
|
95
|
+
raise ValueError(msg) from exc
|
|
96
|
+
conf.set_override(name, value, group=GROUP_NAME)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""Host the meter inside a Pecan controller tree.
|
|
14
|
+
|
|
15
|
+
For services built on Pecan, when the meter should live in the
|
|
16
|
+
application's own routing rather than beside it in a paste pipeline::
|
|
17
|
+
|
|
18
|
+
from taskflow_meter.contrib.pecan import MeterController
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RootController:
|
|
22
|
+
v1 = V1Controller()
|
|
23
|
+
taskflow_meter = MeterController()
|
|
24
|
+
|
|
25
|
+
Everything under that path is handed to the WSGI callable. Pecan is
|
|
26
|
+
built on WebOb, so the request already carries the environ the callable
|
|
27
|
+
wants; the only work is moving the part of the path Pecan has already
|
|
28
|
+
consumed out of ``PATH_INFO`` and into ``SCRIPT_NAME``, which is what
|
|
29
|
+
makes the generated links point back at the right place.
|
|
30
|
+
|
|
31
|
+
Mounting a sub-application means the host's own hooks and middleware do
|
|
32
|
+
not run for these requests. For deployments where they must, wire the
|
|
33
|
+
route table into the host's router instead -- ``api.routes.build_routes``
|
|
34
|
+
is that table, and it is data.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
import pecan
|
|
42
|
+
from webob import Request
|
|
43
|
+
|
|
44
|
+
from taskflow_meter.api.http import mount_prefix
|
|
45
|
+
from taskflow_meter.api.wsgi import WSGIApp
|
|
46
|
+
from taskflow_meter.conf import wsgi_app_from_config
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MeterController:
|
|
50
|
+
"""A Pecan controller that delegates to the WSGI callable."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, app: WSGIApp | None = None) -> None:
|
|
53
|
+
"""Wrap ``app``, or build one from the service's config."""
|
|
54
|
+
self.app = app if app is not None else wsgi_app_from_config()
|
|
55
|
+
|
|
56
|
+
@pecan.expose()
|
|
57
|
+
def index(self) -> Any:
|
|
58
|
+
"""The controller's own path, with nothing after it."""
|
|
59
|
+
return self._delegate(())
|
|
60
|
+
|
|
61
|
+
@pecan.expose()
|
|
62
|
+
def _default(self, *remainder: str) -> Any:
|
|
63
|
+
"""Everything below it."""
|
|
64
|
+
return self._delegate(remainder)
|
|
65
|
+
|
|
66
|
+
def _delegate(self, remainder: tuple[str, ...]) -> Any:
|
|
67
|
+
request = pecan.request
|
|
68
|
+
environ = dict(request.environ)
|
|
69
|
+
consumed, sub_path = _split(request.path_info, remainder)
|
|
70
|
+
environ["SCRIPT_NAME"] = request.script_name + consumed
|
|
71
|
+
environ["PATH_INFO"] = sub_path
|
|
72
|
+
# Pecan uses a returned WebOb response as-is, so the callable's
|
|
73
|
+
# status, headers and body reach the client untouched.
|
|
74
|
+
return Request(environ).get_response(self.app)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _split(path_info: str, remainder: tuple[str, ...]) -> tuple[str, str]:
|
|
78
|
+
"""Split the path into what Pecan consumed and what is left for us.
|
|
79
|
+
|
|
80
|
+
Pecan does not rewrite ``PATH_INFO`` as it routes, so the mount
|
|
81
|
+
point has to be recovered by taking the remainder off the end.
|
|
82
|
+
"""
|
|
83
|
+
sub_path = "/" + "/".join(remainder) if remainder else "/"
|
|
84
|
+
return mount_prefix(path_info, sub_path), sub_path
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""Datasources: where flow state is read from, and optionally written to."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from taskflow_meter.datasource.base import DataSource
|
|
18
|
+
from taskflow_meter.datasource.base import EventPage
|
|
19
|
+
from taskflow_meter.datasource.base import FlowPage
|
|
20
|
+
from taskflow_meter.datasource.base import UnknownMarkerError
|
|
21
|
+
from taskflow_meter.datasource.base import WritableDataSource
|
|
22
|
+
from taskflow_meter.datasource.memory import MemoryDataSource
|
|
23
|
+
from taskflow_meter.datasource.persistence import PersistenceDataSource
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DataSource",
|
|
27
|
+
"EventPage",
|
|
28
|
+
"FlowPage",
|
|
29
|
+
"MemoryDataSource",
|
|
30
|
+
"PersistenceDataSource",
|
|
31
|
+
"UnknownMarkerError",
|
|
32
|
+
"WritableDataSource",
|
|
33
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
2
|
+
# not use this file except in compliance with the License. You may obtain
|
|
3
|
+
# a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
9
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
10
|
+
# License for the specific language governing permissions and limitations
|
|
11
|
+
# under the License.
|
|
12
|
+
|
|
13
|
+
"""The contract every datasource implements.
|
|
14
|
+
|
|
15
|
+
Read and write are separate on purpose. The primary datasource reads
|
|
16
|
+
taskflow's own persistence and can never accept events, while the in-memory
|
|
17
|
+
and SQL ones are fed by a producer; making that a type distinction stops a
|
|
18
|
+
read-only source from being wired up as a sink by mistake.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import abc
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from types import TracebackType
|
|
27
|
+
from typing import Self
|
|
28
|
+
|
|
29
|
+
from taskflow_meter.events import Event
|
|
30
|
+
from taskflow_meter.models import AtomSnapshot
|
|
31
|
+
from taskflow_meter.models import FlowSnapshot
|
|
32
|
+
|
|
33
|
+
#: Flows returned by a single unqualified listing.
|
|
34
|
+
DEFAULT_FLOW_LIMIT = 50
|
|
35
|
+
|
|
36
|
+
#: Events returned by a single :meth:`DataSource.events_since` call.
|
|
37
|
+
DEFAULT_EVENT_LIMIT = 500
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UnknownMarkerError(LookupError):
|
|
41
|
+
"""A paging marker refers to a flow the datasource cannot place.
|
|
42
|
+
|
|
43
|
+
Usually means the run expired between pages. Raised rather than
|
|
44
|
+
silently restarting from the top, which would make a client loop over
|
|
45
|
+
the same first page forever.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class FlowPage:
|
|
51
|
+
"""One page of flows, newest observation first."""
|
|
52
|
+
|
|
53
|
+
items: tuple[FlowSnapshot, ...] = ()
|
|
54
|
+
next_marker: str | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def has_more(self) -> bool:
|
|
58
|
+
return self.next_marker is not None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class EventPage:
|
|
63
|
+
"""One page of a run's event stream.
|
|
64
|
+
|
|
65
|
+
``truncated`` is the important field: it says the caller asked for
|
|
66
|
+
events that have already been evicted, so there is a hole between what
|
|
67
|
+
it last saw and what it is being given. A client that cares must
|
|
68
|
+
re-read the snapshot instead of assuming continuity.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
events: tuple[Event, ...] = ()
|
|
72
|
+
next_seq: int = 0
|
|
73
|
+
oldest_seq: int | None = None
|
|
74
|
+
truncated: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DataSource(abc.ABC):
|
|
78
|
+
"""Read access to observed flows."""
|
|
79
|
+
|
|
80
|
+
#: Stevedore plugin name, set by subclasses.
|
|
81
|
+
name: str = ""
|
|
82
|
+
|
|
83
|
+
#: Whether :meth:`events_since` can actually return a history. A
|
|
84
|
+
#: source reading a store that only keeps current state sets this
|
|
85
|
+
#: False, so an API can decline to advertise a stream it cannot serve
|
|
86
|
+
#: rather than handing clients an empty one that is indistinguishable
|
|
87
|
+
#: from silence.
|
|
88
|
+
supports_events: bool = True
|
|
89
|
+
|
|
90
|
+
def start(self) -> None: # noqa: B027 - optional hook, not abstract
|
|
91
|
+
"""Acquire whatever the source needs. Idempotent.
|
|
92
|
+
|
|
93
|
+
Sources holding nothing (the in-memory one) need not override it.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def stop(self) -> None: # noqa: B027 - optional hook, not abstract
|
|
97
|
+
"""Release it again. Idempotent, and safe to call unstarted."""
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> Self:
|
|
100
|
+
self.start()
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(
|
|
104
|
+
self,
|
|
105
|
+
exc_type: type[BaseException] | None,
|
|
106
|
+
exc: BaseException | None,
|
|
107
|
+
tb: TracebackType | None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self.stop()
|
|
110
|
+
|
|
111
|
+
@abc.abstractmethod
|
|
112
|
+
def list_flows(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
state: str | None = None,
|
|
116
|
+
book_id: str | None = None,
|
|
117
|
+
limit: int = DEFAULT_FLOW_LIMIT,
|
|
118
|
+
marker: str | None = None,
|
|
119
|
+
) -> FlowPage:
|
|
120
|
+
"""Return flows, newest observation first."""
|
|
121
|
+
|
|
122
|
+
@abc.abstractmethod
|
|
123
|
+
def get_flow(self, run_id: str) -> FlowSnapshot | None:
|
|
124
|
+
"""Return one flow, or ``None`` if the source has never seen it."""
|
|
125
|
+
|
|
126
|
+
@abc.abstractmethod
|
|
127
|
+
def events_since(
|
|
128
|
+
self,
|
|
129
|
+
run_id: str,
|
|
130
|
+
*,
|
|
131
|
+
since_seq: int = 0,
|
|
132
|
+
limit: int = DEFAULT_EVENT_LIMIT,
|
|
133
|
+
) -> EventPage:
|
|
134
|
+
"""Return events for ``run_id`` with ``seq`` greater than
|
|
135
|
+
``since_seq``."""
|
|
136
|
+
|
|
137
|
+
def get_atoms(self, run_id: str) -> tuple[AtomSnapshot, ...] | None:
|
|
138
|
+
"""Return a flow's atoms in name order, or ``None`` if unknown."""
|
|
139
|
+
flow = self.get_flow(run_id)
|
|
140
|
+
if flow is None:
|
|
141
|
+
return None
|
|
142
|
+
return tuple(flow.atoms[name] for name in flow.atom_names)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class WritableDataSource(DataSource):
|
|
146
|
+
"""A datasource that is fed by a producer."""
|
|
147
|
+
|
|
148
|
+
@abc.abstractmethod
|
|
149
|
+
def apply(self, event: Event) -> None:
|
|
150
|
+
"""Fold one event into the stored state."""
|
|
151
|
+
|
|
152
|
+
def apply_many(self, events: Iterable[Event]) -> None:
|
|
153
|
+
for event in events:
|
|
154
|
+
self.apply(event)
|