Prefect 于 2026 年 9 月 26 日发布 3.8.7:PostgreSQL 索引把 6350000 行查询从 61.3 秒降到 385 毫秒。 ## 这条发布的重点在修复清单,不在新功能 前置部署 Forward Deployed Engineer(FDE)在客户现场维护自托管工作流引擎时,升级决策的依据通常不是新增了什么功能,而是修复清单里哪些条目会改动数据库结构、网络安全组与并发语义。Prefect 3.8.7 恰好是这样一次发布:40 个合并的 PR 里有 25 个由自主编码 Agent 提交,唯一的新功能只有 1 个,Bug Fixes 占了 20 个。 前置部署 Forward Deployed Engineer(FDE)需要在这条里拿到的是三件具体事实:一条 PostgreSQL 复合索引迁移把 6350000 行表上的 state 过滤查询从 61319.544 毫秒降到 385 毫秒、ECS worker 不再发送空的 securityGroups 从而改变实际生效的安全组、以及 Docker worker 新增 container_wait_timeout 来避免并发槽被永久占用。 前置部署 Forward Deployed Engineer(FDE)还要先划清题材边界:这篇的素材是 Prefect 官方发布说明与关联的 PR、issue 原文,不是客户案例,也不含成本数据。 ## 一、发布事实与时间口径 Prefect 发布页上有两个时间戳:2026-09-26T23:41:44Z 与 2026-09-26 23:40:47 UTC,Release feed 的 updated 字段同为 2026-09-26T23:41:44Z,本条据此记为 2026 年 9 月 26 日。 发布说明正文里另有一行写的是 Released on September 25, 2026,与上面两个时间戳不一致。这是原文自身口径不统一,本条按发布页时间戳取值,读者若按 9 月 25 日引用会与发布页对不上。 上一个正式版本是 3.8.6,标题为 Pause for cancellation,发布于 2026-09-14T14:18:53Z,与 3.8.7 相隔约 12 天。两个正式版之间夹着 7 个 nightly 开发版:3.8.7.dev1 在 2026-09-15 08:20:02Z、dev2 在 09-16 08:19:54Z、dev3 在 09-18 08:18:58Z、dev4 在 09-19 08:16:49Z、dev5 在 09-22 08:19:45Z、dev6 在 09-23 08:19:42Z、dev7 在 09-26 08:18:02Z。 完整变更区间是 3.8.6 到 3.8.7,官方在发布说明末尾给出的对比链接写法就是 3.8.6...3.8.7。 ## 二、40 个 PR 的类型分布说明风险不在哪里 发布说明共列出 40 个 PR,编号唯一无重复。按官方分段统计:New Features 1 个、Enhancements 6 个、Bug Fixes 20 个、Integrations & Dependencies 1 个、Development & Tidiness 7 个、Documentation 5 个。 作者分布是这条发布最值得注意的一面:25 个 PR 由 devin-ai-integration [bot] 提交,占 40 个的 62.5%;其余为 uinstinct 5 个、zzstoatzz 4 个、David-Wu1119 2 个、desertaxle 2 个、dependabot 1 个、mrchatam 1 个。 由此得到一个对升级有直接意义的判断:只有 1 个条目属于新功能,20 个条目属于缺陷修复,因此升级风险不在功能面,而在这些修复对数据库结构、网络配置与并发语义的改动。前置部署 Forward Deployed Engineer(FDE)审这段发布说明时,应把阅读顺序倒过来,从 Bug Fixes 开始。 自主编码 Agent 在本次发布中还被明确标注在单条 PR 上:PR 23180 的说明里带 Generated with Claude Code 字样,提交信息里还有 Co-Authored-By: Claude Opus 5 这类标记,说明这批合入是人工评审后落地的,而非无人值守自动合并。 ## 三、修复一:PostgreSQL 复合索引迁移让过滤查询从超时变成 385 毫秒 问题现象是自托管 Prefect 服务端配 PostgreSQL 时,POST /flow_runs/filter 返回 HTTP 500,报错为 PrefectHTTPStatusError: Server error 500 Internal Server Error,服务端侧异常栈最终落在 asyncpg 的 prepared_stmt.py 第 177 行的 TimeoutError。 触发这个查询的形状是:按 state.type 过滤 RUNNING、按 start_time 取 15 分钟前的上界、按 START_TIME_ASC 排序、单页取 200 行。报告者给出的表规模是约 6350000 行、占用 26 GB,其中 RUNNING 状态约 27000 行。 PostgreSQL 生成的语句是 WHERE flow_run.state_type IN ('RUNNING') AND coalesce(flow_run.start_time, flow_run.expected_start_time) <= :cutoff ORDER BY coalesce(flow_run.start_time, flow_run.expected_start_time) ASC LIMIT 200 OFFSET 0。 根因在于两组索引都不能同时服务这个形状:既有的 (state_type, start_time) 索引无法服务 coalesce(...) 这个表达式;而既有的 coalesce(start_time, expected_start_time) 表达式索引里不含 state_type。planner 于是走了有序表达式索引,再逐行过滤 state。 执行计划里的数字把代价写得非常清楚:Limit 节点的 actual time 为 59751.738..61319.498 毫秒、rows 为 200;下面挂的是 Index Scan Backward using ix_flow_run__coalesce_start_time_expected_start_time_desc,Filter 为 state_type = 'RUNNING'::state_type,Rows Removed by Filter 为 5497752 行,Execution Time 为 61319.544 毫秒。也就是说为取 200 行先进了 5497752 行过滤。 修复方式是新增一条复合索引 ix_flow_run__state_type_coalesce_start_time,建在 (state_type, (coalesce(start_time, expected_start_time))) 上。加入后同一条 API 请求返回 200 行用 0.385 秒,一个原本第一页就失败的流随后在 20 页里取到 4000 行、用 5.7 秒完成。 改动前后的对比是 61319.544 毫秒对 385 毫秒,约 159 倍差距;而 0.385 秒这个数字是同一台机器、同一个查询形状下的实测值,不是估算。 迁移实现有两个必须分开看的口径。PostgreSQL 侧迁移编号 d4a7e1c93f52,使用 CREATE INDEX CONCURRENTLY,并带一套 pg_index.indisvalid 校验加 REINDEX INDEX CONCURRENTLY 的修复模式,该模式沿用自更早的迁移 c8d5f2a71b3e;SQLite 侧迁移编号 a3b9c7d2e5f1,只是普通的 CREATE INDEX IF NOT EXISTS。 前置部署 Forward Deployed Engineer(FDE)还需要知道一个对交付环境有直接影响的细节:PostgreSQL 这条迁移明确拒绝 --sql 离线生成,因为它必须读取 catalog。这意味着在网络受限或只能离线产出 DDL 的交付流程里,这条迁移必须在一个能连上数据库的节点上实际执行。 建议的升级后自查方式是用 EXPLAIN 复现 issue 里的查询形状,确认计划里出现 Index Scan using ix_flow_run__state_type_coalesce_start_time 且没有独立的 Sort 节点;同时查 pg_index 的 indisvalid 字段确认为 true,若为 false 就按迁移内置的 REINDEX INDEX CONCURRENTLY 模式修复。 这条修复的局限也要写清楚:它只解决 state 过滤与 start_time 排序组合这一种形状的退化,原文没有给出索引在 6350000 行表上的实际构建耗时,也没有给出该索引带来的写入开销,这两个数字在本条里刻意留空。 ## 四、修复二:ECS worker 不再发送空的 securityGroups 这个改动只有一行删除,但它改变的是实际生效的网络安全组。PR 23132 从 ECSWorker._load_network_configuration 依据 VPC 推断出的网络配置里删掉了 "securityGroups": [] 这一项。 根因是 AWS 的 AwsVpcConfiguration 契约规定:省略 securityGroups 时 ECS 使用该 VPC 的默认安全组。显式空列表与省略字段并不等价,botocore 会把空列表原样发到线上,于是 ECS 不会回退到默认安全组。改动前后差的就是这一层回退。 适用条件是使用 vpc_id 让 Prefect 推断网络配置的 ECS work pool。自定义 network_configuration 里显式给出的 securityGroups 不受这个改动影响,仍然按原值发送。 对前置部署 Forward Deployed Engineer(FDE)来说,这是本次发布里最需要提前评估的一条:升级后 ECS 任务会落在 VPC 默认安全组上,而不是此前的空安全组。默认安全组通常带有允许同组成员互访的入站规则,因此升级前应先确认目标 VPC 默认安全组的入站规则是否符合预期,否则可能在任务间连通性上出现意料之外的变化。 测试实现里有一个可以复用的细节:Moto 并不模拟 AWS 的默认行为,省略该字段会抛 KeyError: 'securityGroups',所以两个断言字段缺失的测试改为在 outbound 请求边界 mock _create_task_run,而其余使用 ecs_mocks fixture 的测试改为 patch moto.ecs.models.Task.__init__,在副本上补一个空的 securityGroups,使 Prefect 实际发出的请求保持不变。 这条改动的验证局限是只有单元测试,原文没有给出真实 AWS 账号上的验证结果。 ## 五、修复三:Docker worker 新增 container_wait_timeout 问题现象是 DockerWorker 管理的 run 会无限期停在 Submitting。issue 23150 的标题直接写明 container.wait() 绕过了客户端超时。 根因链条很短但很隐蔽:DockerWorker._watch_container 在结尾调用的是无参数的 container.wait()(源码位置在 worker.py 第 1001 行);docker-py 的 APIClient.wait 签名是 wait(self, container, timeout=None, condition=None),会把 None 透传;而请求超时的设置走的是 kwargs.setdefault('timeout', self.timeout),因为 timeout 这个键已经以 None 存在,客户端超时不会被应用,requests 就在 POST /containers/{id}/wait 上无限阻塞。 关键点是 DOCKER_CLIENT_TIMEOUT 这个环境变量管不到这里。worker 里其余调用都按 worker.py 第 731 到 736 行的写法取超时并用 docker.from_env(timeout=timeout) 建客户端,而 wait() 是每次流运行里唯一一个无界请求。 后果不只是日志难查:worker 会一直挂在这个 run 上,并持续占用它的并发槽。 修复方式是给 DockerWorkerJobConfiguration 增加字段 container_wait_timeout,类型为 Optional[int]、约束 gt=0、默认 None,并把 _watch_container 的调用改为 container.wait(timeout=configuration.container_wait_timeout)。 默认值刻意保持无限制,这一点值得注意:issue 里认为容器寿命没有正确的默认值,所以该值是 opt-in 的,升级本身不会改变任何现有行为,必须显式设置才生效。 超时的语义边界必须一起记住三条:第一,它只约束 worker 发起的 /wait 请求,不会停止容器;第二,超时后 docker-py 抛 requests.exceptions.ReadTimeout,因为 run 已经上报为 started,BaseWorker._submit_run_and_capture_errors 会记录一条 Lost connection to flow run infrastructure、释放并发槽,并把最终状态留给容器内的引擎决定,不会提出 Crashed 状态;第三,stream_output=True 时的日志流不受这个设置约束,只约束 /wait。 配置入口是 work pool 的 base job template,字段说明会通过 base job template 与自动生成的 API 参考暴露出来,不需要额外改文档。取值建议按该 work pool 内最长容器寿命来设,并注意它不会替你停止超时容器,超时后容器可能继续运行。 ## 六、修复四:文件系统并发租约不再被误撤(含两个未修局限) 根因是文件系统过期索引里的陈旧条目会让仍然活跃的并发租约被撤销。这不是注入类问题,而是索引与权威租约文件两份状态没被同步管理。 修复做法有三层:索引的更新、删除与批量修复都用 Prefect 既有的 FileLock 跨服务端进程串行化,出错与取消时释放;过期扫描在实际上报候选前,把候选与权威租约文件核对一次;修复只在条目仍持有扫描时读到的那个值时才改写。 第三层是关键,它保证扫描期间落地的续约不会被撤销,无论那次续约是加长还是缩短租约;同时被扫描期间删除的条目也不会被重建。撤销任务另外改用按租约分配的 Docket key,用来抑制任务在排队或运行期间的重复调度。 测试方式也做了升级:把原来基于时序的并发测试换成受控的进程交错,覆盖创建、续约与撤销三条路径。原文记录并发创建的回归在旧实现上会失败,表现为两个租约文件但只有一个索引条目。命令是 uv run pytest tests/server/concurrency tests/server/services/test_repossessor.py -x -q,结果为 74 passed、1 xfailed,环境是 Python 3.14 加 SQLite;那个预期失败覆盖的是既有的重复回收器记账竞态。 原文自述有两个未修限制,这一点很重要,因为它说明这不是彻底修复而是缩小了窗口。第一,续约仍可能在 repossessor 的过期检查之后、数据库工作仍在进行期间与撤销竞争。第二,因为 renew_lease 分别写租约文件与索引,一次缩短租约的续约可能让索引比租约文件更晚,回收会一直延迟到索引上的截止时间;索引报告为活跃的条目,扫描不会去读租约文件。 原文明确说明:关闭其中任何一项都需要一个独立的协议变更来协调两次写入,并需要带 SQL 槽位记账的租约删除,目标 claim protocol 仍未实现。前置部署 Forward Deployed Engineer(FDE)如果依赖文件系统后端的并发控制做容量约束,应把这两条当成已知残留风险写进交付说明,而不是当成已解决。 ## 七、修复五:WatcherThreadCancelScope 的超时注入竞态 问题现象是 xdist worker 崩溃,而且报错落在下一个测试上,典型的假线索。PR 23089 处理的是 worker 线程上 as_completed(..., timeout=...) 的超时实现。 根因是异常注入没有与作用域退出建立互斥。Worker 线程上的 as_completed 超时由 WatcherThreadCancelScope 实现,enforcer 线程等超时到期后,用 PyThreadState_SetAsyncExc 把 CancelledError 注入受管线程;原文指出没有任何机制阻止 enforcer 在受管线程已经离开作用域之后仍然注入。当超时到期与作用域退出同时发生,异常会落在作用域之外的任意指令上,失败的那次运行里它落在了一个持锁的 logging handler 内。结果是 test_as_completed_with_timeout_from_worker_thread 里的 worker 线程永不结束,紧随其后的 test_as_completed_with_zero_timeout_yields_completed_future 被报为崩溃点。 本地复现条件是 CPU 争用加上把 PREFECT_LOGGING_INTERNAL_LEVEL 设为 DEBUG,此时受管线程阻塞在 __exit__ 里 join enforcer,而 enforcer 正在注入异常。 修复方式是给 WatcherThreadCancelScope 增加 _exit_lock 与 _exited 标志。enforcer 仍然在超时到期时立刻把作用域标记为已取消并运行取消回调,而且不持有任何锁;只有异常投递这一步与作用域退出串行化,并且当受管线程已经离开作用域时直接跳过投递。 影响面值得单独说明:凡是自托管部署里在非主线程上使用带 timeout 的 as_completed,都可能受到这个竞态影响,而它的表现是进程内崩溃、没有可读的自有报错。前置部署 Forward Deployed Engineer(FDE)排查这类间歇性 worker 崩溃时,应优先怀疑异步异常注入与作用域退出的时序,而不是先怀疑被测代码。 ## 八、其余值得扫一眼的条目 PR 23153 修的是内联子流在父流程重试时丢失重试预算:子流上一次已完成但未持久化结果时,会在同一 flow-run ID 下重新执行,累计的 run_count 使它跳过自己配置的重试次数。修复后子流拿到全新的 run,示例从失败变为 A:1 到 B:1 再到 B:2。代价是已完成子流的替换 run 会有新的 flow-run ID,按 flow-run ID 作键的嵌套任务缓存不会延续;持久化过结果的已完成子流仍然复用结果,进行中的子流在进程重启后仍然重新挂接。 PR 23188 修的是用 result_storage=Path("./custom-storage") 指定本地存储时的读取失败:写入正常,读回时报路径在 basepath 之外,默认 basepath 是 /home/user/.prefect/storage。根因是结果经未保存的 LocalFileSystem 写出时只记了绝对路径作为 storage_key、storage_block_id 为空,读取时没有 block id 可解析就回退到默认本地存储,于是绝对路径被判定越界。修复在 ResultStore._from_metadata 里补了无 block id 分支:storage_key 为绝对路径时用 LocalFileSystem(basepath=父目录) 承接。 PR 23170 修的是内容寻址的 include_files sidecar 在 Azure Blob 上重复或并发上传时抛 ResourceExistsError:ZipBuilder 会有意复用同一把键(形如 files/<sha256>.zip),而 Azure 上传路径此前用的是默认的不覆盖上传。修复为传 overwrite=True,与包内 S3 与 GCS 上传器以及其余 Azure 上传路径保持一致。原文自述未在真实 Azure 账号上复现,只用单元测试覆盖了重复上传与 5 个并发上传同一把键。 PR 23160 修的是失败任务的异常是否落盘在两套引擎上不一致:同步引擎无条件写入,异步引擎漏传参数从而默认不写。后果是 API 重新加载到的状态带着指向从未写入文件的 ResultRecordMetadata,调用 aresult() 抛的是路径不存在的 ValueError,而不是原始异常。 PR 23180 修的是一个日志格式缺陷:超限事件的告警里 %s 没有对应位置参数,长度被放进 extra 的 length 字段,于是日志只打印 Refusing to publish event of size %s;改为位置传参后打印真实字节数,示例值为 30253。原文特别指出这种形态对 lint 不可见,ruff 的 PLE1206 只在至少传入一个位置参数时才触发,因此有占位符而没有任何参数的写法永远不会被查出。 PR 23143 是本次唯一的新功能:React UI 支持浏览器本地固定部署,固定项存在 localStorage 的 prefect-pinned-deployments 键下,并跨标签页通过 storage event 同步。原文解释了为什么不做服务端固定:开源服务端没有用户概念,服务端固定会被所有指向该实例的人共享。对自托管交付而言,这意味着固定状态不会在客户端之间传递。 ## 九、升级操作清单与回滚方式 前置部署 Forward Deployed Engineer(FDE)可以按下面的顺序执行,每一步都对应上面可核对的取值。 第一步,记录当前版本并备份数据库,命令是 prefect version 与数据库侧的常规备份流程。PostgreSQL 上新增索引会长时间占用 IO,虽然用了 CREATE INDEX CONCURRENTLY 允许并发读写,仍建议避开业务高峰。 第二步,升级包或镜像到 3.8.7,例如 pip install -U prefect==3.8.7。 第三步,执行数据库迁移,命令是 prefect server database upgrade。PostgreSQL 侧会以 CONCURRENTLY 方式建索引,注意这类语句不能在显式事务块里运行;SQLite 侧是普通的 CREATE INDEX IF NOT EXISTS。这条迁移不支持 --sql 离线生成,必须在能连上数据库并读取 catalog 的节点上执行。 第四步,核对索引确实可用:查询 pg_index 里 indisvalid 字段是否为 true,若为 false 就按迁移内置的 REINDEX INDEX CONCURRENTLY 模式重建;再用 EXPLAIN 复现 state 过滤加 start_time 排序的查询,确认计划里出现 Index Scan using ix_flow_run__state_type_coalesce_start_time 且没有独立的 Sort 节点。 第五步,Docker work pool 按最长容器寿命显式设置 container_wait_timeout(约束为大于 0,不设置则维持无限制)。注意它只约束 /wait 请求、不停止容器、也不约束日志流。 第六步,ECS work pool 在升级前先确认目标 VPC 默认安全组的入站规则,因为省略 securityGroups 后 ECS 会使用该默认安全组,而不是此前的空安全组。 回滚方式是降级到 3.8.6,例如 pip install prefect==3.8.6。新增的索引可以保留:多一个索引只增加写入开销,不影响正确性;若确定要删除,用 DROP INDEX CONCURRENTLY ix_flow_run__state_type_coalesce_start_time,同样不能在事务块里执行。 ## 十、原文与 PR 自述的局限 局限一,时间口径不一致。发布说明正文写 Released on September 25, 2026,而发布页两处时间戳与 Release feed 的 updated 都指向 2026-09-26。本条按发布页时间戳记为 9 月 26 日,读者按 9 月 25 日引用会与发布页对不上。 局限二,三个修复的验证强度不同。索引迁移在 PostgreSQL 16 上做过本地验证并给出完整执行计划;container_wait_timeout 只有单元测试与异常传播测试,原文没有给出让 Docker API 真实挂住的复现;Azure 幂等修复原文自述未对真实 Azure 账号复现;ECS 安全组改动依赖对 Moto 的 mock,也没有真实 AWS 账号验证。 局限三,并发租约仍有两个未修限制,即续约与撤销的残留竞态、以及续约分别写租约文件与索引导致索引可能落后,原文明确说明修复它们需要独立的协议变更。 局限四,索引迁移不支持 --sql 离线生成,因为它必须读取 catalog。对网络受限或以离线 DDL 为交付物的环境,这条迁移的执行方式需要单独规划。 局限五,原文没有给出索引在 6350000 行表上的实际构建耗时,也没有给出该索引带来的写入开销,这两个数字在本条刻意留空,不做推算。 局限六,未覆盖的场景包括 PostgreSQL 与 SQLite 之外的数据库后端、stream_output=True 时日志流的超时约束、以及 ECS 使用自定义 network_configuration 时的安全组行为(该路径不受本次改动影响)。 ## 十一、与本站既有条目的差异说明 本条主体是 Prefect 3.8.7 这一个版本的发布内容,检索本站已发布列表,标题含 Prefect、devin 与 orchestration 的条目数均为 0,不存在同版本、同平台或同团队经验的重复。 可能与既有条目产生混淆的是本站两条提及工作流的稿件,它们的落点分别是金融场景的流程编排与厂商平台能力介绍,与本文的技术面(PostgreSQL 查询计划退化、ECS 安全组回退语义、Docker 客户端超时透传、异步异常注入竞态)没有交集。本条与它们不是同一事件、不是同一技术面。 ## 原始来源 原始来源: https://github.com/PrefectHQ/prefect/releases/tag/3.8.7 https://github.com/PrefectHQ/prefect/issues/23127 https://github.com/PrefectHQ/prefect/pull/23128 https://github.com/PrefectHQ/prefect/pull/23132 https://github.com/PrefectHQ/prefect/issues/23150 https://github.com/PrefectHQ/prefect/pull/23151 https://github.com/PrefectHQ/prefect/pull/22936 https://github.com/PrefectHQ/prefect/pull/23089 https://github.com/PrefectHQ/prefect/pull/23153 https://github.com/PrefectHQ/prefect/pull/23188 https://github.com/PrefectHQ/prefect/pull/23170 https://github.com/PrefectHQ/prefect/pull/23160 https://github.com/PrefectHQ/prefect/pull/23180 https://github.com/PrefectHQ/prefect/pull/23143 https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_AwsVpcConfiguration.html