Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ backend is already absent (`deleted = false`), the request removes gateway state
synchronously. Sandbox row removal remains bound to the stable ID and resource
version. Settings retain their existing best-effort name-based cleanup; SSH
sessions, indexes, and watch/log buses are cleaned after confirmed removal.
Owned-record cleanup discovers records before mutating them and uses bounded
set-based deletes so teardown cannot amplify one sandbox into an unbounded
sequence of individual persistence writes.

The request acquires both locks before starting owned work, so cancellation
while queued does not leave a delete armed. After that commitment point, the
Expand Down
106 changes: 91 additions & 15 deletions crates/openshell-server/src/compute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ pub use vm::VmComputeConfig;
use crate::grpc::policy::SANDBOX_SETTINGS_OBJECT_TYPE;
use crate::otel_tracing::TraceContextInterceptor;
use crate::persistence::{
DRAFT_CHUNK_OBJECT_TYPE, ObjectId, ObjectName, ObjectRecord, ObjectType, POLICY_OBJECT_TYPE,
Store, WriteCondition,
DRAFT_CHUNK_OBJECT_TYPE, ObjectCursor, ObjectId, ObjectName, ObjectRecord, ObjectType,
POLICY_OBJECT_TYPE, Store, WriteCondition,
};
use crate::sandbox_index::SandboxIndex;
use crate::sandbox_watch::SandboxWatchBus;
Expand Down Expand Up @@ -69,7 +69,7 @@ use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, Mutex as StdMutex, Weak};
use std::time::Duration;
use std::time::{Duration, Instant};
#[cfg(unix)]
use tokio::net::UnixStream;
use tokio::sync::{Mutex, watch};
Expand Down Expand Up @@ -3084,21 +3084,60 @@ impl ComputeRuntime {
sandbox_id: &str,
workspace: &str,
) -> Result<(), String> {
let records = self
let started = Instant::now();
let mut cursor = None;
let mut scanned = 0_usize;
let mut decode_failures = 0_usize;
let mut session_ids = Vec::new();

loop {
let records = self
.store
.list_after(
SshSession::object_type(),
workspace,
cursor.as_ref(),
LIFECYCLE_SWEEP_PAGE_SIZE,
)
.await
.map_err(|e| format!("list SSH sessions: {e}"))?;
let page_len = records.len();
scanned += page_len;

cursor = records.last().map(ObjectCursor::from);
for record in records {
match SshSession::decode(record.payload.as_slice()) {
Ok(session) if session.sandbox_id == sandbox_id => {
session_ids.push(session.object_id().to_string());
}
Ok(_) => {}
Err(_) => decode_failures += 1,
}
}

if page_len < LIFECYCLE_SWEEP_PAGE_SIZE as usize {
break;
}
}

let matched = session_ids.len();
let deleted = self
.store
.list(SshSession::object_type(), workspace, 1000, 0)
.delete_many(SshSession::object_type(), &session_ids)
.await
.map_err(|e| format!("list SSH sessions: {e}"))?;
.map_err(|e| format!("delete sandbox SSH sessions: {e}"))?;

for record in records {
if let Ok(session) = SshSession::decode(record.payload.as_slice())
&& session.sandbox_id == sandbox_id
{
self.store
.delete(SshSession::object_type(), session.object_id())
.await
.map_err(|e| format!("delete SSH session {}: {e}", session.object_id()))?;
}
if matched > 0 || decode_failures > 0 {
debug!(
sandbox_id,
workspace,
scanned,
matched,
deleted,
decode_failures,
elapsed_ms = started.elapsed().as_millis(),
"Sandbox SSH session cleanup complete"
);
}

Ok(())
Expand Down Expand Up @@ -7151,6 +7190,43 @@ mod tests {
assert_eq!(driver.delete_calls(), 1);
}

#[tokio::test]
async fn sandbox_ssh_session_cleanup_batches_across_list_pages() {
let runtime = test_runtime(ControlledDriver::new()).await;
for idx in 0..(LIFECYCLE_SWEEP_PAGE_SIZE + 5) {
let session = ssh_session_record(&format!("owned-{idx:04}"), "sb-owned");
runtime.store.put_message(&session).await.unwrap();
}
for idx in 0..7 {
let session = ssh_session_record(&format!("unrelated-{idx:04}"), "sb-unrelated");
runtime.store.put_message(&session).await.unwrap();
}

runtime
.cleanup_sandbox_ssh_sessions("sb-owned", "default")
.await
.unwrap();

assert_eq!(
runtime
.store
.count_in_workspace(SshSession::object_type(), "default")
.await
.unwrap(),
7
);
for idx in 0..7 {
assert!(
runtime
.store
.get_message::<SshSession>(&format!("unrelated-{idx:04}"))
.await
.unwrap()
.is_some()
);
}
}

#[tokio::test]
async fn already_absent_driver_resource_is_removed_synchronously() {
let driver = ControlledDriver::new();
Expand Down
86 changes: 86 additions & 0 deletions crates/openshell-server/src/persistence/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ pub const DRAFT_CHUNK_OBJECT_TYPE: &str = "draft_policy_chunk";

pub type PersistenceResult<T> = Result<T, PersistenceError>;

/// Maximum number of object ids sent in one set-based delete statement.
///
/// Keep this well below `SQLite`'s bind-variable limit. Backends split larger
/// requests into independently retryable, bounded write statements.
pub const DELETE_MANY_BATCH_SIZE: usize = 128;

/// Persistence-layer error type.
#[derive(Debug, Error, Clone)]
pub enum PersistenceError {
Expand Down Expand Up @@ -101,6 +107,30 @@ pub struct ObjectRecord {
pub resource_version: u64,
}

/// Stable position in the global object-listing order.
///
/// Keyset consumers must use the matching store method for the order encoded
/// here: workspace-scoped lists use `created_at_ms`, `name`, and `id`; global
/// lists additionally include `workspace`.
#[derive(Debug, Clone)]
pub struct ObjectCursor {
pub created_at_ms: i64,
pub name: String,
pub workspace: String,
pub id: String,
}

impl From<&ObjectRecord> for ObjectCursor {
fn from(record: &ObjectRecord) -> Self {
Self {
created_at_ms: record.created_at_ms,
name: record.name.clone(),
workspace: record.workspace.clone(),
id: record.id.clone(),
}
}
}

/// Write condition for compare-and-swap operations.
#[derive(Debug, Clone, Copy)]
pub enum WriteCondition {
Expand Down Expand Up @@ -415,6 +445,22 @@ impl Store {
store_dispatch_traced!(self.delete(object_type, id))
}

/// Delete objects of one type by id in bounded, set-based statements.
#[tracing::instrument(
name = "store",
skip_all,
fields(
otel.name = "store.delete_many",
otel.status_code = tracing::field::Empty,
object_type = %object_type,
object_count = ids.len(),
batch_count = ids.len().div_ceil(DELETE_MANY_BATCH_SIZE),
)
)]
pub async fn delete_many(&self, object_type: &str, ids: &[String]) -> PersistenceResult<u64> {
store_dispatch_traced!(self.delete_many(object_type, ids))
}

/// Count objects of a given type within a workspace.
#[tracing::instrument(
name = "store",
Expand Down Expand Up @@ -499,6 +545,46 @@ impl Store {
store_dispatch_traced!(self.list_by_type(object_type, limit, offset))
}

/// List workspace objects after a stable cursor, without offset drift.
#[tracing::instrument(
name = "store",
skip_all,
fields(
otel.name = "store.list_after",
otel.status_code = tracing::field::Empty,
object_type = %object_type,
workspace = %workspace,
)
)]
pub async fn list_after(
&self,
object_type: &str,
workspace: &str,
after: Option<&ObjectCursor>,
limit: u32,
) -> PersistenceResult<Vec<ObjectRecord>> {
store_dispatch_traced!(self.list_after(object_type, workspace, after, limit))
}

/// List objects across workspaces after a stable cursor, without offset drift.
#[tracing::instrument(
name = "store",
skip_all,
fields(
otel.name = "store.list_by_type_after",
otel.status_code = tracing::field::Empty,
object_type = %object_type,
)
)]
pub async fn list_by_type_after(
&self,
object_type: &str,
after: Option<&ObjectCursor>,
limit: u32,
) -> PersistenceResult<Vec<ObjectRecord>> {
store_dispatch_traced!(self.list_by_type_after(object_type, after, limit))
}

