Skip to main content

aven_core/sync/persistence/
parent_liveness.rs

1use anyhow::Result;
2use serde_json::Value;
3use sqlx::{Row, SqliteConnection};
4
5use crate::change_log::op_type;
6use crate::db::{Database, begin_immediate};
7
8impl Database {
9    /// Rebuild attachment retention before server maintenance can prune old objects.
10    pub async fn reconcile_server_attachment_parents(&self) -> Result<()> {
11        let mut conn = self.acquire_writer().await?;
12        let mut tx = begin_immediate(&mut conn).await?;
13        let parents: Vec<(String, String)> = sqlx::query_as(
14            "SELECT workspace_id, task_id FROM server_task_tombstones
15             UNION SELECT workspace_id, task_id FROM server_blob_references",
16        )
17        .fetch_all(&mut *tx)
18        .await?;
19        for (workspace, task) in parents {
20            reconcile_parent(&mut tx, &workspace, &task).await?;
21        }
22        crate::attachments::lifecycle::reconcile_liveness_in_transaction(
23            &mut tx,
24            &crate::attachments::lifecycle::SystemClock,
25        )
26        .await?;
27        tx.commit().await?;
28        Ok(())
29    }
30}
31
32pub(super) async fn reconcile_parent(
33    conn: &mut SqliteConnection,
34    workspace: &str,
35    task: &str,
36) -> Result<()> {
37    let rows = sqlx::query(
38        "SELECT change_id, op_type, payload, base_version FROM changes
39         WHERE entity_type = 'task' AND entity_id = ? AND server_seq IS NOT NULL
40           AND json_extract(payload, '$.workspace_id') = ?
41           AND (op_type = 'create_task'
42                OR (op_type IN ('set_field', 'resolve_field') AND field = 'deleted'))
43         ORDER BY server_seq",
44    )
45    .bind(task)
46    .bind(workspace)
47    .fetch_all(&mut *conn)
48    .await?;
49    let mut version: Option<String> = None;
50    let mut deleted = false;
51    let mut contested = false;
52    for row in rows {
53        let id: String = row.try_get("change_id")?;
54        let operation: String = row.try_get("op_type")?;
55        let payload: Value = serde_json::from_str(row.try_get("payload")?)?;
56        if operation == op_type::CREATE_TASK {
57            if version.is_none() {
58                version = Some(
59                    payload["task_field_version_seed"]
60                        .as_str()
61                        .unwrap_or(&id)
62                        .to_owned(),
63                );
64            }
65            continue;
66        }
67        let base: Option<String> = row.try_get("base_version")?;
68        if version.is_none() || (operation != op_type::RESOLVE_FIELD && base != version) {
69            contested = true;
70            continue;
71        }
72        deleted = payload["value"].as_str() == Some("1");
73        version = Some(id);
74    }
75    // A force resolution cannot prove that other replicas have drained older writes.
76    // Retain contested parents until their attachment references are explicitly deleted.
77    let unreferenced = deleted && version.is_some() && !contested;
78    sqlx::query(
79        "INSERT INTO server_task_tombstones(workspace_id, task_id, deleted)
80         VALUES (?, ?, ?)
81         ON CONFLICT(workspace_id, task_id) DO UPDATE SET deleted = excluded.deleted",
82    )
83    .bind(workspace)
84    .bind(task)
85    .bind(unreferenced)
86    .execute(&mut *conn)
87    .await?;
88    Ok(())
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use serde_json::json;
95
96    async fn project(history: &[(&str, &str, Option<&str>, &str)]) -> bool {
97        let (_temp, mut conn) = crate::test_support::test_conn().await;
98        for (index, (id, op, base, value)) in history.iter().enumerate() {
99            let payload = if *op == op_type::CREATE_TASK {
100                json!({"workspace_id": "workspace", "task_field_version_seed": value})
101            } else {
102                json!({"workspace_id": "workspace", "value": value})
103            };
104            sqlx::query(
105                "INSERT INTO changes(change_id, client_id, local_seq, entity_type, entity_id,
106                 field, op_type, payload, base_version, created_at, server_seq)
107                 VALUES (?, 'client', ?, 'task', 'task', 'deleted', ?, ?, ?, 'now', ?)",
108            )
109            .bind(id)
110            .bind(index as i64 + 1)
111            .bind(op)
112            .bind(payload.to_string())
113            .bind(base)
114            .bind(index as i64 + 1)
115            .execute(&mut *conn)
116            .await
117            .unwrap();
118        }
119        reconcile_parent(&mut conn, "workspace", "task")
120            .await
121            .unwrap();
122        sqlx::query_scalar("SELECT deleted FROM server_task_tombstones WHERE task_id = 'task'")
123            .fetch_one(&mut *conn)
124            .await
125            .unwrap()
126    }
127
128    #[tokio::test]
129    async fn ordered_deletion_restore_and_custom_seed() {
130        let mut history = vec![
131            ("create", op_type::CREATE_TASK, None, "seed"),
132            ("delete", op_type::SET_FIELD, Some("seed"), "1"),
133        ];
134        assert!(project(&history).await);
135        history.push(("restore", op_type::SET_FIELD, Some("delete"), "0"));
136        assert!(!project(&history).await);
137        history.push(("delete2", op_type::SET_FIELD, Some("restore"), "1"));
138        assert!(project(&history).await);
139    }
140
141    #[tokio::test]
142    async fn stale_delete_and_force_resolution_keep_protection() {
143        let mut history = vec![
144            ("create", op_type::CREATE_TASK, None, "seed"),
145            ("delete", op_type::SET_FIELD, Some("seed"), "1"),
146            ("restore", op_type::SET_FIELD, Some("delete"), "0"),
147            ("stale", op_type::SET_FIELD, Some("seed"), "1"),
148        ];
149        assert!(!project(&history).await);
150        history.push(("resolve", op_type::RESOLVE_FIELD, Some("restore"), "1"));
151        assert!(!project(&history).await);
152    }
153
154    #[tokio::test]
155    async fn equal_value_conflicts_and_missing_creation_are_conservative() {
156        assert!(
157            !project(&[
158                ("create", op_type::CREATE_TASK, None, "seed"),
159                ("delete", op_type::SET_FIELD, Some("seed"), "1"),
160                ("other", op_type::SET_FIELD, Some("seed"), "1"),
161            ])
162            .await
163        );
164        assert!(!project(&[("delete", op_type::SET_FIELD, None, "1")]).await);
165        assert!(!project(&[]).await);
166    }
167}