Skip to main content

little_durable_objects/
placement.rs

1use std::{collections::HashMap, sync::Mutex};
2
3use anyhow::{Context, Result, ensure};
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6
7use crate::{actor_state::ActorStorageKey, host::HostId, postgres::PostgresDatabase};
8
9#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
10pub struct ObjectPlacement {
11    pub object: ActorStorageKey,
12    pub owner: HostId,
13    pub owner_epoch: u64,
14    pub home_region: String,
15    pub state_version: u64,
16    pub state_object: Option<String>,
17    pub last_request_id: Option<String>,
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum PlacementClaim {
22    Acquired(ObjectPlacement),
23    Current(ObjectPlacement),
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct StateCommitRequest {
28    pub object: ActorStorageKey,
29    pub owner: HostId,
30    pub session_id: String,
31    pub owner_epoch: u64,
32    pub expected_version: u64,
33    pub state_object: String,
34    pub request_id: String,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum StateCommit {
39    Committed(ObjectPlacement),
40    Current(ObjectPlacement),
41}
42
43#[async_trait]
44pub trait ObjectPlacementStore: Send + Sync {
45    async fn get(&self, object: &ActorStorageKey) -> Result<Option<ObjectPlacement>>;
46
47    async fn claim(
48        &self,
49        object: &ActorStorageKey,
50        expected: Option<&ObjectPlacement>,
51        owner: &HostId,
52        home_region: &str,
53    ) -> Result<PlacementClaim>;
54
55    async fn commit_state(&self, request: &StateCommitRequest) -> Result<StateCommit>;
56}
57
58#[derive(Default)]
59pub struct LocalObjectPlacementStore {
60    placements: Mutex<HashMap<ActorStorageKey, ObjectPlacement>>,
61}
62
63#[async_trait]
64impl ObjectPlacementStore for LocalObjectPlacementStore {
65    async fn get(&self, object: &ActorStorageKey) -> Result<Option<ObjectPlacement>> {
66        Ok(self
67            .placements
68            .lock()
69            .map_err(|_| anyhow::anyhow!("object placement lock poisoned"))?
70            .get(object)
71            .cloned())
72    }
73
74    async fn claim(
75        &self,
76        object: &ActorStorageKey,
77        expected: Option<&ObjectPlacement>,
78        owner: &HostId,
79        home_region: &str,
80    ) -> Result<PlacementClaim> {
81        validate_region(home_region)?;
82        let mut placements = self
83            .placements
84            .lock()
85            .map_err(|_| anyhow::anyhow!("object placement lock poisoned"))?;
86        match placements.get(object) {
87            None if expected.is_none() => {
88                let placement = ObjectPlacement {
89                    object: object.clone(),
90                    owner: owner.clone(),
91                    owner_epoch: 1,
92                    home_region: home_region.to_owned(),
93                    state_version: 0,
94                    state_object: None,
95                    last_request_id: None,
96                };
97                placements.insert(object.clone(), placement.clone());
98                Ok(PlacementClaim::Acquired(placement))
99            }
100            Some(current) if expected == Some(current) => {
101                ensure!(
102                    current.home_region == home_region,
103                    "object home region cannot change"
104                );
105                if &current.owner == owner {
106                    return Ok(PlacementClaim::Current(current.clone()));
107                }
108                let placement = ObjectPlacement {
109                    object: object.clone(),
110                    owner: owner.clone(),
111                    owner_epoch: current
112                        .owner_epoch
113                        .checked_add(1)
114                        .context("object owner epoch overflow")?,
115                    home_region: home_region.to_owned(),
116                    state_version: current.state_version,
117                    state_object: current.state_object.clone(),
118                    last_request_id: current.last_request_id.clone(),
119                };
120                placements.insert(object.clone(), placement.clone());
121                Ok(PlacementClaim::Acquired(placement))
122            }
123            Some(current) => Ok(PlacementClaim::Current(current.clone())),
124            None => anyhow::bail!("expected object placement no longer exists"),
125        }
126    }
127
128    async fn commit_state(&self, request: &StateCommitRequest) -> Result<StateCommit> {
129        validate_state_commit(request)?;
130        let mut placements = self
131            .placements
132            .lock()
133            .map_err(|_| anyhow::anyhow!("object placement lock poisoned"))?;
134        let current = placements
135            .get(&request.object)
136            .cloned()
137            .context("actor placement does not exist")?;
138        if is_replayed_commit(&current, request) {
139            return Ok(StateCommit::Committed(current));
140        }
141        if current.owner != request.owner
142            || current.owner_epoch != request.owner_epoch
143            || current.state_version != request.expected_version
144        {
145            return Ok(StateCommit::Current(current));
146        }
147        let mut committed = current;
148        committed.state_version = committed
149            .state_version
150            .checked_add(1)
151            .context("actor state version overflow")?;
152        committed.state_object = Some(request.state_object.clone());
153        committed.last_request_id = Some(request.request_id.clone());
154        placements.insert(request.object.clone(), committed.clone());
155        Ok(StateCommit::Committed(committed))
156    }
157}
158
159pub struct PostgresObjectPlacementStore {
160    database: PostgresDatabase,
161}
162
163impl PostgresObjectPlacementStore {
164    pub async fn connect(url: &str) -> Result<Self> {
165        Ok(Self::from_database(PostgresDatabase::connect(url).await?))
166    }
167
168    pub(crate) fn from_database(database: PostgresDatabase) -> Self {
169        Self { database }
170    }
171}
172
173#[async_trait]
174impl ObjectPlacementStore for PostgresObjectPlacementStore {
175    async fn get(&self, object: &ActorStorageKey) -> Result<Option<ObjectPlacement>> {
176        let row = self
177            .database
178            .query_opt(
179                "SELECT owner_host_id, owner_epoch, home_region, state_version, state_object, last_request_id \
180                 FROM durable_object_placements WHERE object_id = $1",
181                &[&object.as_str()],
182            )
183            .await
184            .context("load PostgreSQL object placement")?;
185        row.map(|row| placement_from_row(object, &row)).transpose()
186    }
187
188    async fn claim(
189        &self,
190        object: &ActorStorageKey,
191        expected: Option<&ObjectPlacement>,
192        owner: &HostId,
193        home_region: &str,
194    ) -> Result<PlacementClaim> {
195        object.validate()?;
196        validate_region(home_region)?;
197        if expected.is_none() {
198            if let Some(row) = self
199                .database
200                .query_opt(
201                    "INSERT INTO durable_object_placements \
202                     (object_id, owner_host_id, owner_epoch, home_region) \
203                     VALUES ($1, $2, 1, $3) ON CONFLICT DO NOTHING \
204                     RETURNING owner_host_id, owner_epoch, home_region, state_version, state_object, last_request_id",
205                    &[&object.as_str(), &owner.as_str(), &home_region],
206                )
207                .await
208                .context("insert PostgreSQL object placement")?
209            {
210                return Ok(PlacementClaim::Acquired(placement_from_row(object, &row)?));
211            }
212            return self.current_claim(object).await;
213        }
214
215        let expected = expected.expect("checked above");
216        ensure!(
217            expected.object == *object && expected.home_region == home_region,
218            "expected object placement does not match the claim"
219        );
220        if &expected.owner == owner {
221            return self.current_claim(object).await;
222        }
223        let expected_epoch = i64::try_from(expected.owner_epoch)
224            .context("object owner epoch exceeds PostgreSQL BIGINT")?;
225        if let Some(row) = self
226            .database
227            .query_opt(
228                "UPDATE durable_object_placements \
229                 SET owner_host_id = $2, owner_epoch = owner_epoch + 1, updated_at = clock_timestamp() \
230                 WHERE object_id = $1 AND owner_host_id = $3 AND owner_epoch = $4 AND home_region = $5 \
231                 RETURNING owner_host_id, owner_epoch, home_region, state_version, state_object, last_request_id",
232                &[
233                    &object.as_str(),
234                    &owner.as_str(),
235                    &expected.owner.as_str(),
236                    &expected_epoch,
237                    &home_region,
238                ],
239            )
240            .await
241            .context("claim PostgreSQL object placement")?
242        {
243            return Ok(PlacementClaim::Acquired(placement_from_row(object, &row)?));
244        }
245        self.current_claim(object).await
246    }
247
248    async fn commit_state(&self, request: &StateCommitRequest) -> Result<StateCommit> {
249        validate_state_commit(request)?;
250        let expected_epoch = i64::try_from(request.owner_epoch)
251            .context("object owner epoch exceeds PostgreSQL BIGINT")?;
252        let expected_version = i64::try_from(request.expected_version)
253            .context("actor state version exceeds PostgreSQL BIGINT")?;
254        let row = self
255            .database
256            .query_opt(
257                "UPDATE durable_object_placements AS placement \
258                 SET state_version = state_version + 1, state_object = $6, last_request_id = $7, updated_at = clock_timestamp() \
259                 FROM durable_object_host_leases AS lease \
260                 WHERE placement.object_id = $1 \
261                   AND placement.owner_host_id = $2 \
262                   AND placement.owner_epoch = $3 \
263                   AND placement.state_version = $4 \
264                   AND lease.host_id = placement.owner_host_id \
265                   AND lease.session_id = $5 \
266                   AND lease.expires_at_ms > (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT \
267                 RETURNING placement.owner_host_id, placement.owner_epoch, placement.home_region, \
268                           placement.state_version, placement.state_object, placement.last_request_id",
269                &[
270                    &request.object.as_str(),
271                    &request.owner.as_str(),
272                    &expected_epoch,
273                    &expected_version,
274                    &request.session_id,
275                    &request.state_object,
276                    &request.request_id,
277                ],
278            )
279            .await
280            .context("commit PostgreSQL actor state head")?;
281        if let Some(row) = row {
282            return Ok(StateCommit::Committed(placement_from_row(
283                &request.object,
284                &row,
285            )?));
286        }
287        let current = self
288            .get(&request.object)
289            .await?
290            .context("actor placement disappeared during state commit")?;
291        Ok(if is_replayed_commit(&current, request) {
292            StateCommit::Committed(current)
293        } else {
294            StateCommit::Current(current)
295        })
296    }
297}
298
299impl PostgresObjectPlacementStore {
300    async fn current_claim(&self, object: &ActorStorageKey) -> Result<PlacementClaim> {
301        self.get(object)
302            .await?
303            .map(PlacementClaim::Current)
304            .context("object placement disappeared during claim")
305    }
306}
307
308fn placement_from_row(
309    object: &ActorStorageKey,
310    row: &tokio_postgres::Row,
311) -> Result<ObjectPlacement> {
312    Ok(ObjectPlacement {
313        object: object.clone(),
314        owner: HostId::new(row.get::<_, String>(0)),
315        owner_epoch: u64::try_from(row.get::<_, i64>(1))
316            .context("PostgreSQL object owner epoch is negative")?,
317        home_region: row.get(2),
318        state_version: u64::try_from(row.get::<_, i64>(3))
319            .context("PostgreSQL actor state version is negative")?,
320        state_object: row.get(4),
321        last_request_id: row.get(5),
322    })
323}
324
325fn validate_state_commit(request: &StateCommitRequest) -> Result<()> {
326    request.object.validate()?;
327    ensure!(
328        !request.owner.as_str().is_empty(),
329        "state commit owner is empty"
330    );
331    ensure!(
332        !request.session_id.is_empty(),
333        "state commit session is empty"
334    );
335    ensure!(
336        request.owner_epoch > 0,
337        "state commit owner epoch must be positive"
338    );
339    ensure!(
340        !request.state_object.is_empty() && request.state_object.len() <= 1024,
341        "state commit object name is invalid"
342    );
343    ensure!(
344        !request.request_id.is_empty() && request.request_id.len() <= 255,
345        "state commit request ID is invalid"
346    );
347    Ok(())
348}
349
350fn is_replayed_commit(current: &ObjectPlacement, request: &StateCommitRequest) -> bool {
351    current.state_version == request.expected_version.saturating_add(1)
352        && current.state_object.as_deref() == Some(&request.state_object)
353        && current.last_request_id.as_deref() == Some(&request.request_id)
354}
355
356pub fn validate_region(region: &str) -> Result<()> {
357    ensure!(
358        !region.is_empty()
359            && region.len() <= 64
360            && region.bytes().all(|byte| {
361                byte.is_ascii_lowercase()
362                    || byte.is_ascii_digit()
363                    || matches!(byte, b'.' | b'_' | b'-')
364            }),
365        "sandbox region is invalid"
366    );
367    Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[tokio::test]
375    async fn claims_once_and_increments_epoch_on_transfer() -> Result<()> {
376        let store = LocalObjectPlacementStore::default();
377        let object = ActorStorageKey::new("object.v1.project.Counter.one");
378        let first = match store
379            .claim(&object, None, &HostId::new("host-a"), "us-east")
380            .await?
381        {
382            PlacementClaim::Acquired(placement) => placement,
383            claim => anyhow::bail!("unexpected claim: {claim:?}"),
384        };
385        assert_eq!(first.owner_epoch, 1);
386
387        let second = match store
388            .claim(&object, Some(&first), &HostId::new("host-b"), "us-east")
389            .await?
390        {
391            PlacementClaim::Acquired(placement) => placement,
392            claim => anyhow::bail!("unexpected claim: {claim:?}"),
393        };
394        assert_eq!(second.owner, HostId::new("host-b"));
395        assert_eq!(second.owner_epoch, 2);
396        assert_eq!(second.home_region, "us-east");
397        Ok(())
398    }
399
400    #[tokio::test]
401    async fn stale_claim_observes_the_current_owner() -> Result<()> {
402        let store = LocalObjectPlacementStore::default();
403        let object = ActorStorageKey::new("object.v1.project.Counter.one");
404        let PlacementClaim::Acquired(first) = store
405            .claim(&object, None, &HostId::new("host-a"), "us-east")
406            .await?
407        else {
408            anyhow::bail!("first claim was not acquired")
409        };
410        let PlacementClaim::Acquired(second) = store
411            .claim(&object, Some(&first), &HostId::new("host-b"), "us-east")
412            .await?
413        else {
414            anyhow::bail!("second claim was not acquired")
415        };
416        assert_eq!(
417            store
418                .claim(&object, Some(&first), &HostId::new("host-c"), "us-east",)
419                .await?,
420            PlacementClaim::Current(second)
421        );
422        Ok(())
423    }
424}