keepup-tasks 0.5.0__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.
- keepup_tasks-0.5.0/PKG-INFO +22 -0
- keepup_tasks-0.5.0/README.md +10 -0
- keepup_tasks-0.5.0/keepup_tasks/__init__.py +31 -0
- keepup_tasks-0.5.0/keepup_tasks/plugin.py +197 -0
- keepup_tasks-0.5.0/keepup_tasks/scheduler.py +181 -0
- keepup_tasks-0.5.0/keepup_tasks/tables.py +28 -0
- keepup_tasks-0.5.0/keepup_tasks.egg-info/PKG-INFO +22 -0
- keepup_tasks-0.5.0/keepup_tasks.egg-info/SOURCES.txt +12 -0
- keepup_tasks-0.5.0/keepup_tasks.egg-info/dependency_links.txt +1 -0
- keepup_tasks-0.5.0/keepup_tasks.egg-info/entry_points.txt +2 -0
- keepup_tasks-0.5.0/keepup_tasks.egg-info/requires.txt +5 -0
- keepup_tasks-0.5.0/keepup_tasks.egg-info/top_level.txt +1 -0
- keepup_tasks-0.5.0/pyproject.toml +23 -0
- keepup_tasks-0.5.0/setup.cfg +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-tasks
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: The one scheduler, the declared jobs and the distributed locks they run under.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.4.0
|
|
8
|
+
Requires-Dist: keepup-db>=0.4.0
|
|
9
|
+
Requires-Dist: keepup-auth>=0.4.0
|
|
10
|
+
Requires-Dist: apscheduler<4,>=3.10
|
|
11
|
+
Requires-Dist: fastapi<1,>=0.128
|
|
12
|
+
|
|
13
|
+
# keepup-tasks
|
|
14
|
+
|
|
15
|
+
The one scheduler, the declared jobs and the distributed locks they run under.
|
|
16
|
+
|
|
17
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
18
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
19
|
+
this distribution carries one part of it. It is installed on its own
|
|
20
|
+
(`pip install keepup-tasks`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
21
|
+
and it declares itself to the kernel through its
|
|
22
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# keepup-tasks
|
|
2
|
+
|
|
3
|
+
The one scheduler, the declared jobs and the distributed locks they run under.
|
|
4
|
+
|
|
5
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
6
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
7
|
+
this distribution carries one part of it. It is installed on its own
|
|
8
|
+
(`pip install keepup-tasks`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
9
|
+
and it declares itself to the kernel through its
|
|
10
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Background tasks: the one scheduler, and the locks the work runs under.
|
|
2
|
+
|
|
3
|
+
This distribution provides `scheduler` and `locks` and declares one plugin,
|
|
4
|
+
`tasks`, in the `keepup.plugins` group. It depends on `keepup-admin` for the
|
|
5
|
+
constructor, on `keepup-auth` for the panel guard of its routes -- the temporary
|
|
6
|
+
edge the route bridge carries until `keepup-http` mounts what a plugin declares
|
|
7
|
+
(keepup-121) -- and on `keepup-db`, because the locks it offers write to a table.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from keepup_tasks.scheduler import (
|
|
11
|
+
JOB_DEFAULTS,
|
|
12
|
+
get_scheduler,
|
|
13
|
+
init_scheduler,
|
|
14
|
+
new_scheduler,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.5.0"
|
|
18
|
+
|
|
19
|
+
__all__ = ["JOB_DEFAULTS", "get_scheduler", "init_scheduler", "new_scheduler"]
|
|
20
|
+
|
|
21
|
+
#: What the entry point asks for, looked up on access: a deployment that only
|
|
22
|
+
#: wants the scheduler does not pay for the plugin's panel wiring.
|
|
23
|
+
_PLUGIN_NAMES = {"TasksPlugin", "SERVICE_SCHEDULER", "SERVICE_LOCKS"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def __getattr__(name):
|
|
27
|
+
if name in _PLUGIN_NAMES:
|
|
28
|
+
from keepup_tasks import plugin
|
|
29
|
+
|
|
30
|
+
return getattr(plugin, name)
|
|
31
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Background tasks as a capability: the one scheduler and the locks under it.
|
|
2
|
+
|
|
3
|
+
Task keepup-109. The scheduler used to be the kernel's (`keepup/scheduler.py`):
|
|
4
|
+
the application's lifespan created it before the plugins and started it after
|
|
5
|
+
them, so a deployment that wanted no background work paid for a scheduler, the
|
|
6
|
+
panel's "Background tasks" section listed whatever the kernel happened to start
|
|
7
|
+
(nothing), and every loop of the framework was an `asyncio.create_task` in that
|
|
8
|
+
same lifespan instead of a job somebody could see.
|
|
9
|
+
|
|
10
|
+
The capability owns it now:
|
|
11
|
+
|
|
12
|
+
* it starts the scheduler when the application comes up -- with the job defaults
|
|
13
|
+
the deployment named -- and shuts it down with the application;
|
|
14
|
+
* it publishes the `scheduler` service, and consumes the `jobs` contribution
|
|
15
|
+
(`(job_id, callable, trigger)`, the contract in doc/plugin_constructor.md), so
|
|
16
|
+
a capability declares its work instead of starting a loop of its own, and the
|
|
17
|
+
section shows every declared job with its next run;
|
|
18
|
+
* it publishes the `locks` service. The lock machinery is kernel infrastructure
|
|
19
|
+
-- the kernel's own plugin runtime and the capabilities that outlived it need
|
|
20
|
+
it -- and the capability is what offers it to everyone else.
|
|
21
|
+
|
|
22
|
+
The routes are declared in `keepup_tasks.scheduler` and registered by the
|
|
23
|
+
application until the transport that mounts what a plugin declares exists
|
|
24
|
+
(`keepup-http`, keepup-121).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
from typing import Any, List
|
|
29
|
+
|
|
30
|
+
from keepup.kernel.datasource import SERVICE_DATASOURCE
|
|
31
|
+
from keepup.kernel.descriptor import KIND_OPTIONAL, PluginDescriptor
|
|
32
|
+
from keepup.plugins.base import BasePlugin
|
|
33
|
+
from keepup_tasks.scheduler import (
|
|
34
|
+
close_scheduler,
|
|
35
|
+
init_scheduler,
|
|
36
|
+
jobs_payload,
|
|
37
|
+
restart_scheduler,
|
|
38
|
+
stats_payload,
|
|
39
|
+
trigger_job,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
__all__ = ["TasksPlugin", "SERVICE_LOCKS", "SERVICE_SCHEDULER"]
|
|
45
|
+
|
|
46
|
+
#: What another plugin asks for to have its work scheduled.
|
|
47
|
+
SERVICE_SCHEDULER = "scheduler"
|
|
48
|
+
|
|
49
|
+
#: What another plugin asks for to run its work once across the replicas.
|
|
50
|
+
SERVICE_LOCKS = "locks"
|
|
51
|
+
|
|
52
|
+
#: The metrics capability's service, asked for by name: the locks' own numbers
|
|
53
|
+
#: are published as a sample instead of being written into its table.
|
|
54
|
+
SERVICE_METRICS = "metrics"
|
|
55
|
+
|
|
56
|
+
#: The panel's section: the jobs the scheduler holds and the locks in force.
|
|
57
|
+
SECTION = {
|
|
58
|
+
"id": "background_tasks",
|
|
59
|
+
"name": "Background tasks",
|
|
60
|
+
"description": "Scheduled jobs and the distributed locks they hold",
|
|
61
|
+
"js": "/keepup-static/modules/js/background_tasks.js",
|
|
62
|
+
"css": "/keepup-static/modules/css/background_tasks.css",
|
|
63
|
+
"initFunction": "initBackgroundTasks",
|
|
64
|
+
"icon": "clock",
|
|
65
|
+
"version": "1.0.0",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TasksPlugin(BasePlugin):
|
|
70
|
+
"""The scheduler and the locks, and the section that shows both."""
|
|
71
|
+
|
|
72
|
+
descriptor = PluginDescriptor(
|
|
73
|
+
id="tasks",
|
|
74
|
+
name="Background tasks",
|
|
75
|
+
version="0.5.0",
|
|
76
|
+
distribution="keepup-tasks",
|
|
77
|
+
kind=KIND_OPTIONAL,
|
|
78
|
+
priority=80,
|
|
79
|
+
provides=(f"{SERVICE_SCHEDULER}>=1", f"{SERVICE_LOCKS}>=1"),
|
|
80
|
+
requires=("datasource>=1",),
|
|
81
|
+
contributions=("routes", "sections", "tables", "jobs"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __init__(self, config=None):
|
|
85
|
+
super().__init__("tasks", "Background tasks", config)
|
|
86
|
+
self.scheduler = None
|
|
87
|
+
|
|
88
|
+
def register(self, services):
|
|
89
|
+
"""Offer the locks and the scheduler.
|
|
90
|
+
|
|
91
|
+
The locks are published here because a plugin may ask for them while it
|
|
92
|
+
registers -- the runtime's own post-construct lock does -- and the
|
|
93
|
+
scheduler is published once it exists (``initialize``), since a
|
|
94
|
+
scheduler that is not started yet is not something to hand out.
|
|
95
|
+
"""
|
|
96
|
+
if services.has(SERVICE_DATASOURCE):
|
|
97
|
+
from keepup import locks
|
|
98
|
+
|
|
99
|
+
services.provide(SERVICE_LOCKS, locks, version=1, plugin_id="tasks")
|
|
100
|
+
# The locks' own numbers, when something is collecting them: the
|
|
101
|
+
# metrics capability is optional, and the lock machinery does not
|
|
102
|
+
# write into its table (keepup-109).
|
|
103
|
+
if services.has(SERVICE_METRICS):
|
|
104
|
+
services.require(SERVICE_METRICS).collect(locks.samples)
|
|
105
|
+
|
|
106
|
+
async def initialize(self):
|
|
107
|
+
"""Create the scheduler and start it; the declared jobs join at the freeze."""
|
|
108
|
+
settings = getattr(self.plugin_manager, "settings", None)
|
|
109
|
+
self.scheduler = init_scheduler(getattr(settings, "scheduler_job_defaults", None))
|
|
110
|
+
self.scheduler.start()
|
|
111
|
+
if self.services is not None:
|
|
112
|
+
self.services.provide(SERVICE_SCHEDULER, self.scheduler, version=1,
|
|
113
|
+
plugin_id="tasks")
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
async def post_construct(self):
|
|
117
|
+
"""Put the declared jobs in, now that every plugin has declared them.
|
|
118
|
+
|
|
119
|
+
Contributions are mounted after the plugins initialise (step 8 of the
|
|
120
|
+
lifecycle), so a job another capability declared is known here and not
|
|
121
|
+
in `initialize` -- and a running scheduler takes a job added to it.
|
|
122
|
+
"""
|
|
123
|
+
for job in self._declared_jobs():
|
|
124
|
+
self._put(job)
|
|
125
|
+
logger.info("The tasks capability holds %s job(s)",
|
|
126
|
+
len(self.scheduler.get_jobs()) if self.scheduler else 0)
|
|
127
|
+
|
|
128
|
+
def _declared_jobs(self) -> List[Any]:
|
|
129
|
+
"""Every job the other plugins declared, in the order they initialised.
|
|
130
|
+
|
|
131
|
+
Read here rather than collected by the kernel: the contribution is the
|
|
132
|
+
contract, and consuming it is this capability's work (keepup-109).
|
|
133
|
+
"""
|
|
134
|
+
contributions = getattr(self.plugin_manager, "contributions", None)
|
|
135
|
+
if contributions is None:
|
|
136
|
+
return []
|
|
137
|
+
return list(contributions.of("jobs"))
|
|
138
|
+
|
|
139
|
+
def _put(self, job) -> None:
|
|
140
|
+
"""One declared job: `(job_id, callable, trigger)`, intervals by default."""
|
|
141
|
+
job_id, work, trigger = job
|
|
142
|
+
self.scheduler.add_job(work, id=job_id, trigger="interval", **trigger)
|
|
143
|
+
|
|
144
|
+
async def cleanup(self):
|
|
145
|
+
"""Stop the scheduler with the application that started it."""
|
|
146
|
+
if self.scheduler is not None:
|
|
147
|
+
close_scheduler()
|
|
148
|
+
self.scheduler = None
|
|
149
|
+
|
|
150
|
+
def get_declared_tables(self) -> List[Any]:
|
|
151
|
+
"""The lock table: the service published here is the one that uses it.
|
|
152
|
+
|
|
153
|
+
Declared since keepup-158, when the 0.3.0 path that created it went
|
|
154
|
+
away. A deployment that assembles its runtime from plugins had no
|
|
155
|
+
`distributed_locks` table and failed on its first background sweep.
|
|
156
|
+
"""
|
|
157
|
+
from keepup_tasks.tables import DISTRIBUTED_LOCKS
|
|
158
|
+
|
|
159
|
+
return [DISTRIBUTED_LOCKS]
|
|
160
|
+
|
|
161
|
+
def get_api_routes(self):
|
|
162
|
+
"""The section's three doors, declared as data for the transport.
|
|
163
|
+
|
|
164
|
+
The application registers the same three through the bridge until
|
|
165
|
+
`keepup-http` mounts what a plugin declares (keepup-121); both answer
|
|
166
|
+
with the payload functions of `keepup_tasks.scheduler`, so there is one
|
|
167
|
+
implementation of each answer.
|
|
168
|
+
"""
|
|
169
|
+
return [
|
|
170
|
+
{"path": "/api/admin/scheduler/jobs", "methods": ["GET"],
|
|
171
|
+
"handler": self.jobs},
|
|
172
|
+
{"path": "/api/admin/scheduler/jobs/{job_id}/trigger", "methods": ["POST"],
|
|
173
|
+
"handler": self.trigger},
|
|
174
|
+
{"path": "/api/admin/scheduler/stats", "methods": ["GET"],
|
|
175
|
+
"handler": self.stats},
|
|
176
|
+
{"path": "/api/admin/scheduler/restart", "methods": ["POST"],
|
|
177
|
+
"handler": self.restart},
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
async def jobs(self, current_user=None):
|
|
181
|
+
return jobs_payload()
|
|
182
|
+
|
|
183
|
+
async def trigger(self, job_id: str, current_user=None):
|
|
184
|
+
return trigger_job(job_id)
|
|
185
|
+
|
|
186
|
+
async def stats(self, current_user=None):
|
|
187
|
+
return stats_payload()
|
|
188
|
+
|
|
189
|
+
async def restart(self, current_user=None):
|
|
190
|
+
return restart_scheduler()
|
|
191
|
+
|
|
192
|
+
def get_panel_sections(self):
|
|
193
|
+
"""The jobs and the locks, as the panel's section."""
|
|
194
|
+
return [dict(SECTION)]
|
|
195
|
+
|
|
196
|
+
def get_handlers(self):
|
|
197
|
+
return {"scheduler": self.scheduler}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""One scheduler for the whole process.
|
|
2
|
+
|
|
3
|
+
Plugins are meant to register their jobs against it rather than each starting
|
|
4
|
+
a scheduler of its own, which is what keeps the jobs visible in one place -- and
|
|
5
|
+
what lets an administrator see and trigger them. A plugin that does need its
|
|
6
|
+
own takes it from new_scheduler(), so it gets the same job defaults. Running the
|
|
7
|
+
same job in every replica is prevented one level up, by the distributed lock
|
|
8
|
+
around the job itself, not here.
|
|
9
|
+
|
|
10
|
+
The job defaults are what APScheduler leaves open and a replica set cannot
|
|
11
|
+
leave open (keepup-46):
|
|
12
|
+
|
|
13
|
+
- ``coalesce``: a job that missed several runs -- the process was paused, the
|
|
14
|
+
loop was busy, the machine slept -- runs once on waking, not once per missed
|
|
15
|
+
slot. Without it a sync job that fell an hour behind fires sixty times in a
|
|
16
|
+
row, on every replica that woke up.
|
|
17
|
+
- ``max_instances = 1``: a run that has not finished is not started again
|
|
18
|
+
beside itself. Two passes of the same job in one process would contend for
|
|
19
|
+
the same distributed lock and one of them would only log that it lost.
|
|
20
|
+
- ``misfire_grace_time``: how late a run may still start. APScheduler's default
|
|
21
|
+
is one second, so a loop held for longer than that silently drops the run.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, Dict, Optional
|
|
27
|
+
|
|
28
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
29
|
+
from fastapi import Depends, HTTPException
|
|
30
|
+
|
|
31
|
+
from keepup import clock
|
|
32
|
+
from keepup_auth.dependencies import get_current_admin
|
|
33
|
+
|
|
34
|
+
#: What an application may import from this module. Everything else is
|
|
35
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
36
|
+
__all__ = [
|
|
37
|
+
"JOB_DEFAULTS",
|
|
38
|
+
"init_scheduler",
|
|
39
|
+
"new_scheduler",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
#: The process-wide scheduler, created by init_scheduler().
|
|
45
|
+
scheduler = None
|
|
46
|
+
|
|
47
|
+
#: The job defaults the application named, kept so a restart starts the same way.
|
|
48
|
+
_job_defaults: Optional[Dict[str, Any]] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_scheduler():
|
|
52
|
+
"""Return the process-wide scheduler, or None before it is created."""
|
|
53
|
+
return scheduler
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: The job defaults every scheduler of the framework starts with; an
|
|
57
|
+
#: application overrides them with ``KeepupSettings.scheduler_job_defaults``.
|
|
58
|
+
JOB_DEFAULTS = {
|
|
59
|
+
"coalesce": True,
|
|
60
|
+
"max_instances": 1,
|
|
61
|
+
"misfire_grace_time": 60,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def new_scheduler(job_defaults: Optional[Dict[str, Any]] = None) -> AsyncIOScheduler:
|
|
66
|
+
"""A scheduler with the framework's job defaults, overridden by ``job_defaults``."""
|
|
67
|
+
return AsyncIOScheduler(job_defaults={**JOB_DEFAULTS, **(job_defaults or {})})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def init_scheduler(job_defaults: Optional[Dict[str, Any]] = None):
|
|
71
|
+
"""Create and configure the task scheduler."""
|
|
72
|
+
global scheduler, _job_defaults
|
|
73
|
+
|
|
74
|
+
_job_defaults = job_defaults
|
|
75
|
+
scheduler = new_scheduler(job_defaults)
|
|
76
|
+
|
|
77
|
+
logger.info("Global scheduler initialized - tasks will be registered by plugins")
|
|
78
|
+
return scheduler
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def restart_scheduler() -> Dict[str, Any]:
|
|
82
|
+
"""Stop the scheduler and start a fresh one holding the same jobs.
|
|
83
|
+
|
|
84
|
+
What the panel's button asks for: the jobs are the contract, and the clock
|
|
85
|
+
that runs them is replaceable. A job is re-added by its function, id and
|
|
86
|
+
trigger, so nothing has to be declared twice (keepup-109).
|
|
87
|
+
"""
|
|
88
|
+
global scheduler
|
|
89
|
+
|
|
90
|
+
previous = scheduler
|
|
91
|
+
declared = []
|
|
92
|
+
if previous is not None:
|
|
93
|
+
declared = [{"func": job.func, "id": job.id, "name": job.name,
|
|
94
|
+
"trigger": job.trigger}
|
|
95
|
+
for job in previous.get_jobs()]
|
|
96
|
+
previous.shutdown(wait=False)
|
|
97
|
+
|
|
98
|
+
fresh = init_scheduler(_job_defaults)
|
|
99
|
+
for spec in declared:
|
|
100
|
+
fresh.add_job(spec["func"], id=spec["id"], name=spec["name"], trigger=spec["trigger"])
|
|
101
|
+
fresh.start()
|
|
102
|
+
logger.info("Scheduler restarted with %s job(s)", len(fresh.get_jobs()))
|
|
103
|
+
return {"restarted": True, "jobs": [job.id for job in fresh.get_jobs()]}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def close_scheduler() -> None:
|
|
107
|
+
"""Stop the process-wide scheduler and forget it.
|
|
108
|
+
|
|
109
|
+
Called by the tasks capability when its application goes down: the name is
|
|
110
|
+
the process's, and a scheduler that stopped is not one to answer a route
|
|
111
|
+
with (keepup-109).
|
|
112
|
+
"""
|
|
113
|
+
global scheduler
|
|
114
|
+
|
|
115
|
+
if scheduler is not None:
|
|
116
|
+
scheduler.shutdown()
|
|
117
|
+
scheduler = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def jobs_payload() -> Dict[str, Any]:
|
|
121
|
+
"""Every job the scheduler holds, as the panel's section reads them."""
|
|
122
|
+
if not scheduler:
|
|
123
|
+
return {"error": "Scheduler not initialized"}
|
|
124
|
+
return {"jobs": [{"id": job.id, "name": job.name,
|
|
125
|
+
"next_run_time": job.next_run_time,
|
|
126
|
+
"trigger": str(job.trigger)}
|
|
127
|
+
for job in scheduler.get_jobs()]}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def stats_payload() -> Dict[str, Any]:
|
|
131
|
+
"""How the scheduler is doing, as the panel's section reads it."""
|
|
132
|
+
if not scheduler:
|
|
133
|
+
return {"error": "Scheduler not initialized"}
|
|
134
|
+
jobs = scheduler.get_jobs()
|
|
135
|
+
next_run_times = [job.next_run_time for job in jobs if job.next_run_time]
|
|
136
|
+
return {
|
|
137
|
+
"scheduler_running": scheduler.running,
|
|
138
|
+
"total_jobs": len(jobs),
|
|
139
|
+
"next_wakeup": min(next_run_times) if next_run_times else None,
|
|
140
|
+
"pending_tasks": len(asyncio.all_tasks()) if asyncio.get_event_loop() else 0,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def trigger_job(job_id: str) -> Dict[str, Any]:
|
|
145
|
+
"""Run one job by hand, now; refuses a job nobody declared."""
|
|
146
|
+
if not scheduler:
|
|
147
|
+
raise HTTPException(status_code=500, detail="Scheduler not initialized")
|
|
148
|
+
job = scheduler.get_job(job_id)
|
|
149
|
+
if not job:
|
|
150
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
151
|
+
job.modify(next_run_time=clock.utcnow())
|
|
152
|
+
return {"success": True, "message": f"Job {job_id} triggered manually"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def register_scheduler_routes(app):
|
|
156
|
+
"""Register the scheduler endpoints on the application.
|
|
157
|
+
|
|
158
|
+
The application's bridge until the transport mounts what a plugin declares
|
|
159
|
+
(keepup-121); the plugin contributes the same three routes as data, and both
|
|
160
|
+
answer with the payload functions above.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
@app.get("/api/admin/scheduler/jobs")
|
|
164
|
+
async def get_scheduler_jobs(admin: dict = Depends(get_current_admin)):
|
|
165
|
+
"""Return the scheduler's jobs (administrators only)."""
|
|
166
|
+
return jobs_payload()
|
|
167
|
+
|
|
168
|
+
@app.post("/api/admin/scheduler/jobs/{job_id}/trigger")
|
|
169
|
+
async def trigger_scheduler_job(job_id: str, admin: dict = Depends(get_current_admin)):
|
|
170
|
+
"""Run a scheduler job by hand (administrators only)."""
|
|
171
|
+
return trigger_job(job_id)
|
|
172
|
+
|
|
173
|
+
@app.get("/api/admin/scheduler/stats")
|
|
174
|
+
async def get_scheduler_stats(admin: dict = Depends(get_current_admin)):
|
|
175
|
+
"""Return scheduler statistics (administrators only)."""
|
|
176
|
+
return stats_payload()
|
|
177
|
+
|
|
178
|
+
@app.post("/api/admin/scheduler/restart")
|
|
179
|
+
async def restart_the_scheduler(admin: dict = Depends(get_current_admin)):
|
|
180
|
+
"""Stop the scheduler and start a fresh one with the same jobs."""
|
|
181
|
+
return restart_scheduler()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The table the locks service keeps its rows in.
|
|
2
|
+
|
|
3
|
+
Task keepup-158. The declaration used to live in `keepup_db.schema`, on the
|
|
4
|
+
0.3.0 path an application called by hand -- which meant a runtime assembled from
|
|
5
|
+
plugins, the way the kernel does it, created no `distributed_locks` table at all:
|
|
6
|
+
a project built that way served its panel and then reported `no such table:
|
|
7
|
+
distributed_locks` on every background sweep.
|
|
8
|
+
|
|
9
|
+
The table belongs to the capability that provides the service that uses it.
|
|
10
|
+
`keepup.locks` reaches it with plain SQL, so the declaration here is the only
|
|
11
|
+
thing that has to exist.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import Column, DateTime, Text
|
|
15
|
+
|
|
16
|
+
from keepup_db import tables
|
|
17
|
+
|
|
18
|
+
__all__ = ["DISTRIBUTED_LOCKS"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DISTRIBUTED_LOCKS = tables.table(
|
|
22
|
+
"distributed_locks",
|
|
23
|
+
# A text key written as ``TEXT PRIMARY KEY``: SQLite leaves such a column
|
|
24
|
+
# nullable, and the declaration keeps it that way.
|
|
25
|
+
Column("lock_name", Text, primary_key=True, nullable=True),
|
|
26
|
+
Column("acquired_at", DateTime, nullable=False),
|
|
27
|
+
Column("instance_id", Text, nullable=False),
|
|
28
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-tasks
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: The one scheduler, the declared jobs and the distributed locks they run under.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.4.0
|
|
8
|
+
Requires-Dist: keepup-db>=0.4.0
|
|
9
|
+
Requires-Dist: keepup-auth>=0.4.0
|
|
10
|
+
Requires-Dist: apscheduler<4,>=3.10
|
|
11
|
+
Requires-Dist: fastapi<1,>=0.128
|
|
12
|
+
|
|
13
|
+
# keepup-tasks
|
|
14
|
+
|
|
15
|
+
The one scheduler, the declared jobs and the distributed locks they run under.
|
|
16
|
+
|
|
17
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
18
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
19
|
+
this distribution carries one part of it. It is installed on its own
|
|
20
|
+
(`pip install keepup-tasks`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
21
|
+
and it declares itself to the kernel through its
|
|
22
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
keepup_tasks/__init__.py
|
|
4
|
+
keepup_tasks/plugin.py
|
|
5
|
+
keepup_tasks/scheduler.py
|
|
6
|
+
keepup_tasks/tables.py
|
|
7
|
+
keepup_tasks.egg-info/PKG-INFO
|
|
8
|
+
keepup_tasks.egg-info/SOURCES.txt
|
|
9
|
+
keepup_tasks.egg-info/dependency_links.txt
|
|
10
|
+
keepup_tasks.egg-info/entry_points.txt
|
|
11
|
+
keepup_tasks.egg-info/requires.txt
|
|
12
|
+
keepup_tasks.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keepup_tasks
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keepup-tasks"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "The one scheduler, the declared jobs and the distributed locks they run under."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"keepup-admin>=0.4.0",
|
|
13
|
+
"keepup-db>=0.4.0",
|
|
14
|
+
"keepup-auth>=0.4.0",
|
|
15
|
+
"apscheduler>=3.10,<4",
|
|
16
|
+
"fastapi>=0.128,<1",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.entry-points."keepup.plugins"]
|
|
20
|
+
tasks = "keepup_tasks:TasksPlugin"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
packages = ["keepup_tasks"]
|