runifold_store_postgres/
effect.rs1use postgres::error::SqlState;
4use runifold_core::{CapabilityId, EffectId};
5use runifold_effect::{EffectExecutorError, EffectExecutorErrorKind, EffectRecord, EffectStore};
6use serde_json::Value;
7
8use crate::PostgresConversationStore;
9
10impl EffectStore for PostgresConversationStore {
11 fn load(&self, id: EffectId) -> Result<Option<EffectRecord>, EffectExecutorError> {
12 let sql = format!(
13 "SELECT record_json FROM {}_effects WHERE effect_id = $1",
14 self.table()
15 );
16 self.blocking()
17 .execute(move |client| client.query_opt(&sql, &[&id.as_uuid()]))
18 .map_err(|_| effect_worker())?
19 .map_err(effect_storage)?
20 .map(|row| decode_record(row.get("record_json")))
21 .transpose()
22 }
23
24 fn find_by_idempotency(
25 &self,
26 capability_id: CapabilityId,
27 key: &str,
28 ) -> Result<Option<EffectRecord>, EffectExecutorError> {
29 let sql = format!(
30 "SELECT record_json FROM {}_effects \
31 WHERE capability_id = $1 AND idempotency_key = $2",
32 self.table()
33 );
34 let key = key.to_owned();
35 self.blocking()
36 .execute(move |client| client.query_opt(&sql, &[&capability_id.as_uuid(), &key]))
37 .map_err(|_| effect_worker())?
38 .map_err(effect_storage)?
39 .map(|row| decode_record(row.get("record_json")))
40 .transpose()
41 }
42
43 fn compare_and_swap(
44 &self,
45 record: &EffectRecord,
46 expected_revision: Option<u64>,
47 ) -> Result<(), EffectExecutorError> {
48 let revision = effect_i64(record.revision)?;
49 let expected = expected_revision.map(effect_i64).transpose()?;
50 let encoded = serde_json::to_value(record).map_err(|_| effect_protocol())?;
51 let effect_id = record.request.effect_id.as_uuid();
52 let capability_id = record.request.capability_id.as_uuid();
53 let idempotency_key = record.request.idempotency_key.clone();
54 let table = format!("{}_effects", self.table());
55 self.blocking()
56 .execute(move |client| -> Result<(), EffectExecutorError> {
57 let mut transaction = client.transaction().map_err(effect_storage)?;
58 if let Some(key) = idempotency_key.as_deref() {
59 let owner = transaction
60 .query_opt(
61 &format!(
62 "SELECT effect_id FROM {table} \
63 WHERE capability_id = $1 AND idempotency_key = $2"
64 ),
65 &[&capability_id, &key],
66 )
67 .map_err(effect_storage)?
68 .map(|row| row.get::<_, uuid::Uuid>("effect_id"));
69 if owner.is_some_and(|owner| owner != effect_id) {
70 return Err(effect_idempotency_conflict());
71 }
72 }
73
74 match expected {
75 None if revision == 0 => {
76 let changed = transaction
77 .execute(
78 &format!(
79 "INSERT INTO {table} \
80 (effect_id, capability_id, idempotency_key, revision, record_json) \
81 VALUES ($1, $2, $3, $4, $5) \
82 ON CONFLICT (effect_id) DO NOTHING"
83 ),
84 &[
85 &effect_id,
86 &capability_id,
87 &idempotency_key,
88 &revision,
89 &encoded,
90 ],
91 )
92 .map_err(effect_write_error)?;
93 if changed != 1 {
94 return Err(effect_conflict());
95 }
96 }
97 Some(expected)
98 if expected
99 .checked_add(1)
100 .is_some_and(|next| revision == next) =>
101 {
102 let changed = transaction
103 .execute(
104 &format!(
105 "UPDATE {table} SET capability_id = $1, idempotency_key = $2, \
106 revision = $3, record_json = $4 \
107 WHERE effect_id = $5 AND revision = $6"
108 ),
109 &[
110 &capability_id,
111 &idempotency_key,
112 &revision,
113 &encoded,
114 &effect_id,
115 &expected,
116 ],
117 )
118 .map_err(effect_write_error)?;
119 if changed != 1 {
120 return Err(effect_conflict());
121 }
122 }
123 _ => return Err(effect_conflict()),
124 }
125 transaction.commit().map_err(effect_storage)
126 })
127 .map_err(|_| effect_worker())?
128 }
129}
130
131fn decode_record(value: Value) -> Result<EffectRecord, EffectExecutorError> {
132 serde_json::from_value(value).map_err(|_| effect_protocol())
133}
134
135fn effect_i64(value: u64) -> Result<i64, EffectExecutorError> {
136 i64::try_from(value).map_err(|_| {
137 EffectExecutorError::new(
138 EffectExecutorErrorKind::Protocol,
139 "effect revision exceeds PostgreSQL BIGINT",
140 )
141 })
142}
143
144fn effect_storage(_error: postgres::Error) -> EffectExecutorError {
145 EffectExecutorError::new(
146 EffectExecutorErrorKind::Store,
147 "PostgreSQL effect store operation failed",
148 )
149}
150
151fn effect_worker() -> EffectExecutorError {
152 EffectExecutorError::new(
153 EffectExecutorErrorKind::Store,
154 "PostgreSQL effect store worker is unavailable",
155 )
156}
157
158fn effect_write_error(error: postgres::Error) -> EffectExecutorError {
159 if error.code() == Some(&SqlState::UNIQUE_VIOLATION) {
160 effect_idempotency_conflict()
161 } else {
162 effect_storage(error)
163 }
164}
165
166fn effect_protocol() -> EffectExecutorError {
167 EffectExecutorError::new(
168 EffectExecutorErrorKind::Protocol,
169 "PostgreSQL effect record is invalid",
170 )
171}
172
173fn effect_conflict() -> EffectExecutorError {
174 EffectExecutorError::new(
175 EffectExecutorErrorKind::Store,
176 "effect record revision precondition failed",
177 )
178}
179
180fn effect_idempotency_conflict() -> EffectExecutorError {
181 EffectExecutorError::new(
182 EffectExecutorErrorKind::IdempotencyConflict,
183 "idempotency key already belongs to another effect",
184 )
185}