/// List objects by type and application-owned scope.
///
/// Workspace filtering is intentionally omitted: scope values are sandbox
Expand Down
57 changes: 53 additions & 4 deletions crates/openshell-server/src/persistence/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

use super::{
DraftChunkRecord, ObjectRecord, PersistenceError, PersistenceResult, PolicyRecord,
WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error,
DraftChunkRecord, ObjectCursor, ObjectRecord, PersistenceError, PersistenceResult,
PolicyRecord, WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error,
};
use crate::policy_store::{
AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts,
Expand All @@ -14,11 +14,11 @@ use openshell_core::SetResourceVersion;
use openshell_core::proto::Sandbox;
use prost::Message;
use sqlx::postgres::PgPoolOptions;
use sqlx::{Connection, PgPool, Row};
use sqlx::{Connection, PgPool, Postgres, QueryBuilder, Row};

static POSTGRES_MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/postgres");

use super::{DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE};
use super::{DELETE_MANY_BATCH_SIZE, DRAFT_CHUNK_OBJECT_TYPE, POLICY_OBJECT_TYPE};

#[derive(Debug, Clone)]
pub struct PostgresStore {
Expand Down Expand Up @@ -391,6 +391,28 @@ WHERE object_type = $1 AND workspace = $2 AND name = $3
Ok(result.rows_affected() > 0)
}

pub async fn delete_many(&self, object_type: &str, ids: &[String]) -> PersistenceResult<u64> {
let mut deleted = 0_u64;
for ids in ids.chunks(DELETE_MANY_BATCH_SIZE) {
let mut query =
QueryBuilder::<Postgres>::new("DELETE FROM objects WHERE object_type = ");
query.push_bind(object_type).push(" AND id IN (");
let mut separated = query.separated(", ");
for id in ids {
separated.push_bind(id);
}
separated.push_unseparated(")");

deleted += query
.build()
.execute(&self.pool)
.await
.map_err(|e| map_db_error(&e))?
.rows_affected();
}
Ok(deleted)
}

pub async fn count_in_workspace(
&self,
object_type: &str,
Expand Down Expand Up @@ -500,6 +522,33 @@ LIMIT $2 OFFSET $3

Ok(rows.into_iter().map(row_to_object_record).collect())
}
pub async fn list_after(
&self,
object_type: &str,
workspace: &str,
after: Option<&ObjectCursor>,
limit: u32,
) -> PersistenceResult<Vec<ObjectRecord>> {
let rows = if let Some(cursor) = after {
sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND workspace = $2 AND (created_at_ms, name, id) > ($3, $4, $5) ORDER BY created_at_ms, name, id LIMIT $6").bind(object_type).bind(workspace).bind(cursor.created_at_ms).bind(&cursor.name).bind(&cursor.id).bind(i64::from(limit)).fetch_all(&self.pool).await
} else {
sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND workspace = $2 ORDER BY created_at_ms, name, id LIMIT $3").bind(object_type).bind(workspace).bind(i64::from(limit)).fetch_all(&self.pool).await
}.map_err(|e| map_db_error(&e))?;
Ok(rows.into_iter().map(row_to_object_record).collect())
}
pub async fn list_by_type_after(
&self,
object_type: &str,
after: Option<&ObjectCursor>,
limit: u32,
) -> PersistenceResult<Vec<ObjectRecord>> {
let rows = if let Some(cursor) = after {
sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 AND (created_at_ms, name, workspace, id) > ($2, $3, $4, $5) ORDER BY created_at_ms, name, workspace, id LIMIT $6").bind(object_type).bind(cursor.created_at_ms).bind(&cursor.name).bind(&cursor.workspace).bind(&cursor.id).bind(i64::from(limit)).fetch_all(&self.pool).await
} else {
sqlx::query("SELECT object_type, id, name, workspace, payload, created_at_ms, updated_at_ms, labels, resource_version FROM objects WHERE object_type = $1 ORDER BY created_at_ms, name, workspace, id LIMIT $2").bind(object_type).bind(i64::from(limit)).fetch_all(&self.pool).await
}.map_err(|e| map_db_error(&e))?;
Ok(rows.into_iter().map(row_to_object_record).collect())
}

pub async fn list_with_membership(
&self,
Expand Down
Loading
Loading