深入解析 Sentry 异步删除子系统:从 ScheduledDeletion 调度到级联删除任务
导读
当你在 Sentry 中删除一个组织、项目或 Issue(Group)时,涉及的数据并非只删一行那么简单——它们可能横跨数十张 PostgreSQL 关联表、事件存储(Eventstore/Nodestore)甚至外部服务。Sentry 在 src/sentry/deletions/README.md 中定义了一套完整的异步删除子系统(deletion subsystem):它通过 PostgreSQL 记录删除任务与状态,在后台按计划批量执行,并支持级联删除、失败重试与删除取消。读完本文,你将掌握该子系统的整体工作流、调度与重试机制、两大基础删除任务的区别、如何为新增模型接入自定义删除逻辑,以及如何直接通过 deletions 管理器同步执行删除。
子系统概览:为什么需要"删除子系统"
在 Sentry 的数据模型中,Organization → Project → Group(Issue)→ Event 层层嵌套,且大量业务模型(AlertRule、Rule、Monitor、SentryApp、Release 等)都挂靠在组织或项目之下。当应用新增一个模型时,就必须考虑:这条记录所属的组织或项目被删除后,它应当如何被清理?
删除子系统主要解决三个工程问题:
- 异步化与批量化:一次性同步删除海量关联行会长时间占用数据库连接并拖垮请求,因此删除被拆成可恢复的异步任务,逐 chunk 推进。
- 可重试与可靠性:删除任务可能因一次发布(deploy)被打断,或因新增外键关系、数据库故障而失败。子系统用 PostgreSQL 表跟踪每次删除的状态(是否 in_progress、计划时间等),从而支持失败后重新拾取。
- 级联策略可定制:不同模型的删除行为差异很大——有的需要逐条触发 Django signal(如依赖子关系清理的模型),有的可以单条 SQL 批量删除(无关联的叶子模型),有的还需要联动事件存储与外部分组服务(Seer)。
调度执行核心:Taskbroker 定时任务与重试机制
README 指出两个关键调度入口:
run_scheduled_deletions()每 15 分钟执行一次,它会查询所有"计划时间已到、且当前未被处理(不在 in_progress)"的删除任务,并为每一条任务派生(spawn)对应的删除处理任务。reattempt_deletions()每天执行一次,用来清理陈旧任务:它会清掉那些"卡住"的旧任务的in_progress标记,让它们能被下一次 15 分钟调度重新拾取。
对照当前仓库,这一节早已演化成 Control / Cell 双轨实现。在 src/sentry/deletions/tasks/scheduled.py 中,实际存在一组配对任务:
run_scheduled_deletions_control(处理 Control 侧ScheduledDeletion)与run_scheduled_deletions(处理 Cell 侧CellScheduledDeletion);reattempt_deletions_control与reattempt_deletions同理,二者共用_reattempt_deletions()。
其中 scheduled.py 中 _reattempt_deletions 的实现细节是:只重置 in_progress=True 且 date_scheduled 早于当前时间 6 小时以上 的任务——即"若删除进行中且计划时间已过去 6 小时,可认为上次任务已经死亡/失败",随后把 in_progress 翻转为 False,使任务在下一个周期被重新拾取。
def _reattempt_deletions(model_class: type[BaseScheduledDeletion]) -> None:
queryset = model_class.objects.filter(
in_progress=True, date_scheduled__lte=timezone.now() - timedelta(hours=6)
)
queryset.update(in_progress=False)
而调度执行端 _run_scheduled_deletions()(scheduled.py)则通过原子的条件更新来抢占任务,避免同一删除被多个 worker 重复执行:
queryset = model_class.objects.filter(in_progress=False, date_scheduled__lte=timezone.now())
for item in queryset:
with transaction.atomic(router.db_for_write(model_class)):
affected = model_class.objects.filter(
id=item.id,
in_progress=False,
).update(in_progress=True)
if not affected:
continue
process_task.delay(deletion_id=item.id)
即:只有成功把 in_progress 从 False 更新为 True 的进程才负责执行该删除,这正对应 README 中"查询过去到期、且未在进行中的任务"的描述。
实际的删除处理器 run_deletion / run_deletion_control(scheduled.py)还配置了:
- 处理时限:
processing_deadline_duration分别为 15 分钟(control)与 20 分钟(cell); - 重试策略:最多重试 5 次(
MAX_RETRIES = 5),每次间隔 5 分钟,超过次数后丢弃任务;对DeleteAborted(删除被取消)异常不重试且静默处理。
任务主流程 _run_deletion() 的逻辑是:取出 ScheduledDeletion → 通过 get_instance() 还原真实对象 → 用 deletions 管理器拿到对应删除任务 → 调用 task.should_proceed(instance) 校验(不通过则直接删除调度记录并终止,这就是"可取消删除"的落地)→ 首轮发送 pending_delete signal → 反复调用 task.chunk(),只要返回 True 就继续投递下一轮子任务,直到 chunk() 返回 False(全部删完)才删除调度记录本身。
调度删除:ScheduledDeletion 模型
对绝大多数应用代码而言,进入删除子系统的入口是 ScheduledDeletion 模型——通过它创建一条"未来某个时间点执行"的删除任务。README 给出了最核心的用法:
from sentry.deletions.models.scheduleddeletion import ScheduledDeletion
ScheduledDeletion.schedule(organization, days=1, hours=2)
上面这行代码会把该 organization 调度为在 1 天零 2 小时后 被删除。从源码看,schedule() 是 BaseScheduledDeletion 上的类方法,其完整签名是:
@classmethod
def schedule(
cls, instance: Model, days: int = 30, hours: int = 0, data: Any = None, actor: Any = None
) -> Self:
关键行为说明:
days默认 30,hours默认 0,即默认 30 天后执行;README 示例显式传参即可自定义更短的窗口。- 使用
update_or_create以(app_label, model_name, object_id)为唯一键,重复调度同一对象只会更新其计划时间,而不会产生重复任务。 actor会被记录到actor_id字段(用于删除审计);data存入 JSONField,可携带自定义上下文。- 该方法会校验模型的
silo_limit:若当前 Silo 模式下无法操作该模型,则直接抛出SiloLimit.AvailabilityError,防止在错误的 silo 中调度删除。
模型字段层面(scheduleddeletion.py),一次删除任务记录包含:
| 字段 | 含义 |
|---|---|
guid |
32 位 UUID hex,删除任务的唯一交易号(transaction_id) |
app_label / model_name |
被删除模型的 Django 定位信息 |
object_id |
被删除记录主键 |
date_added |
创建时间 |
date_scheduled |
计划执行时间(默认 now() + 30 days) |
actor_id |
发起删除的用户(可空) |
data |
JSON 扩展字段 |
in_progress |
是否正在处理中(调度与重试机制的核心开关) |
此外,当前仓库因多区域(silo)架构将这一模型拆成了两张物理表(scheduleddeletion.py):Control 侧的 ScheduledDeletion(表名 sentry_scheduleddeletion,由 control_silo_model 装饰)与 Cell 侧的 CellScheduledDeletion(表名 sentry_regionscheduleddeletion,由 cell_silo_model 装饰)。README 写作时以单一模型为例,接入时需按被删除模型所在 silo 选择对应的类——可以推断:monolith 模式下二者都会照常被处理。get_model() 内部还会经过 RELOCATED_MODELS 映射,把历史上"模型应用已迁移"的旧任务(例如 sentry.Monitor → monitors.Monitor)翻译到新的 app_label,保证旧调度任务仍能被正确还原与执行。
删除任务:两种内置基础策略
README 指出删除系统提供两个基类来覆盖常见场景:
ModelDeletionTask:逐条获取记录并分别删除每个实例。- 适合依赖 Django signals、或存在子关联的模型(例如删除一条
Group时逐条触发post_delete让下游联动)。 - 当某个模型没有显式注册删除任务时,它就是默认实现。
- 适合依赖 Django signals、或存在子关联的模型(例如删除一条
BulkModelDeletionTask:用单条查询批量删除记录。- 适合没有任何关联关系的"叶子"模型(例如
GroupAssignee、ProjectKey、EnvironmentProject等中间表),效率最高。
- 适合没有任何关联关系的"叶子"模型(例如
对照源码 base.py 可以看到二者在设计上的具体差异:
默认 chunk 大小不同。 BaseDeletionTask.DEFAULT_CHUNK_SIZE = 100(base.py),而 BulkModelDeletionTask 将其重写为 10000(base.py)——批删模型单轮可处理万行,逐条删除模型则以 100 为粒度精细推进。
chunk() 的行为不同。 ModelDeletionTask.chunk()(base.py)在 while 循环中反复拉取 query 命中的记录并调用 delete_bulk(),直到耗尽 chunk_size 配额:一旦某轮查不到更多行就返回 False(已删完),否则返回 True("还有更多工作",需要调度器再次投递)。BulkModelDeletionTask.chunk()(base.py)则直接调用 bulk_delete_objects()(在 unguarded_write 与写库路由保护下执行原始批量 DELETE),同样以返回值指示是否还有剩余行。
delete_bulk() 的级联编排。 BaseDeletionTask.delete_bulk()(base.py)先根据 mark_in_progress 决定是否把实例状态置为 DELETION_IN_PROGRESS,再分别从 get_child_relations_bulk()(批量视角)与每个实例的 get_child_relations()(单实例视角)收集子关系,用 _delete_children() 递归调用对应子任务的 chunk(),全部子关系清理完后再删除自身。
内置限速。 ModelDeletionTask.chunk() 中每轮都会调用 _throttle_deletes()(base.py):若任务配置了 rate_limit_option(指向一个整型 option 名称),系统会基于漏桶限速器(LeakyBucketRateLimiter,drip_rate 取 option 值、burst 取 max(rate, query_limit))按删除行数节流吞吐,避免海量删除压垮数据库。
为新增模型接入删除任务(自定义扩展指南)
当你的模型存在需要额外清理的子关联、或需要覆盖默认删除行为时,README 要求按以下两步注册自定义删除任务:
- 将删除任务子类加入
sentry.deletions.defaults - 在
sentry.deletions.__init__的默认管理器映射中注册该任务
当前仓库中,defaults 目录(src/sentry/deletions/defaults/)已包含 40+ 个模型的删除任务定义(如 organization.py、project.py、group.py、alertrule.py、monitor.py、rule.py、release.py 等),并通过 defaults/init.py 统一导出。
而映射注册位于 src/sentry/deletions/init.py 的 load_defaults():它调用 manager.register(Model, TaskClass) 把模型绑定到具体任务,例如:
manager.register(models.Group, defaults.GroupDeletionTask)
manager.register(models.Organization, defaults.OrganizationDeletionTask)
manager.register(models.Project, defaults.ProjectDeletionTask)
manager.register(models.Activity, BulkModelDeletionTask)
模块级还暴露了与 manager 一一对应的便捷函数(init.py):get()、register()、exec_sync()、exec_sync_many()。默认管理器由 get_manager() 以 DeletionTaskManager(default_task=ModelDeletionTask) 构建,并用 functools.cache 缓存——任何未显式注册的模型都会回退到 ModelDeletionTask 默认实现,这正是 README 所说"未指定删除任务时的默认策略"。
manager.py 中 get() 的解析逻辑也印证了这一点:self.tasks.get(model, self.default_task)——先查精确注册表,未命中则落到 default_task。
实现子类时通常需要覆写的钩子
在 base.py 中,BaseDeletionTask 预留了清晰的扩展点:
chunk():核心推进逻辑(一般继承ModelDeletionTask即可,无需重写);should_proceed(instance):根任务在执行前调用,用于支持删除被取消(详见下节);get_child_relations(instance)/get_child_relations_bulk(instance_list):返回该实例的子关联列表(元素为ModelRelation(model, query, task)或裸BaseRelation),是级联删除的"配方"来源;filter_relations():配合构造参数skip_models剔除不需要处理的子模型;mark_deletion_in_progress():默认把带status字段的实例批量更新为ObjectStatus.DELETION_IN_PROGRESS;- 构造参数
query、order_by、query_limit、chunk_size、actor_id、transaction_id等用于定制单个任务的查询范围与执行方式。
取消删除:should_proceed 钩子
如果某个记录已被调度删除、但希望之后能够取消,README 的指导是:让删除任务实现 should_proceed 钩子。典型实现为:
def should_proceed(self, instance: ModelT) -> bool:
return instance.status in {
ObjectStatus.PENDING_DELETION,
ObjectStatus.DELETION_IN_PROGRESS
}
含义是:只有当记录当前状态仍属于"待删除/删除中"时才继续执行删除。仓库中最直接的落地例子是 defaults/organization.py 的 OrganizationDeletionTask.should_proceed()——它只删除那些没有被撤销删除(undeleted) 的组织:
class OrganizationDeletionTask(ModelDeletionTask[Organization]):
def should_proceed(self, instance: Organization) -> bool:
return instance.status in {
OrganizationStatus.PENDING_DELETION,
OrganizationStatus.DELETION_IN_PROGRESS,
}
其配套支持来自模型侧的 cancel():它会以 (model_name, object_id) 查找一条 in_progress=False 的调度记录并删除它。因此整个"可取消"闭环是:外部 API 先改变记录状态(例如恢复为正常状态)→ 定时任务到期执行 should_proceed() 发现状态不符 → 调度记录被移除,删除不会发生。README 特别强调:当删除被该钩子取消时,对应的 ScheduledDeletion 行会被删除。
直接使用 Deletions 管理器(同步删除)
多数情况下删除走异步调度,但子系统同样支持在代码里同步驱动删除任务。README 以"删除一个 organization"为例给出了如下写法:
from sentry import deletions
task = deletions.get(model=Organization, query={})
work = True
while work:
work = task.chunk()
要点拆解:
deletions.get(model=..., query=...)会通过默认 manager 解析出该模型对应的删除任务并实例化(query为定位待删记录的过滤条件;exec_sync/exec_sync_many会把它封装成query={"id": instance.id}/{"id__in": [...]}的完整循环,见 manager.py);- 循环调用
task.chunk(),每次执行一个 chunk 的数据清理;返回值True表示仍有剩余工作,False表示实体已彻底移除,据此决定是否继续循环。
需要说明的是,query={} 这种"全表/全量匹配"的写法在示例中意在展示接口形态;实际生产路径(如 run_deletion)总是携带 query={"id": deletion.object_id} 精确定位单条记录(见 scheduled.py)。
级联行为取决于"对象类型"
README 强调系统针对 Organization 有默认实现,能够高效地级联删除——该行为会因输入对象不同而变化,因为任务可以为自己的子级覆写行为。两个典型对照:
删除 Group(Issue):传统逐级批删。 GroupDeletionTask(defaults/group.py)以 GROUP_CHUNK_SIZE = 100 的粒度"准批量"推进:它会一次性为列表中的所有 group 组装子关系(涵盖 GroupHash、GroupAssignee、Activity、UserReport、EventAttachment 等 DIRECT_GROUP_RELATED_MODELS + ADDITIONAL_GROUP_RELATED_MODELS 中声明的模型),并按 Error / IssuePlatform 分类分别投递 ErrorEventsDeletionTask 与 IssuePlatformEventsDeletionTask 去清理事件存储中的 Event 数据。这符合 README"批处理每个子级(如 Event)"的描述——因为每一条 group 都有独立的事件负载需要异步清掉。
删除 Project:跳过 Group 任务、直接批量清理间接后代。 而当删除一个 Project 时,并不会把事件交给已注册的 Group 任务逐组处理,而是采取更高效的路径:直接批量删除它的间接后代(如 Event)。从源码看,ProjectDeletionTask.get_child_relations()(defaults/project.py)把 20 余种子关联分门别类地列出来:像 ProjectKey、GroupAssignee、EnvironmentProject 这类模型直接挂 BulkModelDeletionTask 一次批量删掉;Group、Rule、Monitor、Activity 等则走常规逐级任务。这样设计的原因是:既然整个 project 都要消失,就无需再为每个 Group 单独调度事件删除与级联——用更少的查询把其所有后代行成批清掉即可。
设计要点小结与扩展阅读
- 可靠性优先:所有删除任务落库为
ScheduledDeletion/CellScheduledDeletion记录,配合 15 分钟调度与每日重试清理,天然容忍发布中断与单次任务失败。 - 按数据形态选择策略:无关联的叶子数据用
BulkModelDeletionTask(默认 chunk 10000、单查询批删);依赖 signal/子关系的用ModelDeletionTask(默认 chunk 100);两者都以chunk()返回值作为"是否还有工作"的续跑信号。 - 删除可取消:通过
should_proceed()+ 对象状态(如PENDING_DELETION/DELETION_IN_PROGRESS)实现;取消时调度行会被清理,见ScheduledDeletion.cancel()。 - 模型扩展有纪律:新增模型接入只需两步(defaults 子类 + manager 注册);group 相关模型甚至由测试强制约束——defaults/group.py 的模块注释明确提醒:任何带
group_id外键的新模型必须登记到_GROUP_RELATED_MODELS列表中,否则 tests/sentry/deletions/test_validate_group_related_models.py 一类的校验测试会失败。
需要继续深入时,建议按以下路径阅读仓库源码:
- 整体说明文档:src/sentry/deletions/README.md
- 任务基类与限速实现:src/sentry/deletions/base.py
- 调度管理器与默认注册表:src/sentry/deletions/manager.py、src/sentry/deletions/init.py
- 调度模型与定时任务:src/sentry/deletions/models/scheduleddeletion.py、src/sentry/deletions/tasks/scheduled.py
- 三份代表性级联"配方":defaults/organization.py、defaults/project.py、defaults/group.py
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00