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.
Files changed (53) hide show
  1. taskflow_meter/__init__.py +47 -0
  2. taskflow_meter/_version.py +24 -0
  3. taskflow_meter/api/__init__.py +35 -0
  4. taskflow_meter/api/asgi.py +236 -0
  5. taskflow_meter/api/dispatch.py +128 -0
  6. taskflow_meter/api/http.py +210 -0
  7. taskflow_meter/api/router.py +113 -0
  8. taskflow_meter/api/routes.py +53 -0
  9. taskflow_meter/api/serializers.py +137 -0
  10. taskflow_meter/api/service.py +189 -0
  11. taskflow_meter/api/sse.py +222 -0
  12. taskflow_meter/api/wsgi.py +145 -0
  13. taskflow_meter/cli.py +287 -0
  14. taskflow_meter/collect/__init__.py +31 -0
  15. taskflow_meter/collect/attachment.py +208 -0
  16. taskflow_meter/collect/listener.py +161 -0
  17. taskflow_meter/collect/pipeline.py +229 -0
  18. taskflow_meter/collect/progress.py +170 -0
  19. taskflow_meter/conf.py +173 -0
  20. taskflow_meter/contrib/__init__.py +18 -0
  21. taskflow_meter/contrib/django.py +160 -0
  22. taskflow_meter/contrib/fastapi.py +149 -0
  23. taskflow_meter/contrib/flask.py +140 -0
  24. taskflow_meter/contrib/paste.py +96 -0
  25. taskflow_meter/contrib/pecan.py +84 -0
  26. taskflow_meter/datasource/__init__.py +33 -0
  27. taskflow_meter/datasource/base.py +154 -0
  28. taskflow_meter/datasource/memory.py +232 -0
  29. taskflow_meter/datasource/persistence.py +311 -0
  30. taskflow_meter/datasource/sqlalchemy/__init__.py +21 -0
  31. taskflow_meter/datasource/sqlalchemy/migrations/env.py +68 -0
  32. taskflow_meter/datasource/sqlalchemy/migrations/script.py.mako +25 -0
  33. taskflow_meter/datasource/sqlalchemy/migrations/versions/0001_initial.py +71 -0
  34. taskflow_meter/datasource/sqlalchemy/models.py +63 -0
  35. taskflow_meter/datasource/sqlalchemy/source.py +367 -0
  36. taskflow_meter/diff.py +223 -0
  37. taskflow_meter/events.py +129 -0
  38. taskflow_meter/fold.py +137 -0
  39. taskflow_meter/meter.py +255 -0
  40. taskflow_meter/models.py +143 -0
  41. taskflow_meter/poller.py +191 -0
  42. taskflow_meter/py.typed +0 -0
  43. taskflow_meter/states.py +60 -0
  44. taskflow_meter/transports/__init__.py +21 -0
  45. taskflow_meter/transports/amqp.py +204 -0
  46. taskflow_meter/transports/base.py +105 -0
  47. taskflow_meter/transports/http.py +97 -0
  48. taskflow_meter/transports/memory.py +83 -0
  49. taskflow_meter-1.0.0.dist-info/METADATA +258 -0
  50. taskflow_meter-1.0.0.dist-info/RECORD +53 -0
  51. taskflow_meter-1.0.0.dist-info/WHEEL +4 -0
  52. taskflow_meter-1.0.0.dist-info/entry_points.txt +19 -0
  53. taskflow_meter-1.0.0.dist-info/licenses/LICENSE +176 -0
