percival 0.1.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.
- percival/__init__.py +67 -0
- percival/beat.py +91 -0
- percival/cli.py +78 -0
- percival/config.py +102 -0
- percival/dedupe.py +183 -0
- percival/events.py +117 -0
- percival/gate.py +150 -0
- percival/heartbeat.py +124 -0
- percival/importer.py +226 -0
- percival/metadata.py +111 -0
- percival/naming.py +55 -0
- percival/py.typed +0 -0
- percival/redaction.py +93 -0
- percival/runtime.py +85 -0
- percival/schedules.py +172 -0
- percival/signals.py +170 -0
- percival-0.1.0.dist-info/METADATA +107 -0
- percival-0.1.0.dist-info/RECORD +20 -0
- percival-0.1.0.dist-info/WHEEL +4 -0
- percival-0.1.0.dist-info/entry_points.txt +2 -0
percival/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Client side of Percival. Installed in the Celery project, not in the panel.
|
|
2
|
+
|
|
3
|
+
import percival
|
|
4
|
+
percival.init(app, redis_url="redis://...", code_version=os.environ["GIT_SHA"])
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from percival.config import UNKNOWN_VERSION, Settings
|
|
15
|
+
from percival.dedupe import once_per
|
|
16
|
+
from percival.runtime import ClientFactory, Runtime
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
__all__ = ["Settings", "__version__", "init", "once_per", "shutdown"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def init(
|
|
24
|
+
app: Any,
|
|
25
|
+
redis_url: str | None = None,
|
|
26
|
+
code_version: str | None = None,
|
|
27
|
+
*,
|
|
28
|
+
client_factory: ClientFactory | None = None,
|
|
29
|
+
**options: Any,
|
|
30
|
+
) -> Runtime:
|
|
31
|
+
"""Wire percival into a celery app. Safe to call more than once.
|
|
32
|
+
|
|
33
|
+
Any field of :class:`percival.Settings` can be passed as a keyword, e.g.
|
|
34
|
+
``redact_kwargs=[...]``, ``stale_action="drop"``, ``capture_args=False``.
|
|
35
|
+
``client_factory`` exists for tests, you shouldn't need it.
|
|
36
|
+
"""
|
|
37
|
+
# imported here so `import percival` stays cheap for things like the CLI
|
|
38
|
+
from percival import gate, heartbeat, metadata, runtime, signals
|
|
39
|
+
|
|
40
|
+
settings = Settings.build(redis_url=redis_url, code_version=code_version, **options)
|
|
41
|
+
if settings.code_version == UNKNOWN_VERSION:
|
|
42
|
+
log.warning(
|
|
43
|
+
"percival: no code_version set. pass code_version= or set PERCIVAL_CODE_VERSION, "
|
|
44
|
+
"otherwise schedules that require a version will be blocked on this worker"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
rt = runtime.configure(settings, client_factory=client_factory)
|
|
48
|
+
signals.connect()
|
|
49
|
+
heartbeat.connect()
|
|
50
|
+
metadata.connect()
|
|
51
|
+
gate.connect()
|
|
52
|
+
|
|
53
|
+
# if the worker is already up (init called late), gate the tasks now
|
|
54
|
+
if getattr(app, "finalized", False):
|
|
55
|
+
gate.install(app)
|
|
56
|
+
return rt
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def shutdown() -> None:
|
|
60
|
+
"""Undo init(). Mostly useful in tests."""
|
|
61
|
+
from percival import gate, heartbeat, metadata, runtime, signals
|
|
62
|
+
|
|
63
|
+
signals.disconnect()
|
|
64
|
+
heartbeat.disconnect()
|
|
65
|
+
metadata.disconnect()
|
|
66
|
+
gate.disconnect()
|
|
67
|
+
runtime.reset()
|
percival/beat.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""RedBeat scheduler with per-schedule timezones and a heartbeat.
|
|
2
|
+
|
|
3
|
+
celery -A proj beat -S percival.beat.Scheduler
|
|
4
|
+
|
|
5
|
+
It's stock RedBeat underneath. Entries stay in RedBeat's format, so plain
|
|
6
|
+
``redbeat.RedBeatScheduler`` still reads them (crontabs just fall back to the
|
|
7
|
+
app timezone). What this adds:
|
|
8
|
+
|
|
9
|
+
- crontabs with a ``percival_timezone`` header are evaluated in that zone
|
|
10
|
+
- a heartbeat key so the panel knows beat is alive, when it last ticked and
|
|
11
|
+
who holds the lock
|
|
12
|
+
- a shorter default loop interval, so a newly created schedule is picked up
|
|
13
|
+
in seconds instead of up to 5 minutes
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import socket
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from celery.schedules import crontab
|
|
26
|
+
from redbeat.schedulers import RedBeatScheduler, RedBeatSchedulerEntry
|
|
27
|
+
|
|
28
|
+
from percival import __version__, runtime
|
|
29
|
+
from percival.naming import Headers
|
|
30
|
+
from percival.schedules import zoned_crontab
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
DEFAULT_MAX_INTERVAL = 15
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def apply_zone(sched: Any, options: dict[str, Any] | None, app: Any = None) -> Any:
|
|
38
|
+
headers = (options or {}).get("headers") or {}
|
|
39
|
+
zone = headers.get(Headers.TIMEZONE)
|
|
40
|
+
if not zone or not isinstance(sched, crontab) or isinstance(sched, zoned_crontab):
|
|
41
|
+
return sched
|
|
42
|
+
try:
|
|
43
|
+
return zoned_crontab.from_crontab(sched, zone, app=app)
|
|
44
|
+
except Exception:
|
|
45
|
+
log.warning("percival: unknown timezone %r, using the app timezone", zone)
|
|
46
|
+
return sched
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Entry(RedBeatSchedulerEntry): # type: ignore[misc]
|
|
50
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
51
|
+
super().__init__(*args, **kwargs)
|
|
52
|
+
self.schedule = apply_zone(self.schedule, self.options, self.app) # type: ignore[has-type]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Scheduler(RedBeatScheduler): # type: ignore[misc]
|
|
56
|
+
Entry = Entry
|
|
57
|
+
|
|
58
|
+
def __init__(self, app: Any, *args: Any, **kwargs: Any) -> None:
|
|
59
|
+
if kwargs.get("max_interval") is None and not app.conf.beat_max_loop_interval:
|
|
60
|
+
kwargs["max_interval"] = DEFAULT_MAX_INTERVAL
|
|
61
|
+
super().__init__(app, *args, **kwargs)
|
|
62
|
+
self._started_at = time.time()
|
|
63
|
+
|
|
64
|
+
def tick(self, *args: Any, **kwargs: Any) -> Any:
|
|
65
|
+
result = super().tick(*args, **kwargs)
|
|
66
|
+
self._heartbeat()
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
def _heartbeat(self) -> None:
|
|
70
|
+
rt = runtime.get_optional()
|
|
71
|
+
if rt is None:
|
|
72
|
+
return
|
|
73
|
+
payload = {
|
|
74
|
+
"hostname": socket.gethostname(),
|
|
75
|
+
"pid": os.getpid(),
|
|
76
|
+
"scheduler": f"{type(self).__module__}.{type(self).__name__}",
|
|
77
|
+
"started_at": self._started_at,
|
|
78
|
+
"last_tick": time.time(),
|
|
79
|
+
"max_interval": self.max_interval,
|
|
80
|
+
"lock_key": self.lock_key,
|
|
81
|
+
"code_version": rt.settings.code_version,
|
|
82
|
+
"client_version": __version__,
|
|
83
|
+
}
|
|
84
|
+
try:
|
|
85
|
+
rt.redis.get().set(
|
|
86
|
+
rt.keys.beat,
|
|
87
|
+
json.dumps(payload),
|
|
88
|
+
ex=int(max(self.max_interval * 3, 60)),
|
|
89
|
+
)
|
|
90
|
+
except Exception:
|
|
91
|
+
log.debug("percival: beat heartbeat failed", exc_info=True)
|
percival/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``percival`` command line. Only ``import`` for now."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from percival.naming import NAME
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_app(spec: str) -> Any:
|
|
15
|
+
"""``proj.celery:app``, or just ``proj.celery`` if the app is called app/celery."""
|
|
16
|
+
# same as celery -A: make the current directory importable
|
|
17
|
+
if os.getcwd() not in sys.path:
|
|
18
|
+
sys.path.insert(0, os.getcwd())
|
|
19
|
+
|
|
20
|
+
module_name, _, attr = spec.partition(":")
|
|
21
|
+
module = importlib.import_module(module_name)
|
|
22
|
+
if attr:
|
|
23
|
+
return getattr(module, attr)
|
|
24
|
+
for candidate in ("app", "celery"):
|
|
25
|
+
if hasattr(module, candidate):
|
|
26
|
+
return getattr(module, candidate)
|
|
27
|
+
raise SystemExit(f"couldn't find a celery app in {module_name}, try {module_name}:app")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_import(args: argparse.Namespace) -> int:
|
|
31
|
+
from percival import importer
|
|
32
|
+
|
|
33
|
+
app = load_app(args.app)
|
|
34
|
+
importer.configure_redbeat(app, args.redis_url, args.key_prefix)
|
|
35
|
+
planned = importer.plan(app, require_version=args.require_version, only=args.only or None)
|
|
36
|
+
print(importer.render(planned))
|
|
37
|
+
|
|
38
|
+
if not args.apply:
|
|
39
|
+
if any(p.action in (importer.CREATE, importer.UPDATE) for p in planned):
|
|
40
|
+
print("\ndry run, nothing written. run again with --apply to write these.")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
written = importer.apply(app, planned)
|
|
44
|
+
print(f"\nwrote {written} entries to redbeat.")
|
|
45
|
+
if written:
|
|
46
|
+
print(
|
|
47
|
+
"note: beat re-syncs beat_schedule from config on every start, so remove the\n"
|
|
48
|
+
"imported entries from your config or they will overwrite changes made in the panel."
|
|
49
|
+
)
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = argparse.ArgumentParser(prog=NAME)
|
|
55
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
imp = sub.add_parser("import", help="copy beat_schedule from your app into redbeat")
|
|
58
|
+
imp.add_argument("--app", required=True, help="celery app, e.g. proj.celery:app")
|
|
59
|
+
imp.add_argument("--redis-url", help="redis used by redbeat (default: redbeat_redis_url)")
|
|
60
|
+
imp.add_argument("--key-prefix", help="redbeat_key_prefix if you changed it")
|
|
61
|
+
imp.add_argument(
|
|
62
|
+
"--require-version",
|
|
63
|
+
help="set the required code version on every imported entry",
|
|
64
|
+
)
|
|
65
|
+
imp.add_argument("--only", nargs="+", metavar="NAME", help="import just these entries")
|
|
66
|
+
imp.add_argument("--apply", action="store_true", help="actually write (default is dry run)")
|
|
67
|
+
imp.set_defaults(func=cmd_import)
|
|
68
|
+
return parser
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main(argv: list[str] | None = None) -> int:
|
|
72
|
+
args = build_parser().parse_args(argv)
|
|
73
|
+
result: int = args.func(args)
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|
percival/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from percival.naming import ENV_PREFIX, NAME
|
|
9
|
+
|
|
10
|
+
DEFAULT_REDACT = (
|
|
11
|
+
"password",
|
|
12
|
+
"passwd",
|
|
13
|
+
"secret",
|
|
14
|
+
"token",
|
|
15
|
+
"api_key",
|
|
16
|
+
"apikey",
|
|
17
|
+
"authorization",
|
|
18
|
+
"cookie",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
UNKNOWN_VERSION = "unknown"
|
|
22
|
+
|
|
23
|
+
StaleAction = Literal["requeue", "drop"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _env(name: str) -> str | None:
|
|
27
|
+
value = os.environ.get(ENV_PREFIX + name)
|
|
28
|
+
return value if value else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _env_bool(name: str) -> bool | None:
|
|
32
|
+
value = _env(name)
|
|
33
|
+
if value is None:
|
|
34
|
+
return None
|
|
35
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Settings:
|
|
40
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
41
|
+
code_version: str = UNKNOWN_VERSION
|
|
42
|
+
key_prefix: str = NAME
|
|
43
|
+
|
|
44
|
+
# what goes into run events
|
|
45
|
+
capture_args: bool = True
|
|
46
|
+
redact_kwargs: tuple[str, ...] = DEFAULT_REDACT
|
|
47
|
+
max_value_chars: int = 2_000
|
|
48
|
+
max_traceback_chars: int = 20_000
|
|
49
|
+
|
|
50
|
+
# what the stale-worker gate does with a blocked message
|
|
51
|
+
stale_action: StaleAction = "requeue"
|
|
52
|
+
requeue_delay: float = 5.0
|
|
53
|
+
max_requeues: int = 50
|
|
54
|
+
|
|
55
|
+
heartbeat_interval: float = 30.0
|
|
56
|
+
heartbeat_ttl: int = 90
|
|
57
|
+
|
|
58
|
+
events_maxlen: int = 100_000
|
|
59
|
+
publish_source: bool = True
|
|
60
|
+
source_max_lines: int = 300
|
|
61
|
+
|
|
62
|
+
# once_per behaviour when redis is down; False means skip the run
|
|
63
|
+
dedupe_fail_open: bool = False
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def build(cls, **overrides: Any) -> Settings:
|
|
67
|
+
"""Explicit arguments win, then PERCIVAL_* env vars, then defaults."""
|
|
68
|
+
values: dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
env_map: dict[str, Any] = {
|
|
71
|
+
"redis_url": _env("REDIS_URL"),
|
|
72
|
+
"code_version": _env("CODE_VERSION"),
|
|
73
|
+
"key_prefix": _env("KEY_PREFIX"),
|
|
74
|
+
"capture_args": _env_bool("CAPTURE_ARGS"),
|
|
75
|
+
"dedupe_fail_open": _env_bool("DEDUPE_FAIL_OPEN"),
|
|
76
|
+
"stale_action": _env("STALE_ACTION"),
|
|
77
|
+
}
|
|
78
|
+
values.update({k: v for k, v in env_map.items() if v is not None})
|
|
79
|
+
values.update({k: v for k, v in overrides.items() if v is not None})
|
|
80
|
+
|
|
81
|
+
redact = values.get("redact_kwargs")
|
|
82
|
+
if redact is not None and not isinstance(redact, tuple):
|
|
83
|
+
values["redact_kwargs"] = _as_tuple(redact)
|
|
84
|
+
|
|
85
|
+
settings = cls(**values)
|
|
86
|
+
settings.validate()
|
|
87
|
+
return settings
|
|
88
|
+
|
|
89
|
+
def validate(self) -> None:
|
|
90
|
+
if self.stale_action not in ("requeue", "drop"):
|
|
91
|
+
raise ValueError(f"stale_action must be 'requeue' or 'drop', got {self.stale_action!r}")
|
|
92
|
+
if self.heartbeat_ttl <= self.heartbeat_interval:
|
|
93
|
+
raise ValueError("heartbeat_ttl has to be larger than heartbeat_interval")
|
|
94
|
+
|
|
95
|
+
def with_(self, **changes: Any) -> Settings:
|
|
96
|
+
return replace(self, **changes)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _as_tuple(items: Iterable[str] | str) -> tuple[str, ...]:
|
|
100
|
+
if isinstance(items, str):
|
|
101
|
+
return tuple(s.strip() for s in items.split(",") if s.strip())
|
|
102
|
+
return tuple(items)
|
percival/dedupe.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""``@once_per``: run a task at most once per key per time window.
|
|
2
|
+
|
|
3
|
+
@app.task
|
|
4
|
+
@once_per(key=lambda user_id, **kw: user_id, window="1d")
|
|
5
|
+
def send_digest(user_id): ...
|
|
6
|
+
|
|
7
|
+
Backed by ``SET NX EX`` in redis. Fixed windows ("5m", "1h", "1d") are
|
|
8
|
+
aligned to the unix epoch, so "1d" means one UTC calendar day. For anything
|
|
9
|
+
else, like the recipient's local date, pass a callable as ``window`` and it
|
|
10
|
+
becomes the bucket.
|
|
11
|
+
|
|
12
|
+
If the task raises (including celery's Retry) the key is released again,
|
|
13
|
+
otherwise a failed attempt would block its own retry for the whole window.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import functools
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from types import SimpleNamespace
|
|
24
|
+
from typing import Any, TypeVar
|
|
25
|
+
|
|
26
|
+
from celery import Task, current_task
|
|
27
|
+
from celery.exceptions import Ignore
|
|
28
|
+
|
|
29
|
+
from percival import runtime
|
|
30
|
+
from percival.events import Status, build_event, emit
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
35
|
+
|
|
36
|
+
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
37
|
+
_WINDOW_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$")
|
|
38
|
+
|
|
39
|
+
# ttl for callable windows when none is given, generous on purpose
|
|
40
|
+
DEFAULT_CALLABLE_TTL = 2 * 86400
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_window(window: str) -> int:
|
|
44
|
+
match = _WINDOW_RE.match(window)
|
|
45
|
+
if not match:
|
|
46
|
+
raise ValueError(f"can't parse window {window!r}, use something like '5m', '1h', '1d'")
|
|
47
|
+
seconds = int(match.group(1)) * _UNITS[match.group(2)]
|
|
48
|
+
if seconds <= 0:
|
|
49
|
+
raise ValueError("window has to be longer than zero")
|
|
50
|
+
return seconds
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def once_per(
|
|
54
|
+
key: Callable[..., Any] | None = None,
|
|
55
|
+
window: str | Callable[..., Any] = "1d",
|
|
56
|
+
*,
|
|
57
|
+
name: str | None = None,
|
|
58
|
+
ttl: int | None = None,
|
|
59
|
+
fail_open: bool | None = None,
|
|
60
|
+
release_on_error: bool = True,
|
|
61
|
+
) -> Callable[[F], F]:
|
|
62
|
+
# validate up front so a typo fails at import, not at 3am
|
|
63
|
+
window_seconds = parse_window(window) if isinstance(window, str) else None
|
|
64
|
+
if window_seconds is None and not callable(window):
|
|
65
|
+
raise TypeError("window must be a string like '1d' or a callable")
|
|
66
|
+
|
|
67
|
+
def decorator(fn: F) -> F:
|
|
68
|
+
fallback_name = f"{fn.__module__}.{fn.__qualname__}"
|
|
69
|
+
|
|
70
|
+
@functools.wraps(fn)
|
|
71
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
72
|
+
rt = runtime.current()
|
|
73
|
+
task = _running_task()
|
|
74
|
+
task_name = name or (task.name if task is not None else fallback_name)
|
|
75
|
+
|
|
76
|
+
# bind=True tasks get self first, the key function shouldn't see it
|
|
77
|
+
user_args = args[1:] if args and isinstance(args[0], Task) else args
|
|
78
|
+
key_value = str(key(*user_args, **kwargs)) if key is not None else "_"
|
|
79
|
+
|
|
80
|
+
if window_seconds is not None:
|
|
81
|
+
bucket = str(int(time.time() // window_seconds))
|
|
82
|
+
expire = ttl or window_seconds
|
|
83
|
+
else:
|
|
84
|
+
bucket = str(window(*user_args, **kwargs)) # type: ignore[operator]
|
|
85
|
+
expire = ttl or DEFAULT_CALLABLE_TTL
|
|
86
|
+
|
|
87
|
+
redis_key = rt.keys.once(task_name, key_value, bucket)
|
|
88
|
+
holder = getattr(getattr(task, "request", None), "id", None) or "direct"
|
|
89
|
+
should_fail_open = rt.settings.dedupe_fail_open if fail_open is None else fail_open
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
acquired = bool(rt.redis.get().set(redis_key, holder, nx=True, ex=expire))
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
if should_fail_open:
|
|
95
|
+
log.warning(
|
|
96
|
+
"percival: once_per can't reach redis, running %s anyway",
|
|
97
|
+
task_name,
|
|
98
|
+
exc_info=True,
|
|
99
|
+
)
|
|
100
|
+
return fn(*args, **kwargs)
|
|
101
|
+
log.error(
|
|
102
|
+
"percival: once_per can't reach redis, skipping %s", task_name, exc_info=True
|
|
103
|
+
)
|
|
104
|
+
_report(
|
|
105
|
+
rt,
|
|
106
|
+
Status.DEDUPE_UNAVAILABLE,
|
|
107
|
+
task,
|
|
108
|
+
task_name,
|
|
109
|
+
fn,
|
|
110
|
+
user_args,
|
|
111
|
+
kwargs,
|
|
112
|
+
redis_key,
|
|
113
|
+
exc_type=type(exc).__name__,
|
|
114
|
+
exc_message=str(exc),
|
|
115
|
+
)
|
|
116
|
+
return _skip(task)
|
|
117
|
+
|
|
118
|
+
if not acquired:
|
|
119
|
+
_report(
|
|
120
|
+
rt, Status.SKIPPED_DUPLICATE, task, task_name, fn, user_args, kwargs, redis_key
|
|
121
|
+
)
|
|
122
|
+
return _skip(task)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
return fn(*args, **kwargs)
|
|
126
|
+
except BaseException:
|
|
127
|
+
if release_on_error:
|
|
128
|
+
_release(rt, redis_key, holder)
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
return wrapper # type: ignore[return-value]
|
|
132
|
+
|
|
133
|
+
return decorator
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _running_task() -> Any:
|
|
137
|
+
task = current_task
|
|
138
|
+
if not task:
|
|
139
|
+
return None
|
|
140
|
+
request = getattr(task, "request", None)
|
|
141
|
+
if request is None or getattr(request, "called_directly", True):
|
|
142
|
+
return None
|
|
143
|
+
return task
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _skip(task: Any) -> None:
|
|
147
|
+
# inside a worker, Ignore keeps celery from also recording a "success"
|
|
148
|
+
if task is not None:
|
|
149
|
+
raise Ignore()
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _release(rt: runtime.Runtime, redis_key: str, holder: str) -> None:
|
|
154
|
+
try:
|
|
155
|
+
client = rt.redis.get()
|
|
156
|
+
# only delete it if it's still ours
|
|
157
|
+
current = client.get(redis_key)
|
|
158
|
+
if isinstance(current, bytes):
|
|
159
|
+
current = current.decode()
|
|
160
|
+
if current == holder:
|
|
161
|
+
client.delete(redis_key)
|
|
162
|
+
except Exception:
|
|
163
|
+
log.warning("percival: could not release once_per key %s", redis_key, exc_info=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _report(
|
|
167
|
+
rt: runtime.Runtime,
|
|
168
|
+
status: str,
|
|
169
|
+
task: Any,
|
|
170
|
+
task_name: str,
|
|
171
|
+
fn: Callable[..., Any],
|
|
172
|
+
args: Any,
|
|
173
|
+
kwargs: Any,
|
|
174
|
+
redis_key: str,
|
|
175
|
+
**extra: Any,
|
|
176
|
+
) -> None:
|
|
177
|
+
subject = task if task is not None else SimpleNamespace(name=task_name, request=None, run=fn)
|
|
178
|
+
emit(
|
|
179
|
+
rt,
|
|
180
|
+
build_event(
|
|
181
|
+
rt, status, task=subject, args=args, kwargs=kwargs, dedupe_key=redis_key, **extra
|
|
182
|
+
),
|
|
183
|
+
)
|
percival/events.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Run events, pushed to a Redis Stream that the panel consumes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from percival.naming import Headers
|
|
12
|
+
from percival.redaction import capture_args
|
|
13
|
+
from percival.runtime import Runtime
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Status:
|
|
19
|
+
STARTED = "started"
|
|
20
|
+
SUCCESS = "success"
|
|
21
|
+
FAILURE = "failure"
|
|
22
|
+
RETRY = "retry"
|
|
23
|
+
REVOKED = "revoked"
|
|
24
|
+
SKIPPED_DUPLICATE = "skipped_duplicate"
|
|
25
|
+
BLOCKED_STALE_WORKER = "blocked_stale_worker"
|
|
26
|
+
# once_per could not reach redis and fail_open is off, so the run was skipped
|
|
27
|
+
DEDUPE_UNAVAILABLE = "dedupe_unavailable"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def emit(rt: Runtime, event: dict[str, Any]) -> None:
|
|
31
|
+
"""Best effort. A broken redis must never take a task down with it."""
|
|
32
|
+
try:
|
|
33
|
+
payload = json.dumps(event, default=repr, separators=(",", ":"))
|
|
34
|
+
rt.redis.get().xadd(
|
|
35
|
+
rt.keys.events,
|
|
36
|
+
{"data": payload},
|
|
37
|
+
maxlen=rt.settings.events_maxlen,
|
|
38
|
+
approximate=True,
|
|
39
|
+
)
|
|
40
|
+
except Exception:
|
|
41
|
+
log.warning(
|
|
42
|
+
"percival: could not record %s event for %s",
|
|
43
|
+
event.get("status"),
|
|
44
|
+
event.get("task_id"),
|
|
45
|
+
exc_info=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_header(request: Any, name: str) -> Any:
|
|
50
|
+
"""Custom headers end up in different places depending on the message
|
|
51
|
+
protocol and celery version, so look in both."""
|
|
52
|
+
if request is None:
|
|
53
|
+
return None
|
|
54
|
+
headers = getattr(request, "headers", None)
|
|
55
|
+
if isinstance(headers, dict) and name in headers:
|
|
56
|
+
return headers[name]
|
|
57
|
+
return getattr(request, name, None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_event(
|
|
61
|
+
rt: Runtime,
|
|
62
|
+
status: str,
|
|
63
|
+
*,
|
|
64
|
+
task: Any,
|
|
65
|
+
request: Any = None,
|
|
66
|
+
args: Any = None,
|
|
67
|
+
kwargs: Any = None,
|
|
68
|
+
started_at: float | None = None,
|
|
69
|
+
**extra: Any,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
settings = rt.settings
|
|
72
|
+
now = time.time()
|
|
73
|
+
if request is None:
|
|
74
|
+
request = getattr(task, "request", None)
|
|
75
|
+
|
|
76
|
+
event: dict[str, Any] = {
|
|
77
|
+
"status": status,
|
|
78
|
+
"task_id": getattr(request, "id", None),
|
|
79
|
+
"task": getattr(task, "name", None),
|
|
80
|
+
"schedule": get_header(request, Headers.SCHEDULE),
|
|
81
|
+
"required_version": get_header(request, Headers.REQUIRED_VERSION),
|
|
82
|
+
"worker": getattr(request, "hostname", None) or rt.hostname or socket.gethostname(),
|
|
83
|
+
"code_version": settings.code_version,
|
|
84
|
+
"retries": getattr(request, "retries", None),
|
|
85
|
+
"queue": _queue_of(request),
|
|
86
|
+
"ts": now,
|
|
87
|
+
}
|
|
88
|
+
if started_at is not None:
|
|
89
|
+
event["started_at"] = started_at
|
|
90
|
+
if status != Status.STARTED:
|
|
91
|
+
event["finished_at"] = now
|
|
92
|
+
event["duration"] = round(now - started_at, 6)
|
|
93
|
+
|
|
94
|
+
if settings.capture_args:
|
|
95
|
+
if args is None and kwargs is None and request is not None:
|
|
96
|
+
args = getattr(request, "args", None)
|
|
97
|
+
kwargs = getattr(request, "kwargs", None)
|
|
98
|
+
event.update(
|
|
99
|
+
capture_args(
|
|
100
|
+
getattr(task, "run", None),
|
|
101
|
+
args,
|
|
102
|
+
kwargs,
|
|
103
|
+
settings.redact_kwargs,
|
|
104
|
+
settings.max_value_chars,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
event.update({k: v for k, v in extra.items() if v is not None})
|
|
109
|
+
return event
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _queue_of(request: Any) -> str | None:
|
|
113
|
+
info = getattr(request, "delivery_info", None)
|
|
114
|
+
if isinstance(info, dict):
|
|
115
|
+
queue = info.get("routing_key") or info.get("queue")
|
|
116
|
+
return str(queue) if queue else None
|
|
117
|
+
return None
|