runifold_store_postgres/conversation/
durable.rs1use runifold_agent::{
4 ConversationStoreError, ConversationStoreFuture, ConversationVersion,
5 DurableConversationCommit, DurableConversationStore,
6};
7use runifold_core::{
8 Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, CheckpointStore,
9};
10use serde_json::Value;
11
12use super::{
13 PostgresConversationStore,
14 support::{
15 conflict_error, conversation_uuid, decode_version, encode_error, storage_error, to_i64,
16 validate_append,
17 },
18};
19
20impl CheckpointStore for PostgresConversationStore {
21 fn load(&self, id: CheckpointId) -> Result<Checkpoint, CheckpointError> {
22 let sql = format!(
23 "SELECT record_json FROM {}_checkpoints WHERE checkpoint_id = $1",
24 self.table
25 );
26 let row = self
27 .blocking()
28 .execute(move |client| client.query_opt(&sql, &[&id.as_uuid()]))
29 .map_err(|_| checkpoint_worker())?
30 .map_err(checkpoint_storage)?
31 .ok_or_else(|| checkpoint_not_found(id))?;
32 serde_json::from_value(row.get::<_, Value>("record_json")).map_err(|_| {
33 CheckpointError::new(
34 CheckpointErrorKind::InvalidPayload,
35 "PostgreSQL checkpoint payload is invalid",
36 )
37 })
38 }
39
40 fn compare_and_swap(
41 &self,
42 checkpoint: &Checkpoint,
43 expected_revision: Option<u64>,
44 ) -> Result<(), CheckpointError> {
45 let revision = checkpoint_i64(checkpoint.revision)?;
46 let expected = expected_revision.map(checkpoint_i64).transpose()?;
47 let record = serde_json::to_value(checkpoint).map_err(|_| {
48 CheckpointError::new(
49 CheckpointErrorKind::InvalidPayload,
50 "checkpoint could not be encoded",
51 )
52 })?;
53 let table = format!("{}_checkpoints", self.table);
54 let checkpoint_id = checkpoint.id;
55 self.blocking()
56 .execute(move |client| -> Result<(), CheckpointError> {
57 let mut transaction = client.transaction().map_err(checkpoint_storage)?;
58 match expected {
59 None if revision == 0 => {
60 let changed = transaction
61 .execute(
62 &format!(
63 "INSERT INTO {table} (checkpoint_id, revision, record_json) \
64 VALUES ($1, $2, $3) \
65 ON CONFLICT (checkpoint_id) DO NOTHING"
66 ),
67 &[&checkpoint_id.as_uuid(), &revision, &record],
68 )
69 .map_err(checkpoint_storage)?;
70 if changed != 1 {
71 return Err(checkpoint_conflict(checkpoint_id));
72 }
73 }
74 Some(expected)
75 if expected.checked_add(1).is_some_and(|next| revision == next) =>
76 {
77 let changed = transaction
78 .execute(
79 &format!(
80 "UPDATE {table} SET revision = $1, record_json = $2 \
81 WHERE checkpoint_id = $3 AND revision = $4"
82 ),
83 &[&revision, &record, &checkpoint_id.as_uuid(), &expected],
84 )
85 .map_err(checkpoint_storage)?;
86 if changed != 1 {
87 let exists = transaction
88 .query_opt(
89 &format!("SELECT 1 FROM {table} WHERE checkpoint_id = $1"),
90 &[&checkpoint_id.as_uuid()],
91 )
92 .map_err(checkpoint_storage)?
93 .is_some();
94 return Err(if exists {
95 checkpoint_conflict(checkpoint_id)
96 } else {
97 checkpoint_not_found(checkpoint_id)
98 });
99 }
100 }
101 _ => return Err(checkpoint_conflict(checkpoint_id)),
102 }
103 transaction.commit().map_err(checkpoint_storage)
104 })
105 .map_err(|_| checkpoint_worker())?
106 }
107}
108
109impl DurableConversationStore for PostgresConversationStore {
110 fn commit_durable_turn(
111 &self,
112 command: DurableConversationCommit,
113 ) -> ConversationStoreFuture<'_, Result<ConversationVersion, ConversationStoreError>> {
114 Box::pin(async move {
115 validate_append(&command.append)?;
116 let expected_checkpoint = to_i64(command.expected_checkpoint_revision)?;
117 let checkpoint_revision = to_i64(command.checkpoint.revision)?;
118 if expected_checkpoint
119 .checked_add(1)
120 .is_none_or(|next| next != checkpoint_revision)
121 {
122 return Err(conflict_error(
123 "durable conversation checkpoint is not the expected successor",
124 ));
125 }
126 let messages = serde_json::to_value(&command.append.messages).map_err(encode_error)?;
127 let checkpoint = serde_json::to_value(&command.checkpoint).map_err(encode_error)?;
128 let mut client = self.transaction_client.lock().await;
129 let transaction = client.transaction().await.map_err(storage_error)?;
130 let conversation_sql = format!(
131 "UPDATE {table} SET version = version + 1, updated_at = clock_timestamp() \
132 WHERE conversation_id = $1 AND namespace = $2 AND version = $3 \
133 AND version < 9223372036854775807 RETURNING version",
134 table = self.table
135 );
136 let Some(version_row) = transaction
137 .query_opt(
138 &conversation_sql,
139 &[
140 &conversation_uuid(command.append.conversation_id),
141 &command.namespace.as_str(),
142 &to_i64(command.append.expected_version.get())?,
143 ],
144 )
145 .await
146 .map_err(storage_error)?
147 else {
148 return Err(conflict_error(
149 "durable conversation transcript version precondition failed",
150 ));
151 };
152 let transcript_sql = format!(
153 "WITH base AS (\
154 SELECT COALESCE(MAX(sequence), 0) AS last_sequence \
155 FROM {table}_transcript WHERE conversation_id = $1\
156 ) \
157 INSERT INTO {table}_transcript (conversation_id, sequence, message) \
158 SELECT $1, base.last_sequence + payload.ordinality, payload.message \
159 FROM base CROSS JOIN LATERAL \
160 jsonb_array_elements($2::JSONB) WITH ORDINALITY AS payload(message, ordinality)",
161 table = self.table
162 );
163 let inserted = transaction
164 .execute(
165 &transcript_sql,
166 &[
167 &conversation_uuid(command.append.conversation_id),
168 &messages,
169 ],
170 )
171 .await
172 .map_err(storage_error)?;
173 if inserted != u64::try_from(command.append.messages.len()).unwrap_or(u64::MAX) {
174 return Err(conflict_error(
175 "durable conversation transcript append was incomplete",
176 ));
177 }
178 let checkpoint_sql = format!(
179 "UPDATE {table}_checkpoints SET revision = $1, record_json = $2 \
180 WHERE checkpoint_id = $3 AND revision = $4",
181 table = self.table
182 );
183 let changed = transaction
184 .execute(
185 &checkpoint_sql,
186 &[
187 &checkpoint_revision,
188 &checkpoint,
189 &command.checkpoint.id.as_uuid(),
190 &expected_checkpoint,
191 ],
192 )
193 .await
194 .map_err(storage_error)?;
195 if changed != 1 {
196 return Err(conflict_error(
197 "durable conversation checkpoint precondition failed",
198 ));
199 }
200 transaction.commit().await.map_err(storage_error)?;
201 decode_version(version_row.get("version"))
202 })
203 }
204}
205
206fn checkpoint_i64(value: u64) -> Result<i64, CheckpointError> {
207 i64::try_from(value).map_err(|_| {
208 CheckpointError::new(
209 CheckpointErrorKind::InvalidPayload,
210 "checkpoint revision exceeds PostgreSQL BIGINT",
211 )
212 })
213}
214
215fn checkpoint_storage(_error: postgres::Error) -> CheckpointError {
216 CheckpointError::new(
217 CheckpointErrorKind::Storage,
218 "PostgreSQL checkpoint operation failed",
219 )
220}
221
222fn checkpoint_worker() -> CheckpointError {
223 CheckpointError::new(
224 CheckpointErrorKind::Storage,
225 "PostgreSQL checkpoint worker is unavailable",
226 )
227}
228
229fn checkpoint_conflict(id: CheckpointId) -> CheckpointError {
230 CheckpointError::new(
231 CheckpointErrorKind::Conflict,
232 format!("checkpoint `{id}` revision precondition failed"),
233 )
234}
235
236fn checkpoint_not_found(id: CheckpointId) -> CheckpointError {
237 CheckpointError::new(
238 CheckpointErrorKind::NotFound,
239 format!("checkpoint `{id}` does not exist"),
240 )
241}