Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

135 linesCodeBlameRaw
1//! Durable cleanup, committed in the same transaction as identity deletion.
2//! Each target is idempotent: a timeout after success may call it again.
3use g1t_contracts::events::{NewEvent, Publish};
4use g1t_contracts::time::rfc3339;
5use g1t_kit::now_ms;
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8use worker::{D1PreparedStatement, Result};
9
10use crate::Identity;
11
12#[derive(Deserialize, Serialize)]
13pub(crate) struct PurgeJob {
14 subject_id: String,
15 kind: String,
16 target: String,
17 payload: String,
18 created_at: String,
19 completed_at: Option<String>,
20 attempts: u32,
21 retry_at: String,
22 last_error: Option<String>,
23}
24
25impl Identity {
26 pub(crate) fn purge_job_statements(
27 &self,
28 kind: &str,
29 id: &str,
30 payload: Value,
31 ) -> Result<Vec<D1PreparedStatement>> {
32 let now = rfc3339(now_ms());
33 let payload = payload.to_string();
34 ["AVATAR", "CHAT", "NOTIFY", "ARTIFACTS", "EVENTS"]
35 .into_iter()
36 .map(|target| {
37 self.db.prepare(include_str!("purge_job.sql")).bind(&[
38 id.into(),
39 kind.into(),
40 target.into(),
41 payload.as_str().into(),
42 now.as_str().into(),
43 ])
44 })
45 .collect()
46 }
47
48 pub(crate) async fn run_purge_jobs(&self) -> Result<()> {
49 let jobs = self.db.prepare("SELECT * FROM purge_jobs WHERE completed_at IS NULL AND retry_at <= ? ORDER BY retry_at LIMIT 25")
50 .bind(&[rfc3339(now_ms()).into()])?.all().await?.results::<PurgeJob>()?;
51 for job in jobs {
52 let result = self.run_purge_job(&job).await;
53 // Do not persist remote response bodies: they may contain personal data or secrets.
54 let now = rfc3339(now_ms());
55 if matches!(result, Ok(true)) {
56 self.db.prepare("UPDATE purge_jobs SET completed_at = ?3, attempts = attempts + 1, last_error = NULL, payload = '{}' WHERE subject_id = ?1 AND target = ?2 AND completed_at IS NULL")
57 .bind(&[job.subject_id.as_str().into(), job.target.as_str().into(), now.as_str().into()])?.run().await?;
58 } else if matches!(result, Ok(false)) {
59 self.db.prepare("UPDATE purge_jobs SET attempts = attempts + 1, retry_at = ?3, last_error = NULL WHERE subject_id = ?1 AND target = ?2 AND completed_at IS NULL")
60 .bind(&[job.subject_id.as_str().into(), job.target.as_str().into(), rfc3339(now_ms() + 900_000).into()])?.run().await?;
61 } else {
62 let retry = rfc3339(now_ms() + 900_000 * (1_u64 << job.attempts.min(6)));
63 self.db.prepare("UPDATE purge_jobs SET attempts = attempts + 1, retry_at = ?3, last_error = 'Cleanup failed; retry pending' WHERE subject_id = ?1 AND target = ?2 AND completed_at IS NULL")
64 .bind(&[job.subject_id.as_str().into(), job.target.as_str().into(), retry.into()])?.run().await?;
65 worker::console_error!(
66 "purge target {} failed for {}; retry pending",
67 job.target,
68 job.subject_id
69 );
70 }
71 }
72 Ok(())
73 }
74
75 async fn run_purge_job(&self, job: &PurgeJob) -> Result<bool> {
76 let payload: Value = serde_json::from_str(&job.payload)?;
77 if job.target == "AVATAR" {
78 self.forget_avatar(
79 payload
80 .get("avatar")
81 .and_then(Value::as_str)
82 .map(str::to_owned),
83 )
84 .await?;
85 return Ok(true);
86 }
87 let service = self.env.service(&job.target)?;
88 if job.target == "EVENTS" {
89 let (kind, data) = if job.kind == "user" {
90 (
91 "user.deleted",
92 json!({"userId": job.subject_id, "username": payload["username"]}),
93 )
94 } else {
95 (
96 "workspace.deleted",
97 json!({"workspaceId": job.subject_id, "slug": payload["slug"]}),
98 )
99 };
100 let _: Value = g1t_kit::call(
101 &service,
102 "publish",
103 &Publish {
104 events: vec![NewEvent {
105 kind,
106 source: "identity",
107 repo_id: None,
108 actor: None,
109 data,
110 }],
111 },
112 )
113 .await?;
114 } else {
115 let result: Value = g1t_kit::call(&service, "purge_personal_data", &json!({
116 "kind": job.kind, "id": job.subject_id, "username": payload["username"], "slug": payload["slug"],
117 "old_slugs": payload["old_slugs"], "members": payload["members"],
118 })).await?;
119 if result.get("pending") == Some(&Value::Bool(true)) {
120 return Ok(false);
121 }
122 if result.get("ok") != Some(&Value::Bool(true)) {
123 return Err(worker::Error::RustError(
124 "Cleanup did not confirm success".into(),
125 ));
126 }
127 }
128 Ok(true)
129 }
130
131 pub(crate) async fn pending_purge_jobs(&self) -> Result<Vec<PurgeJob>> {
132 self.db.prepare("SELECT subject_id, kind, target, '{}' AS payload, created_at, completed_at, attempts, retry_at, last_error FROM purge_jobs WHERE completed_at IS NULL ORDER BY created_at LIMIT 500")
133 .all().await?.results()
134 }
135}