xschedy 0.1.1__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.

Potentially problematic release.


This version of xschedy might be problematic. Click here for more details.

@@ -0,0 +1,25 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Runtime artifacts
13
+ *.db
14
+ logs/
15
+
16
+ # 审查过程产物(非交付物):对照项目的取证记录(matrix/*.md)与一次性工具脚本。
17
+ # 内容有参考价值(design 2.3 对比矩阵的原文出处),但不属于要发布/部署的东西。
18
+ .superpowers/
19
+
20
+ # 打包快照
21
+ *.rar
22
+
23
+ # 控制面容器 bind 出来的作业目录(日志在 deploy/work/logs)
24
+ deploy/xschedy/
25
+ deploy/xschedy-worker/
xschedy-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zxpshadow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
xschedy-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,607 @@
1
+ Metadata-Version: 2.5
2
+ Name: xschedy
3
+ Version: 0.1.1
4
+ Summary: 内部系统的异步任务调度器
5
+ Author: zxpshadow
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: celery,distributed,heartbeat,scheduler,task-queue,timeout
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Classifier: Topic :: System :: Distributed Computing
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: boto3>=1.34
18
+ Requires-Dist: celery==5.7.0
19
+ Requires-Dist: loguru>=0.7
20
+ Requires-Dist: orjson>=3.12.0
21
+ Requires-Dist: pydantic>=2.0
22
+ Requires-Dist: redis>=6.2
23
+ Provides-Extra: client
24
+ Requires-Dist: httpx>=0.27; extra == 'client'
25
+ Provides-Extra: server
26
+ Requires-Dist: croniter>=2.0; extra == 'server'
27
+ Requires-Dist: cryptography>=42.0; extra == 'server'
28
+ Requires-Dist: fastapi>=0.111; extra == 'server'
29
+ Requires-Dist: httpx>=0.27; extra == 'server'
30
+ Requires-Dist: prometheus-client>=0.20; extra == 'server'
31
+ Requires-Dist: psycopg[binary]>=3.2; extra == 'server'
32
+ Requires-Dist: sqlalchemy>=2.0; extra == 'server'
33
+ Requires-Dist: uvicorn[standard]>=0.30; extra == 'server'
34
+ Provides-Extra: worker
35
+ Requires-Dist: httpx>=0.27; extra == 'worker'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # Xschedy
39
+
40
+ 异步任务调度器,构建在 Celery 之上(复用其 events / result / control 能力)。
41
+
42
+ ## 安装
43
+
44
+ ```bash
45
+ pip install xschedy # 核心:celery / redis / loguru / boto3 / pydantic
46
+ pip install xschedy[worker] # 执行面:默认安装 + httpx(worker 内可发子任务)
47
+ pip install xschedy[client] # 调用方 SDK:只依赖 httpx
48
+ ```
49
+
50
+ ## 运行
51
+
52
+ ```bash
53
+ # 前置:RabbitMQ + Redis(部署前提:rabbitmq.conf 调大 consumer_timeout)
54
+
55
+ # 普通
56
+ xschedy worker -Q my_queue --tasks myproject.tasks # --tasks:启动时加载的任务模块(逗号分隔)
57
+
58
+ # 分片监听(下发方传了 shard_key 时用;队列名是固定的 {queue}.shard.{N})
59
+ xschedy worker -Q my_queue.shard.0,my_queue.shard.1 --tasks myproject.tasks
60
+ ```
61
+
62
+ **`--tasks` 实际上是必填的**:不传**不会报错**(只打一条 WARNING),但**一个业务任务都不会注册**——
63
+ worker 看着好好的,下发的任务却全被 **422 `UNKNOWN_TASK`** 拒(注册表里没有这个任务名)。逗号分隔可传多个模块。
64
+
65
+ **同机跑多个 worker 必须给互不相同的 `--name`**:
66
+
67
+ ```bash
68
+ xschedy worker -n worker1 -Q q1 --tasks myproject.tasks
69
+ xschedy worker -n worker2 -Q q2 --tasks myproject.tasks
70
+ ```
71
+
72
+ 不传 `--name` 时节点名是 `celery@<本机 hostname>`,两个进程都这么起就**是同一个 worker**:
73
+ 一方正常退出会把**另一个**名下在途任务的 `error` 标成 `worker_lost`(它拿到的只有短名,区分不出是哪个进程);
74
+ restart / shutdown / pool_grow 这类远程控制会**同时**落到两个进程上、没法只操作其中一个;面板上两条时间线也会并成一条。
75
+
76
+ > **注意:`--name` 里别写 `@`,也别写 `.`**——worker 标识取的是 `@` **右边**、第一个 `.` **之前**的那一段:
77
+ > `-n worker1` → `worker1`(对);`-n w1@host1` → `host1`(**`w1` 被丢掉**,与 `-n w2@host1` 撞成同一个);
78
+ > `-n worker1@10.0.0.5` → `10`;`-n w1@%h` 会被 Celery 展开成 `w1@<本机 hostname>`,**和不传 `--name` 等价**。
79
+
80
+ > **分片(`shard_key`)**:下发时带 `shard_key` 的任务,框架按 `md5(key) % 分片数` 把它投到
81
+ > `{queue}.shard.{N}`,让**同一个 key 始终落在同一个分片队列**——买的是顺序性、本地缓存命中。
82
+ > worker 侧**只要按这个命名监听**就行:监听了哪些分片,框架就从队列名里读出来登记(上面的
83
+ > `-Q` 就是 2 个分片)。
84
+ >
85
+ > - **没监听任何分片队列时,带 `shard_key` 的任务会退化成走普通队列**(并打一条 WARNING);
86
+ > - **分片数一变(扩缩容),几乎所有 key 的目的分片都会变**(`% N` 的固有性质,不是可优化项)
87
+ > ——「同 key 同分片」这个保证**只在该分片数下成立**,别在有顺序性诉求的 key 上频繁扩缩容。
88
+ >
89
+ > 不关心顺序性就别配 `shard_key`,走普通队列即可。
90
+
91
+ ## 写业务任务
92
+
93
+ 任务定义在任意模块(如 `myproject/tasks.py`),启动 worker 时用 `--tasks` 指定加载(逗号分隔)。模块顶层 `@xschedy_heartbeat_task` 定义的任务才会注册进 worker;缺省 `--tasks` 则不加载任何用户任务,收到消息会报 `unregistered task` 丢弃。
94
+
95
+ 注意:worker 必须用 `xschedy worker` 启动——任务发现(`/api/task-types` 与下发校验)依赖入口写入的**自注册清单**;直接 `celery ... worker` 启动不会注册,下发会被 `UNKNOWN_TASK` 拒绝。
96
+
97
+ ```python
98
+ from xschedy.worker import TaskContext, xschedy_heartbeat_task
99
+
100
+ # 可选:worker 生命周期钩子(零 celery import)
101
+ from xschedy.worker import (
102
+ xschedy_worker_process_init, # 每个子进程初始化(建 DB 连接池等连接类资源)
103
+ xschedy_worker_process_shutdown, # 子进程退出前清理(收 pid, exitcode)
104
+ )
105
+
106
+ @xschedy_worker_process_init
107
+ def init_process():
108
+ global engine
109
+ engine = create_engine(DB_URL) # 子进程各建各的,不能放主进程钩子(prefork 继承 fd)
110
+
111
+ @xschedy_heartbeat_task(
112
+ name="order_pay",
113
+ heartbeat_interval=2.0, # 心跳间隔
114
+ timeout=600, # 硬超时
115
+ max_retries=3, # 重试次数
116
+ retry_backoff=True, # 指数退避
117
+ )
118
+ def order_pay(self, task_ctx: TaskContext): # 单参契约:业务参数在 task_ctx.user_data(JSON 串)
119
+ self.report_progress(current=1, total=3, description="第1步")
120
+ return {"paid": True}
121
+ ```
122
+
123
+ 业务函数能拿到的参数分四类:
124
+
125
+ **① 签名参数**:`(self, task_ctx)` —— `task_ctx` 是 `TaskContext`,规格与业务参数都在它上面。业务参数在 `task_ctx.user_data`,是**请求体那个 JSON 字符串原样**(`str | None`)——**框架不替你解析**:要 dict 就 `json.loads`,要 class 就 `Model.model_validate_json`。
126
+
127
+ **② `self` 上的方法**(HeartbeatTask 基类):
128
+
129
+ ```python
130
+ self.report_progress(current, total, description, extra) # 上报进度
131
+ self.increment_progress(by, description, extra) # 递增进度
132
+ self.is_aborted() # 协作式中止检查(需 abortable=True,见下)
133
+ ```
134
+
135
+ > **`extra`**:业务自定义的扩展数据——一段**不透明字符串**,**内容完全由你定义**(框架既不解析
136
+ > 也不预设它表示什么;要带结构化数据就自己 `json.dumps`)。它随这条进度进 backend meta
137
+ > (`GET /api/tasks/{id}` 的 `progress` 里看得到)并推给调用方。
138
+ > 它**不是**下发时那个 `user_define`:那个是整个任务的标识、由调用方在下发时给;
139
+ > 这个是**本次上报**的、由业务现场给。
140
+ >
141
+ > - **`increment_progress` 的三个参数一律「覆盖」,不传即空**:`extra` 与 `description`
142
+ > 同款,不传就是清空,要换就给值。唯一被带下去的是 `total`,而它**压根没有参数**
143
+ > (`increment_progress(by)` 只加数,不会把已报的 `total` 抹掉——那会让进度条从 60%
144
+ > 打回 0%)。
145
+ > - 改 `extra` **会**再推一条进度(接收方要靠它看阶段),但**不算进度推进**——靠刷它给卡住的
146
+ > 任务续命是没用的,停滞检测不认它(和 `description` 同款)。
147
+ > - **自由文本 64KB 封顶**(`description` / `extra` 各算):超限**按 UTF-8 字节截断**、**尾部留痕**
148
+ > (`\n...[truncated by xschedy]`),并按任务名打一次 WARN。它与 `error` / `traceback` 的截断
149
+ > 是**同一条**(同一档 64KB)——进度文本是「给人看的阶段说明 / 一小包
150
+ > 自定义数据」,**不是载荷通道**;要传大块数据走 S3 或 mq。(设这条是因为:不加界时超过结果上限
151
+ > 会让进度**再也写不进去**且每个节流周期打一条异常日志,而通知照发——业务侧毫无察觉。)
152
+ >
153
+ > `is_aborted()` **仅在装饰器传了 `abortable=True` 时才有效**——该参数决定心跳线程是否去读中止标记
154
+ > (`xschedy:abort:{task_id}`)。未开启时它**恒返回 `False`**(进程内会打一次告警提醒)。
155
+ > **别把它当「立刻停」**:中止是**协作式**的(尽力而为)——外部请求发出并不代表任务会响应,
156
+ > 任务得**自己在关键点检查**这个方法来退出。按需开启:
157
+ >
158
+ > ```python
159
+ > @xschedy_heartbeat_task(name="order_pay", abortable=True)
160
+ > ```
161
+
162
+ **③ 框架字段(就是那个 `task_ctx` 入参,不混进 user_data)**:
163
+
164
+ ```python
165
+ task_ctx.task_name # 任务名
166
+ task_ctx.scheduled_time # 定时任务计划触发时刻(手动下发为 None)
167
+ task_ctx.default_args # 全局默认参数 XSCHEDY_DEFAULT_ARGS(透传不合并,按需读)
168
+ task_ctx.periodic_kwargs # 定时任务 PeriodicTask.kwargs(透传不合并,按需读)
169
+
170
+ self.request.id # 任务 id(**不在 task_ctx 上**——「本次执行」的事实,见下)
171
+ self.request.retries # 第几次尝试(同上)
172
+ ```
173
+
174
+ > 注意:**`task_id` 与 `attempt` 刻意不在 `task_ctx` 上**——它们是「本次执行」的事实(Celery 原生 `self.request`),
175
+ > 与「任务的规格」是两个来源,不合并。
176
+
177
+ > **返回 `None` 的时机**:任务体外(worker 生命周期钩子、Celery 信号、业务自己的收尾逻辑)恒为
178
+ > `None`——任务跑完会复位。所以要用它就得判空,任务体内它一定有值。
179
+
180
+ **④ Celery 原生(`self.request` / `self`)**:
181
+
182
+ ```python
183
+ self.request.id # task_id
184
+ self.request.retries # 重试次数(0 起)
185
+ self.request.hostname # 执行 worker 主机名
186
+ self.request.delivery_info # 投递信息(exchange/routing_key/priority)
187
+ self.request.headers # 完整 message headers
188
+ self.retry(exc=..., countdown=...) # 手动重试
189
+ self.name # 任务名
190
+ ```
191
+
192
+ ### 日志
193
+
194
+ 业务里直接 `from xschedy.worker import logger` 即可,**不要自己初始化 loguru**——worker 进程启动时已经统一装配好日志(stderr + 文件),格式含 `task_id`,容器日志与文件按任务过滤就靠它:
195
+
196
+ ```python
197
+ from xschedy.worker import logger
198
+
199
+ logger.info("order %s paid", order_id) # 这一行会带上当前 task_id
200
+ ```
201
+
202
+ 任务体外(worker 钩子、启动阶段)的日志 `task_id` 显示为 `-`。应用侧不再把任务日志写进 Redis,也没有 `GET /api/tasks/{id}/log`;同一份日志同时落在 `<XSCHEDY_WORK_DIR>/logs/`(按天轮转、保留 `XSCHEDY_LOG_SAVE_DAY` 天,默认 7 天),容器侧的采集与保留交给运行时/K8s 日志驱动。
203
+
204
+ ### 三条必须知道的约束
205
+
206
+ **① `user_data` 里的凭证不加密,会明文进 DB 与日志。**
207
+
208
+ xschedy **不代管**调用方访问外部系统的凭证(不做 airflow 的 Connection/Variable 那套),
209
+ 凭证由你自己管理、随 `user_data` 传进来。但「不代管」不等于「替你加密」——
210
+
211
+ - `user_data` 会**原样明文存下来**(落库 + worker 日志),框架不做任何脱敏;
212
+ - worker 日志行(带 `task_id`,供文件与容器日志按任务过滤)同样会把它打出来。
213
+
214
+ 所以**不要把密码 / token 放进 `user_data`**。确实需要传的,传引用(如密钥管理系统里的 key 名),
215
+ 由业务自己在 worker 侧去取。
216
+
217
+ > **`user_define` 同款,而且出站更频繁。** 下发请求可以带一个 `user_define`(一段不透明字符串,
218
+ > 框架不解析),它会**原样带回结果通知、进度通知与查询响应**——用来把你自己的单子对回 `task_id`
219
+ > (`task_id` 是框架生成的)。它明文进下发快照、随消息下发,并且**随每次进度推送发一遍**
220
+ > (每个任务最多每秒一条)。所以**放短期、可轮换的票据,别放长期凭证**;要放长期凭证就放引用。
221
+ > 另注意它的上限比 `user_data` 小两个数量级(64KB vs 5MB)——它是**唯一随每条进度通知重发一遍**
222
+ > 的入参字段,而进度帧进 Redis 队列(`LTRIM 0 9999`),单帧体积直接决定那个队列的内存上界。
223
+ > 超了会 422。
224
+
225
+ **② 等外部事件的长任务,不要在 worker 里 `sleep` 轮询。**
226
+
227
+ `sleep` 会白占一个执行槽数小时。正确做法是用 Celery 原生的 `self.retry(countdown=N)` 把任务
228
+ 重新入队,下次被调度时再检查一遍条件:
229
+
230
+ ```python
231
+ @xschedy_heartbeat_task(name="wait_upstream", max_retries=100)
232
+ def wait_upstream(self, task_ctx: TaskContext):
233
+ user_data = json.loads(task_ctx.user_data) if task_ctx.user_data else {}
234
+ if not upstream_ready(user_data["job_id"]):
235
+ self.retry(countdown=60) # 释放执行槽,60s 后再来看看
236
+ return {"done": True}
237
+ ```
238
+
239
+ 效果等同轮询,成本几乎为零。(「不做 deferrable / triggerer」的替代实践。)
240
+
241
+ **③ CPU 密集型任务要在关键点让出 GIL,否则心跳会「假死」。**
242
+
243
+ 心跳跑在一个**独立的 Python 线程**里(`core/worker_core.py`)。CPython 的 GIL 下,
244
+ 一个纯计算的死循环会让心跳线程长时间得不到调度——**心跳不再续期**,而框架看到的是
245
+ 「这个任务没有心跳了」,于是按心跳超时处理(默认 30s)。任务其实好端端在算。
246
+
247
+ 对策是**在关键点让出 GIL**:调一次 `self.report_progress(...)`(它做 Redis IO,天然让出),
248
+ 或在长循环里周期性让渡:
249
+
250
+ ```python
251
+ @xschedy_heartbeat_task(name="heavy_calc", timeout=600)
252
+ def heavy_calc(self, task_ctx: TaskContext):
253
+ for i, chunk in enumerate(chunks):
254
+ compute(chunk)
255
+ if i % 100 == 0:
256
+ self.report_progress(i, len(chunks)) # 保住心跳,顺带把进度送出去
257
+ return {"done": True}
258
+ ```
259
+
260
+ > **还有一个不那么直观的连带后果**:`xschedy:idem:{key}`(幂等去重键)的 TTL 同样由心跳
261
+ > 线程每 tick 续期。所以**心跳一停,幂等窗口就开始倒计时**——同 key 的下发会在窗口过期后
262
+ > 重新产生一个新任务,去重形同失效。
263
+ >
264
+ > 顺带一提:**心跳检测不到「任务自己卡住」**——它只在「进程内的心跳线程不再跑」时才中断
265
+ > (进程没了 / 上面这条 GIL 饿死)。阻塞在锁、IO、死锁上的任务 GIL 是释放的,心跳照跑。
266
+ > 「卡住」的可见性由进度停滞指标 `xschedy_task_stalled` 提供。
267
+
268
+ ### 对象存储(S3/MinIO)
269
+
270
+ worker 内置 S3/MinIO 客户端,业务任务可直接存/取对象:
271
+
272
+ ```python
273
+ from xschedy.worker import get_s3
274
+
275
+ s3 = get_s3() # S3Client 单例(fork-safe 惰性初始化,同 get_redis)
276
+
277
+ s3.upload(b"bytes", key="x/y.txt", bucket="data") # 内存字节 → 返回 key
278
+ s3.upload_file("./report.pdf", key="reports/2026.pdf", bucket="data") # 单文件(流式,支持大文件)
279
+ s3.upload_folder("./exports", prefix="exports/2026", bucket="data") # 递归上传目录 → key 列表
280
+
281
+ # 上传时把「整文件摘要」写进对象 tag(键固定 digest,值形如 sha256:<hex>);文件缓存靠它判命中
282
+ s3.upload_file("./report.pdf", key="reports/2026.pdf", bucket="data", with_hash_tag=True)
283
+
284
+ data = s3.download("x/y.txt", bucket="data") # → bytes(整份进内存,只适合小对象)
285
+ path = s3.download_to_file("big.bin", bucket="data") # → Path(流式落盘;省略 path 时落缓存目录)
286
+ path = s3.download_to_file("big.bin", "./tmp/big.bin", bucket="data") # 指定落点
287
+ url = s3.presigned_url("x/y.txt", expires=3600, bucket="data") # 临时下载 URL
288
+ s3.exists("x/y.txt", bucket="data") # → bool
289
+ s3.get_tags("x/y.txt", bucket="data") # → dict
290
+ s3.set_tags("x/y.txt", {"status": "committed"}, bucket="data") # 对象标签(覆盖全部)
291
+ s3.delete("x/y.txt", bucket="data") # 删除对象
292
+ ```
293
+
294
+ - `bucket` 必填(无全局默认)。
295
+ - **`download` vs `download_to_file`**:前者把整个对象读进内存,只适合小对象(几 MB 的 JSON/图片);
296
+ 大文件用 `download_to_file`,它流式写盘、走「临时文件 + 原子改名」,失败不会留下半截文件。
297
+ `path` 省略时落到**缓存目录**(与 `get_cached_file` 同一个地方),传了就用你给的位置。
298
+ - **`with_hash_tag=True`**:由框架算整文件摘要并写进对象 tag(键固定为 `digest`,可用
299
+ `XSCHEDY_S3_HASH_TAG` 改;值形如 `sha256:<hex>`,**算法写在值里**、键与算法解耦)。
300
+ 代价是**多读一遍文件**(tag 必须在创建对象时给,而摘要要读完才知道);超大盘文件可自己算好、
301
+ 直接传 `tags={"digest": "sha256:<hex>"}`,读几次由你控制。
302
+ - 配置走 `XSCHEDY_S3_*`:`XSCHEDY_S3_ENDPOINT`(host:port)、`XSCHEDY_S3_ACCESS_KEY` /
303
+ `XSCHEDY_S3_SECRET_KEY`、`XSCHEDY_S3_SECURE`(是否 HTTPS)、`XSCHEDY_S3_REGION`。
304
+ - 内置错误处理 + 重试:瞬态错误(5xx/限流/连接超时)指数退避重试,最终失败 re-raise
305
+ (任务可见失败、走 Celery 重试);404 这类确定性错误不重试。
306
+
307
+ ### 文件缓存(`get_cached_file`)
308
+
309
+ 把对象取到本地文件、并**按对象 tag 里的 SHA-256 校验内容**;适合「同一个文件要反复读」或「要交给
310
+ pandas / ffmpeg 这类按路径读文件的库」。
311
+
312
+ ```python
313
+ from xschedy.worker import FileIntegrityError, get_cached_file
314
+
315
+ path = get_cached_file("data", "orders/2026/11/x.csv") # -> pathlib.Path
316
+ df = pandas.read_csv(path) # 之后再取同一个对象:直接命中,不再下载
317
+
318
+ # 对象没有摘要 tag(比如别家工具上传的),可以自己给期望值;verify=True 强制重算本地摘要
319
+ path = get_cached_file("data", "big.bin", digest="9e107d…", verify=True)
320
+ ```
321
+
322
+ **落点**:`<作业目录>/cache/<bucket>/<key>`(容器里即 `/home/.xschedy/cache/...`)。文件名**照搬
323
+ 对象 key**,排查时能直接把本地路径对回 MinIO;`download_to_file` 省略 `path` 时也写这里。
324
+
325
+ **命中判定**(两把摘要对上才算命中,默认 SHA-256):
326
+
327
+ | 远端 | 本地 | 结果 |
328
+ |---|---|---|
329
+ | 对象 tag 里的 `digest`(值 `sha256:<hex>`;前缀不是当前算法则视为「未标注」) | 缓存文件的摘要(下载时算、重启后懒算) | 相等 → 命中;不等 → 覆盖重下 |
330
+ | 没有这个 tag(且调用方没传 `digest`) | — | 按 size+mtime 命中,并**告警一次**「内容未校验」 |
331
+ | 下载后算出的摘要与期望不符 | — | 抛 `FileIntegrityError`,半成品删除、不入缓存 |
332
+
333
+ **配置**(都有默认值,可用环境变量覆盖):
334
+
335
+ | 变量 | 默认 | 说明 |
336
+ |---|---|---|
337
+ | `XSCHEDY_FILE_CACHE_ENABLED` | `true` | 关掉 = 每次都下载覆盖(仍写同一个文件),不做命中判断 |
338
+ | `XSCHEDY_FILE_CACHE_MAX_MB` | `10240`(10 GiB) | 总字节上限 |
339
+ | `XSCHEDY_FILE_CACHE_MAX_FILES` | `1000` | 文件数上限(与字节上限**双判**,谁先触到按 LRU 淘汰) |
340
+ | `XSCHEDY_S3_HASH_TAG` | `digest` | 对象 tag 的键名(值形如 `sha256:<hex>`;上传与缓存共用同一个) |
341
+
342
+ **注意事项**:
343
+
344
+ - 想让缓存能校验,**上传时就要打 tag**:`s3.upload_file(..., with_hash_tag=True)`,或自己传
345
+ `tags={"digest": "sha256:<hex>"}`;不打 tag 只能按 size/mtime 命中。
346
+ - 摘要对不上是「数据不一致」而不是「网络抖动」:不会自动重试(重下还是同样的字节)。任务侧若配了
347
+ `max_retries`,会重投整个任务,直到有人把对象或 tag 修正。
348
+ - 索引是**进程内**的(每轮 `get` 只 `stat` 一次目标文件,磁盘为真相);多 worker 副本各扫各的,
349
+ 互不阻塞,也不会互相写坏。
350
+ - 缓存目录里的文件(包括 `download_to_file` 写进去的)都计入上面两个限额,可能被 LRU 淘汰。
351
+
352
+ ### Redis 直连 + 原子操作 / 分布式锁
353
+
354
+ worker 内置 Redis 客户端(复用 `result_backend`),业务任务可直接读写,并附一套原子操作 / 分布式锁工具:
355
+
356
+ ```python
357
+ from xschedy.worker import (
358
+ get_redis,
359
+ redis_incr_with_ttl, redis_incr_with_ttl_once, redis_decr_floor,
360
+ redis_hset_atomic, redis_sadd_atomic, redis_lpush_atomic,
361
+ redis_lock, redis_acquire_lock, redis_release_lock, redis_renew_lock,
362
+ )
363
+
364
+ r = get_redis() # Redis 客户端(零参数,复用 app 的 redis_* 配置)
365
+ r.set("k", "v"); r.get("k") # 原生 redis-py 命令直接可用
366
+
367
+ # 原子计数(Lua 保证「读-判断-写」原子)
368
+ redis_incr_with_ttl(r, "counter", 3600) # INCR + PEXPIRE(每次续期)
369
+ redis_incr_with_ttl_once(r, "limit", 86400) # 仅首次设 TTL(靠 TTL 兜底清零的计数,不续期)
370
+ redis_decr_floor(r, "limit") # 带下限 DECR(>=1 才减,防减成负数)
371
+
372
+ # 原子「写入 + 过期」(单条 Lua)
373
+ redis_hset_atomic(r, "h", {"a": 1, "b": 2}, expire=3600)
374
+ redis_sadd_atomic(r, "s", "m1", "m2", expire=3600)
375
+ redis_lpush_atomic(r, "q", "v1", "v2", expire=3600)
376
+
377
+ # 并发占槽 / 命名资源池**不在这里**:它们由框架自己管(下发时占、终态时还、心跳每拍续期),
378
+ # 对应的原语由框架内部使用,不是业务 API——
379
+ # 它们都按 member 操作、分不清调用者是谁,业务直调会把调度器的账改坏。
380
+
381
+ # 分布式锁(context manager,可选自动续期看门狗)
382
+ with redis_lock(r, "job-lock", ttl=30):
383
+ ...
384
+ # 手动抢 / 续 / 放
385
+ token = redis_acquire_lock(r, "job-lock", ttl=30)
386
+ redis_renew_lock(r, "job-lock", token, 30)
387
+ redis_release_lock(r, "job-lock", token)
388
+ ```
389
+
390
+ > `get_redis()` 零参数:内部复用 Celery app 的 `result_backend` + redis\_\* 韧性配置(socket_timeout=5s、连接池 50、health_check 30s)。fork 后子进程首次调用才建连接,避免继承父进程坏连接。
391
+
392
+ ## 下发任务(`POST /api/tasks`)
393
+
394
+ 两种受理语义由请求体的 `mode` 决定。**区别只在「下发之后等不等」**——两者都会把任务真正投出去:
395
+
396
+ | `mode` | 行为 | 响应 |
397
+ | --- | --- | --- |
398
+ | `async`(默认) | 受理即返回,不等结果 | **202** + `task_id` |
399
+ | `sync` | 阻塞到任务终态 | **200** + `result` / `error`;等待超时 **408** |
400
+
401
+ ```bash
402
+ # 异步(默认):拿到 task_id 就返回
403
+ curl -X POST localhost:9100/api/tasks -H 'content-type: application/json' -d '{
404
+ "task_name": "order_pay",
405
+ "queue_name": "default",
406
+ "user_data": "{\"order_id\": 1}"
407
+ }'
408
+ # -> 202 {"task": {"task_id": "…", "status": "pending", …}, "deduplicated": false, "server_roles": {…}}
409
+ # `task` 与查询响应、通知是**同一个结构**(见下面「通知」那节),这里另加一层
410
+ # `dispatch` 记「这一次受理」的事实(`deduplicated` / `server_roles`)。
411
+ # 此刻有值的是**身份类**字段(`tag` / `user_define` / 延迟任务的 `run_at`);
412
+ # 生命周期类(`worker` / `started_at` / `progress` / `result` / `error`)在 `pending` 下就是 null。
413
+
414
+ # 同步:阻塞到跑完
415
+ curl -X POST localhost:9100/api/tasks -H 'content-type: application/json' -d '{
416
+ "mode": "sync",
417
+ "task_name": "order_pay",
418
+ "queue_name": "default"
419
+ }'
420
+ # -> 200 {"status": "success", "result": …}
421
+ ```
422
+
423
+ > 注意:**`mode="sync"` 的等待有**两道**上限,每轮依次都判**:① **心跳**——任务每有心跳 / 进度即续期,30 秒内毫无心跳判「卡死」,返回 408;② **总时长**——`XSCHEDY_SYNC_MAX_WAIT`(默认 600s)是**绝对上限**,到点即 408,**与心跳在不在无关**。注意:所以「只要任务还在心跳就一直等」**只到 600 秒为止**:**预期跑十几分钟以上的任务应当用 `mode="async"`**(心跳再稳也等不到结果),别指望调大这个数来给长任务让路——它是按网关/LB 的 idle timeout 定的,调大要连带调网关、拉长优雅停机、并让 sync 名额被长任务长期占住。**超时不 revoke**——连接断了任务照跑,结果仍会写进 backend 供后续查询;两个 408 的响应体都带**结构化 `task_id`**,拿它就能接上那条还在跑的任务。
424
+ > 注意:**它是长连接,与网关/LB 的 idle timeout 天然冲突**:网关超时要配得比那个绝对上限更长,否则连接会先被中间设备掐断。**绝大多数场景「异步下发 + 轮询 `GET /api/tasks/{id}` 或 `notify` 回调」更合适**——见下节。
425
+ > 注意:`mode` **只表示这一次调用怎么收结果**,不落库、不下发给 worker:任务本身没有「同步 / 异步」之分。
426
+
427
+ ## 调用方 SDK(`xschedy.client`)
428
+
429
+ Python 服务或脚本直接用 SDK 即可。底层仍是 HTTP,但请求体、轮询与错误信封已经封装好;SDK 只依赖 `httpx`:
430
+
431
+ ```bash
432
+ pip install xschedy[client]
433
+ ```
434
+
435
+ 异步(FastAPI / asyncio):
436
+
437
+ ```python
438
+ import asyncio
439
+ import json
440
+
441
+ from xschedy.client import Client, XschedyError
442
+
443
+
444
+ async def main() -> None:
445
+ async with Client("http://localhost:9100", token="…") as c:
446
+ try:
447
+ sub = await c.submit("order_pay", "default", user_data=json.dumps({"order_id": 1}))
448
+ print(sub.task_id, sub.deduplicated) # 202 的受理结果
449
+ view = await c.wait(sub.task_id, timeout=120) # 轮询到终态
450
+ print(view.status.value, view.result, view.error)
451
+ except XschedyError as exc:
452
+ print(exc.code, exc.message, exc.status_code, exc.task_id)
453
+
454
+
455
+ asyncio.run(main())
456
+ ```
457
+
458
+ 同步(脚本 / 非 async 服务):`SyncClient` 的方法一一对应,去掉 `await` 与 `async with`:
459
+
460
+ ```python
461
+ from xschedy.client import SyncClient
462
+
463
+ with SyncClient("http://localhost:9100", token="…") as c:
464
+ view = c.submit_sync("order_pay", "default", user_data='{"order_id": 1}')
465
+ print(view.status.value, view.result)
466
+ ```
467
+
468
+ `submit` 的其余关键字参数与 `POST /api/tasks` 的请求体同名(`timeout_s`、`priority`、`tag`、`user_data`、`idempotent_key`、`notify` 等);`mode` 由方法决定,不用自己传。`submit` 返回 `Submission`,除 `task` 外还带 `task_id` / `deduplicated` / `server_roles`。
469
+
470
+ 重试策略(`Client(..., retries=N)` / `SyncClient(..., retries=N)`,默认 0):
471
+
472
+ - `get`(以及 `wait` 内部的轮询)对**连接错误**与 **429 / 502 / 503 / 504** 重试——GET 是安全方法;
473
+ - `submit` 只有在**显式传了 `idempotent_key`** 时才重试,同样是连接错误与 429 / 502 / 503 / 504——没有幂等键时重试可能建出两条任务;
474
+ - `submit_sync` **不自动重试**:它是单次长阻塞请求,重试会再等一个 620s 并可能撞上 sync 名额;需要重试用 `submit` + `wait`;
475
+ - 退避为指数 + 半抖动;响应带 `Retry-After` 时优先用它(上限 30s)。
476
+
477
+ 错误:所有非 2xx 与传输失败都抛 `XschedyError`,带 `code` / `message` / `status_code` / `task_id`。`wait` 超时抛 `code="WAIT_TIMEOUT"`,**不会 revoke 任务**。
478
+
479
+ 其余方法:`get` 查详情,`retry` / `revoke` / `abort` / `relaunch` 对应同名端点。
480
+
481
+ 在任务内部发子任务时用 `ctx.submit_task`:它按 `XSCHEDY_API_URL` / `XSCHEDY_API_TOKEN` 调用控制面,参数与 `submit` 相同:
482
+
483
+ ```python
484
+ @xschedy_heartbeat_task(name="rag_parse")
485
+ def rag_parse(self, ctx: TaskContext) -> None:
486
+ ...
487
+ ctx.submit_task(
488
+ "rag_chunk", "rag",
489
+ user_data=json.dumps({"doc_id": doc_id}),
490
+ idempotent_key=f"{doc_id}:chunk:v1",
491
+ )
492
+ ```
493
+
494
+ worker 镜像装 `xschedy[worker]` 即可,它已含 httpx;地址与 token 默认取 `XSCHEDY_API_URL` / `XSCHEDY_API_TOKEN`,也可以用 `base_url=` / `token=` 单次覆盖。幂等键必须按业务语义显式传,否则父任务重发时可能把同一个子任务发两遍。
495
+
496
+ ## 通知(推给调用方的数据结构)
497
+
498
+ 下发时带 `notify` 才会推,不填则一条都不推。**两类通知是同一个结构**——一层的差别都没有,
499
+ 靠 `frame_type`(`progress` / `terminal`)区分场景,接收方一套解析就够;不要用 `task.status`
500
+ 判帧类型(它是任务状态,终态骨架帧里可以是 `null`)。
501
+
502
+ **① 终态通知**——任务结束时一条(哪几种结局推由 `notify.on_success` / `on_failure` / `on_timeout`
503
+ 决定;`cancelled` 归 `on_failure`、`timeout` 归 `on_timeout`),at-least-once
504
+ (重发可能,接收方按 `task.task_id` + `execution_id` + `callback_attempt` 幂等去重):
505
+
506
+ ```json
507
+ {
508
+ "frame_type": "terminal",
509
+ "task": {
510
+ "task_id": "…",
511
+ "status": "failed", // success / failed / timeout / cancelled / suspected / pending / running
512
+ "progress": null, // 仅 running 时有值
513
+ "created_at": "…", // 受理时刻
514
+ "result": null, // 仅 success 时有值,且是**已解析的值**(不是字符串)
515
+ "result_present": false, // backend 上是否真的有 result(区分「没结果」与「结果就是 null」)
516
+ "error": "ValueError: boom", // failed / timeout 时有值;cancelled 时写明是**谁**撤销的
517
+ "traceback": "…",
518
+ "worker": "worker1",
519
+ "retries": 0,
520
+ "run_at": null, // 计划执行时刻(延迟 / 定时任务)
521
+ "dispatch_reason": null, // scheduled / backfill / null = 手工下发
522
+ "started_at": "…",
523
+ "finished_at": "…",
524
+ "user_define": "…", // 你下发时给的业务标识,原样带回(没配则 null)
525
+ "sla_missed": false, // 当前执行轮次是否错过业务 SLA;违约不单独推通知
526
+ "tag": "nightly", // 分组标签:手工/REST 下发取请求里的 `tag`,定时任务取那条定义的 `tag`(两者默认都是 `default`)
527
+ "periodic_task_name": null // 产生它的定时任务定义;**判「是不是定时任务」看它**
528
+ },
529
+ "execution_id": "…", // 本次执行轮次
530
+ "callback_attempt": 0, // 本回调的推送序号(幂等去重用)
531
+ "notify_type": "webhook", // 配置的通知方式;进度帧为 null
532
+ "progress_at": null // 终态帧为 null
533
+ }
534
+ ```
535
+
536
+ **`task` 的键恒在(18 个,没值的给 `null`)**——所以 `if "progress" in body` 这类判断可以删掉;
537
+ **取值按 `status` 分档**:`result` 看 `success`、`error` 看 `failed` / `timeout`、`progress` 看非终态。
538
+ 终态通知在取得对应执行轮次的 backend 结果后发送。
539
+ 判「是不是定时任务」用 `periodic_task_name`——**别用 `run_at`**(手工下发的延迟任务同样有 `run_at`),
540
+ 也**别用 `dispatch_reason`**(那个答的是「按表到的点 / 补的历史点」,两者都属定时任务)。
541
+ 完整的档位表见项目仓库的设计文档 8.2。
542
+
543
+ **② 进度通知**——`notify.on_progress = true` 时才有,**与上面逐键同形**(`task` 同样 18 个键,没值的给 `null`):
544
+
545
+ ```json
546
+ {
547
+ "frame_type": "progress",
548
+ "task": {
549
+ "task_id": "…",
550
+ "status": "running",
551
+ "progress": {
552
+ "current": 2,
553
+ "total": 3,
554
+ "percent": 66.67,
555
+ "description": "扣款",
556
+ "extra": "{\"stage\":\"pay\"}"
557
+ },
558
+ "result": null,
559
+ "result_present": false,
560
+ "error": null,
561
+ "traceback": null,
562
+ "created_at": "…",
563
+ "run_at": null,
564
+ "dispatch_reason": "scheduled",
565
+ "started_at": null,
566
+ "worker": "worker1",
567
+ "retries": 0,
568
+ "user_define": "…",
569
+ "sla_missed": false,
570
+ "tag": "nightly",
571
+ "periodic_task_name": "nightly-report",
572
+ "finished_at": null
573
+ },
574
+ "execution_id": "…",
575
+ "callback_attempt": null, // 进度推送不走退避重试,没有投递序号
576
+ "notify_type": null, // worker 不读回调配置行
577
+ "progress_at": 1763162400.123 // 本帧水位时刻;消费端按它丢弃积压
578
+ }
579
+ ```
580
+
581
+ - `task.progress` 的五个键恒在:`current` / `total` / `percent` / `description` / `extra`;`total` 未知时为 `null`,`description` / `extra` 不传时为 `null`。
582
+ - `time_remaining` **不在这里**,它只在 worker 内存里供心跳/停滞判断,不下发。
583
+
584
+ - `percent` 由框架算(`total` 未知时恒 0);`description` 是本次上报的说明文本。
585
+ - **`task.progress.extra`** 是业务在 `report_progress(..., extra=…)` 里现场给的。注意:它和 `user_define` **是两回事**:`user_define` 是**整个任务**的、下发时给、每次通知都带;`extra` 是**本次上报**的、由业务现场给、只在那条进度里(`increment_progress` 与 `report_progress` 同款:不传即清空)。
586
+
587
+ 出站方式由 `notify.config` 决定;**每一类通知在三种出口发的是同一份 JSON**:**webhook**(HTTP `POST`,`method` 可配,body 就是上面那份)/ **mq**(投到 exchange + routing_key)/ **redis**(`PUBLISH` 到 channel,或 `SET … EX` 写到 key)。
588
+
589
+ > 注意:**进度是水位、不是事件流**:同一个进度报第二遍**不推**;但改 `description` / `extra` **会**再推一条(比的是整份进度);距上次推送不足 1 秒的变化**当场不推**,要等下一次上报才会补推——**所以「报完最后一条就停」的业务,那一条变化永远推不出去**(它通常还能从 `GET /api/tasks/{id}` 查到,因为心跳会把内存里的当前值写进去)。要 1:1 的序列,走日志或 mq。
590
+
591
+ ## 关键配置(`XSCHEDY_*` 环境变量)
592
+
593
+ | 变量 | 默认 | 说明 |
594
+ | ------------------------ | --------------------------------------------- | --------------------------------------------------------------------------------------- |
595
+ | `XSCHEDY_BROKER_URL` | `amqp://localhost:5672/%2F` | RabbitMQ(默认 vhost `/`) |
596
+ | `XSCHEDY_RESULT_BACKEND` | `redis://:<password>@10.252.76.12:32379/2` | 开发 Redis(默认凭据见项目配置) |
597
+ | `XSCHEDY_DB_URL` | `postgresql+psycopg://postgres:<password>@192.168.100.206:5452/xschedy_test` | 开发 PostgreSQL 历史落库 |
598
+ | `XSCHEDY_WORK_DIR` | `~/.xschedy` | 作业目录:机器本地产物的落点。日志默认写在其 `logs/` 下(`XSCHEDY_LOG_PATH` 可显式覆盖) |
599
+
600
+ 注意:**名字必须带 `XSCHEDY_` 前缀**:代码只读这个名字,少写前缀会被**静默忽略**、退回默认值
601
+ (不报错、也没有提示)。`deploy/` 的两份 compose 里出现的短名(`BROKER_URL` / `RESULT_BACKEND` /
602
+ `DB_URL`)是**它们自己的外层覆盖名**,容器里设的仍是上表这几个 `XSCHEDY_*`。
603
+
604
+ 注意:**上表只是最常用的四项,不是全部**(文件缓存的四个旋钮见上面的「文件缓存」一节)。完整清单(含 `XSCHEDY_API_TOKEN` 静态 token、
605
+ `XSCHEDY_TASK_CONCURRENCY_LIMIT` 任务名并发上限、`XSCHEDY_TASK_POOL` 命名资源池、
606
+ `XSCHEDY_SECRET_KEY` 通知配置加密密钥、`XSCHEDY_TIMEZONE` 等)见项目仓库设计文档的
607
+ **「配置参考(`XSCHEDY_*` 环境变量总清单)」**一节。