首页
/ 深入解析 Sentry 异步删除子系统:从 ScheduledDeletion 调度到级联删除任务

深入解析 Sentry 异步删除子系统:从 ScheduledDeletion 调度到级联删除任务

2026-09-08 14:16:30作者:舒璇辛Bertina

导读

当你在 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 等)都挂靠在组织或项目之下。当应用新增一个模型时,就必须考虑:这条记录所属的组织或项目被删除后,它应当如何被清理?

删除子系统主要解决三个工程问题:

  1. 异步化与批量化:一次性同步删除海量关联行会长时间占用数据库连接并拖垮请求,因此删除被拆成可恢复的异步任务,逐 chunk 推进。
  2. 可重试与可靠性:删除任务可能因一次发布(deploy)被打断,或因新增外键关系、数据库故障而失败。子系统用 PostgreSQL 表跟踪每次删除的状态(是否 in_progress、计划时间等),从而支持失败后重新拾取。
  3. 级联策略可定制:不同模型的删除行为差异很大——有的需要逐条触发 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_controlreattempt_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_progressFalse 更新为 True 的进程才负责执行该删除,这正对应 README 中"查询过去到期、且未在进行中的任务"的描述。

实际的删除处理器 run_deletion / run_deletion_controlscheduled.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 默认 30hours 默认 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.Monitormonitors.Monitor)翻译到新的 app_label,保证旧调度任务仍能被正确还原与执行。

删除任务:两种内置基础策略

README 指出删除系统提供两个基类来覆盖常见场景:

  • ModelDeletionTask:逐条获取记录并分别删除每个实例。
    • 适合依赖 Django signals、或存在子关联的模型(例如删除一条 Group 时逐条触发 post_delete 让下游联动)。
    • 当某个模型没有显式注册删除任务时,它就是默认实现
  • BulkModelDeletionTask:用单条查询批量删除记录。
    • 适合没有任何关联关系的"叶子"模型(例如 GroupAssigneeProjectKeyEnvironmentProject 等中间表),效率最高。

对照源码 base.py 可以看到二者在设计上的具体差异:

默认 chunk 大小不同。 BaseDeletionTask.DEFAULT_CHUNK_SIZE = 100base.py),而 BulkModelDeletionTask 将其重写为 10000base.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 要求按以下两步注册自定义删除任务:

  1. 将删除任务子类加入 sentry.deletions.defaults
  2. sentry.deletions.__init__ 的默认管理器映射中注册该任务

当前仓库中,defaults 目录(src/sentry/deletions/defaults/)已包含 40+ 个模型的删除任务定义(如 organization.pyproject.pygroup.pyalertrule.pymonitor.pyrule.pyrelease.py 等),并通过 defaults/init.py 统一导出。

而映射注册位于 src/sentry/deletions/init.pyload_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.pyget() 的解析逻辑也印证了这一点: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
  • 构造参数 queryorder_byquery_limitchunk_sizeactor_idtransaction_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.pyOrganizationDeletionTask.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):传统逐级批删。 GroupDeletionTaskdefaults/group.py)以 GROUP_CHUNK_SIZE = 100 的粒度"准批量"推进:它会一次性为列表中的所有 group 组装子关系(涵盖 GroupHashGroupAssigneeActivityUserReportEventAttachmentDIRECT_GROUP_RELATED_MODELS + ADDITIONAL_GROUP_RELATED_MODELS 中声明的模型),并按 Error / IssuePlatform 分类分别投递 ErrorEventsDeletionTaskIssuePlatformEventsDeletionTask 去清理事件存储中的 Event 数据。这符合 README"批处理每个子级(如 Event)"的描述——因为每一条 group 都有独立的事件负载需要异步清掉。

删除 Project:跳过 Group 任务、直接批量清理间接后代。 而当删除一个 Project 时,并不会把事件交给已注册的 Group 任务逐组处理,而是采取更高效的路径:直接批量删除它的间接后代(如 Event)。从源码看,ProjectDeletionTask.get_child_relations()defaults/project.py)把 20 余种子关联分门别类地列出来:像 ProjectKeyGroupAssigneeEnvironmentProject 这类模型直接挂 BulkModelDeletionTask 一次批量删掉;GroupRuleMonitorActivity 等则走常规逐级任务。这样设计的原因是:既然整个 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 一类的校验测试会失败。

需要继续深入时,建议按以下路径阅读仓库源码:

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389