taskflow_meter/diff.py ADDED
@@ -0,0 +1,223 @@
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
+ """Turn a pair of snapshots into the events that explain the difference.
14
+
15
+ This is how the read-only producer works: poll the flow, compare against
16
+ what was seen last time, and synthesise exactly the events the in-process
17
+ listener would have emitted. One event vocabulary, two producers.
18
+
19
+ What it cannot recover is ordering *within* a pair of snapshots. If two
20
+ atoms changed between polls, we did not see which moved first, so the rules
21
+ below are chosen to be deterministic and defensible rather than to guess:
22
+
23
+ * atoms are visited in name order, so replaying the same pair of snapshots
24
+ always produces the same events in the same order;
25
+ * an atom's state event precedes its progress event, because a state change
26
+ is the coarser fact;
27
+ * the flow's own event comes first when the flow is starting or continuing,
28
+ and last when the flow has reached a finish state -- a flow does not
29
+ finish before its atoms do.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from collections.abc import Iterator
36
+ from dataclasses import replace
37
+
38
+ from taskflow_meter.events import Event
39
+ from taskflow_meter.events import EventKind
40
+ from taskflow_meter.events import SequenceAllocator
41
+ from taskflow_meter.models import AtomSnapshot
42
+ from taskflow_meter.models import FlowSnapshot
43
+
44
+ LOG = logging.getLogger(__name__)
45
+
46
+ _UNSTAMPED_SEQ = 0
47
+ _UNSTAMPED_TS = 0.0
48
+
49
+
50
+ def diff_flow(
51
+ old: FlowSnapshot | None,
52
+ new: FlowSnapshot,
53
+ *,
54
+ allocator: SequenceAllocator,
55
+ ts: float | None = None,
56
+ ) -> list[Event]:
57
+ """Return the events describing ``old`` -> ``new``.
58
+
59
+ ``old`` is ``None`` for a run's first observation, which yields one
60
+ event per known fact so that a client joining late still receives a
61
+ complete picture rather than only future changes.
62
+
63
+ Sequence numbers come from ``allocator`` in emission order. Nothing is
64
+ allocated when there is nothing to report, so a quiet poll costs no
65
+ sequence numbers and a client's ``since_seq`` stays meaningful.
66
+ """
67
+ if old is not None and old.run_id != new.run_id:
68
+ msg = f"cannot diff across runs: {old.run_id!r} -> {new.run_id!r}"
69
+ raise ValueError(msg)
70
+
71
+ stamp = new.observed_at if ts is None else ts
72
+
73
+ flow_events = list(_flow_events(old, new))
74
+ atom_events = list(_atom_events(old, new))
75
+
76
+ ordered = (
77
+ atom_events + flow_events
78
+ if new.is_finished
79
+ else flow_events + atom_events
80
+ )
81
+ return [
82
+ replace(event, seq=allocator.allocate(new.run_id), ts=stamp)
83
+ for event in ordered
84
+ ]
85
+
86
+
87
+ def _flow_events(
88
+ old: FlowSnapshot | None, new: FlowSnapshot
89
+ ) -> Iterator[Event]:
90
+ if old is not None and old.state == new.state:
91
+ return
92
+
93
+ details: dict[str, object] = {}
94
+ if old is None:
95
+ # First sight of this run: carry the identity that later events
96
+ # will not repeat, so a datasource can reconstruct the flow from
97
+ # the event stream alone.
98
+ details["flow_name"] = new.name
99
+ if new.book_name is not None:
100
+ details["book_name"] = new.book_name
101
+
102
+ yield _new_event(
103
+ new,
104
+ kind=EventKind.FLOW_STATE,
105
+ state=new.state,
106
+ old_state=old.state if old is not None else None,
107
+ details=details,
108
+ )
109
+
110
+
111
+ def _atom_events(
112
+ old: FlowSnapshot | None, new: FlowSnapshot
113
+ ) -> Iterator[Event]:
114
+ previous = old.atoms if old is not None else {}
115
+
116
+ for name in new.atom_names:
117
+ current = new.atoms[name]
118
+ before = previous.get(name)
119
+
120
+ if before is None or before.state != current.state:
121
+ yield _atom_state_event(new, before, current)
122
+
123
+ if _progress_changed(before, current):
124
+ yield _atom_progress_event(new, current)
125
+
126
+ for name in sorted(set(previous) - set(new.atoms)):
127
+ # Atoms do not leave a flow while it runs. If one vanishes the
128
+ # snapshot source is lying to us; say so rather than emit an event
129
+ # kind that means nothing downstream.
130
+ LOG.warning(
131
+ "atom %r disappeared from run %s between observations",
132
+ name,
133
+ new.run_id,
134
+ )
135
+
136
+
137
+ def _progress_changed(
138
+ before: AtomSnapshot | None, current: AtomSnapshot
139
+ ) -> bool:
140
+ if before is None:
141
+ # Only worth an event if there is something to say; a brand new
142
+ # atom sitting at 0.0 is already fully described by its state.
143
+ return current.progress != 0.0 or current.progress_details is not None
144
+ return (
145
+ before.progress != current.progress
146
+ or before.progress_details != current.progress_details
147
+ )
148
+
149
+
150
+ def _atom_state_event(
151
+ flow: FlowSnapshot,
152
+ before: AtomSnapshot | None,
153
+ current: AtomSnapshot,
154
+ ) -> Event:
155
+ details: dict[str, object] = {}
156
+ if current.failure is not None and (
157
+ before is None or before.failure != current.failure
158
+ ):
159
+ details["failure"] = current.failure
160
+ if current.revert_failure is not None and (
161
+ before is None or before.revert_failure != current.revert_failure
162
+ ):
163
+ details["revert_failure"] = current.revert_failure
164
+ if current.has_result:
165
+ details["has_result"] = True
166
+
167
+ return _new_event(
168
+ flow,
169
+ kind=EventKind.ATOM_STATE,
170
+ atom=current,
171
+ state=current.state,
172
+ old_state=before.state if before is not None else None,
173
+ progress=current.progress,
174
+ details=details,
175
+ )
176
+
177
+
178
+ def _atom_progress_event(flow: FlowSnapshot, current: AtomSnapshot) -> Event:
179
+ details: dict[str, object] = {}
180
+ if current.progress_details is not None:
181
+ details["progress_details"] = current.progress_details
182
+ return _new_event(
183
+ flow,
184
+ kind=EventKind.ATOM_PROGRESS,
185
+ atom=current,
186
+ state=current.state,
187
+ progress=current.progress,
188
+ details=details,
189
+ )
190
+
191
+
192
+ def _new_event(
193
+ flow: FlowSnapshot,
194
+ *,
195
+ kind: EventKind,
196
+ atom: AtomSnapshot | None = None,
197
+ state: str | None = None,
198
+ old_state: str | None = None,
199
+ progress: float | None = None,
200
+ details: dict[str, object] | None = None,
201
+ ) -> Event:
202
+ """Build an event with placeholder ``seq``/``ts``.
203
+
204
+ :func:`diff_flow` stamps both once emission order is settled.
205
+ """
206
+ return Event(
207
+ run_id=flow.run_id,
208
+ seq=_UNSTAMPED_SEQ,
209
+ ts=_UNSTAMPED_TS,
210
+ kind=kind,
211
+ book_id=flow.book_id,
212
+ atom_name=atom.name if atom is not None else None,
213
+ atom_uuid=atom.uuid if atom is not None else None,
214
+ atom_type=atom.atom_type if atom is not None else None,
215
+ state=state,
216
+ old_state=old_state,
217
+ intention=atom.intention if atom is not None else None,
218
+ progress=progress,
219
+ details=dict(details or {}),
220
+ )
221
+
222
+
223
+ __all__ = ["diff_flow"]
@@ -0,0 +1,129 @@
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 single event shape every producer emits.
14
+
15
+ The in-process listener and the persistence poller observe a flow in very
16
+ different ways, but both express what they saw as one of these, so the
17
+ datasources, the API and any transport only ever handle one vocabulary.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import threading
23
+ from collections.abc import Mapping
24
+ from dataclasses import asdict
25
+ from dataclasses import dataclass
26
+ from dataclasses import field
27
+ from enum import StrEnum
28
+ from typing import Any
29
+ from typing import Self
30
+
31
+
32
+ class EventKind(StrEnum):
33
+ """What an event is reporting."""
34
+
35
+ #: The flow itself moved between states.
36
+ FLOW_STATE = "flow_state"
37
+ #: An atom moved between states, or was seen for the first time.
38
+ ATOM_STATE = "atom_state"
39
+ #: An atom reported progress without necessarily changing state.
40
+ ATOM_PROGRESS = "atom_progress"
41
+ #: The flow's graph, emitted once by the in-process producer. The
42
+ #: poller cannot produce this: taskflow persists atoms but not edges.
43
+ FLOW_STRUCTURE = "flow_structure"
44
+ #: The flow finished and a result or failure summary is available.
45
+ FLOW_RESULT = "flow_result"
46
+ #: Liveness only; carries no state change.
47
+ HEARTBEAT = "heartbeat"
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Event:
52
+ """One observation about one flow run.
53
+
54
+ ``seq`` is gap-free and monotonic per ``run_id``, which is what lets a
55
+ client reconnect with ``since_seq`` and know whether it missed
56
+ anything. ``ts`` is observation time -- see :class:`.FlowSnapshot`.
57
+ """
58
+
59
+ run_id: str
60
+ seq: int
61
+ ts: float
62
+ kind: EventKind
63
+ book_id: str | None = None
64
+ atom_name: str | None = None
65
+ atom_uuid: str | None = None
66
+ atom_type: str | None = None
67
+ state: str | None = None
68
+ old_state: str | None = None
69
+ intention: str | None = None
70
+ progress: float | None = None
71
+ details: dict[str, Any] = field(default_factory=dict)
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ """Render to plain JSON-serialisable types."""
75
+ data = asdict(self)
76
+ data["kind"] = str(self.kind)
77
+ return data
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: Mapping[str, Any]) -> Self:
81
+ """Rebuild from :meth:`to_dict` output.
82
+
83
+ Unknown keys are rejected rather than dropped -- a transport
84
+ speaking a newer dialect should fail loudly, not silently discard
85
+ the part we did not understand.
86
+ """
87
+ payload = dict(data)
88
+ payload["kind"] = EventKind(payload["kind"])
89
+ return cls(**payload)
90
+
91
+
92
+ class SequenceAllocator:
93
+ """Hands out gap-free per-run sequence numbers, starting at 1.
94
+
95
+ Thread-safe by necessity: the parallel engine fires notifier callbacks
96
+ from executor threads, and the poller runs on its own thread.
97
+ """
98
+
99
+ __slots__ = ("_counters", "_lock")
100
+
101
+ def __init__(self, counters: Mapping[str, int] | None = None) -> None:
102
+ self._lock = threading.Lock()
103
+ self._counters: dict[str, int] = dict(counters or {})
104
+
105
+ def allocate(self, run_id: str) -> int:
106
+ """Return the next sequence number for ``run_id``."""
107
+ with self._lock:
108
+ nxt = self._counters.get(run_id, 0) + 1
109
+ self._counters[run_id] = nxt
110
+ return nxt
111
+
112
+ def peek(self, run_id: str) -> int:
113
+ """Return the last number handed out, or 0 if none has been."""
114
+ with self._lock:
115
+ return self._counters.get(run_id, 0)
116
+
117
+ def resume_from(self, run_id: str, seq: int) -> None:
118
+ """Continue numbering after ``seq``.
119
+
120
+ A restarted collector calls this with the highest sequence already
121
+ stored, so it does not renumber events a client has already seen.
122
+ """
123
+ with self._lock:
124
+ self._counters[run_id] = max(self._counters.get(run_id, 0), seq)
125
+
126
+ def forget(self, run_id: str) -> None:
127
+ """Drop the counter for a run that is finished and expired."""
128
+ with self._lock:
129
+ self._counters.pop(run_id, None)
taskflow_meter/fold.py ADDED
@@ -0,0 +1,137 @@
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
+ """Folding events back into a snapshot.
14
+
15
+ Shared by every writable datasource, so they cannot disagree about what
16
+ an event means. The property this exists to keep true: applying the
17
+ events a diff produced reproduces the snapshots the diff came from --
18
+ whichever store is doing the applying.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Any
25
+
26
+ from taskflow_meter.events import Event
27
+ from taskflow_meter.events import EventKind
28
+ from taskflow_meter.models import AtomSnapshot
29
+ from taskflow_meter.models import FlowSnapshot
30
+
31
+ LOG = logging.getLogger(__name__)
32
+
33
+
34
+ def flow_from_event(event: Event) -> FlowSnapshot:
35
+ """Seed a run from the first event seen for it.
36
+
37
+ The diff engine puts the flow's identity on the first event of a
38
+ run, but a client may join mid-stream, so everything here has to
39
+ tolerate absence.
40
+ """
41
+ details = event.details
42
+ return FlowSnapshot(
43
+ run_id=event.run_id,
44
+ name=str(details.get("flow_name", "")),
45
+ book_id=event.book_id,
46
+ book_name=_optional_str(details.get("book_name")),
47
+ observed_at=event.ts,
48
+ )
49
+
50
+
51
+ def fold(flow: FlowSnapshot, event: Event) -> FlowSnapshot:
52
+ """Return ``flow`` updated by ``event``."""
53
+ from dataclasses import replace
54
+
55
+ flow = replace(flow, observed_at=max(flow.observed_at, event.ts))
56
+
57
+ if event.kind is EventKind.FLOW_STATE:
58
+ return replace(flow, state=event.state)
59
+
60
+ if event.kind in (EventKind.ATOM_STATE, EventKind.ATOM_PROGRESS):
61
+ if event.atom_name is None:
62
+ LOG.warning(
63
+ "ignoring %s event %d for run %s: no atom name",
64
+ event.kind,
65
+ event.seq,
66
+ event.run_id,
67
+ )
68
+ return flow
69
+ atoms = dict(flow.atoms)
70
+ atoms[event.atom_name] = fold_atom(
71
+ atoms.get(event.atom_name), event, event.atom_name
72
+ )
73
+ return replace(flow, atoms=atoms)
74
+
75
+ return flow
76
+
77
+
78
+ def fold_atom(
79
+ atom: AtomSnapshot | None, event: Event, name: str
80
+ ) -> AtomSnapshot:
81
+ from dataclasses import replace
82
+
83
+ if atom is None:
84
+ atom = AtomSnapshot(name=name)
85
+
86
+ changes: dict[str, Any] = {}
87
+ if event.atom_uuid is not None:
88
+ changes["uuid"] = event.atom_uuid
89
+ if event.atom_type is not None:
90
+ changes["atom_type"] = event.atom_type
91
+ if event.intention is not None:
92
+ changes["intention"] = event.intention
93
+ if event.progress is not None:
94
+ changes["progress"] = event.progress
95
+
96
+ if event.kind is EventKind.ATOM_STATE:
97
+ changes["state"] = event.state
98
+ failure = event.details.get("failure")
99
+ if failure is not None:
100
+ changes["failure"] = failure
101
+ revert_failure = event.details.get("revert_failure")
102
+ if revert_failure is not None:
103
+ changes["revert_failure"] = revert_failure
104
+ if event.details.get("has_result"):
105
+ changes["has_result"] = True
106
+
107
+ if event.kind is EventKind.ATOM_PROGRESS:
108
+ changes["progress_details"] = event.details.get("progress_details")
109
+
110
+ return replace(atom, **changes)
111
+
112
+
113
+ def contiguous_from(
114
+ events: list[Event], expected: int, limit: int
115
+ ) -> list[Event]:
116
+ """Take the unbroken run of events starting at ``expected``.
117
+
118
+ Concurrent producers do not arrive in sequence order: two threads
119
+ each allocate a number and then hand it on, and the hand-offs can
120
+ invert. Returning event 8 while 7 is still in flight would advance
121
+ a caller past 7 for good, so anything beyond a gap waits.
122
+ """
123
+ taken: list[Event] = []
124
+ for event in events:
125
+ if event.seq < expected:
126
+ continue
127
+ if event.seq != expected:
128
+ break
129
+ taken.append(event)
130
+ expected += 1
131
+ if len(taken) >= limit:
132
+ break
133
+ return taken
134
+
135
+
136
+ def _optional_str(value: Any) -> str | None:
137
+ return None if value is None else str(value)