Skip to content

Commit afdf64f

Browse files
feat(persistence): implement optimistic concurrency control with CAS
Add resource_version-based optimistic concurrency control to the persistence layer. Every write now requires an explicit WriteCondition (MustCreate or MatchResourceVersion), enforced at compile time by gating unconditional put/put_message behind #[cfg(test)]. - Add WriteCondition enum and put_if for conditional writes - Add update_message_cas for atomic read-modify-write operations - Add list_messages/list_messages_with_selector helpers that hydrate resource_version from authoritative DB rows - Convert all production write paths to CAS-aware methods - Gate put/put_message behind #[cfg(test)] to prevent non-CAS writes - Use structured PersistenceError::UniqueViolation matching instead of string matching for duplicate detection - Hydrate resource_version from WriteResult directly on creates, eliminating unnecessary read-after-write round trips Signed-off-by: Derek Carr <decarr@redhat.com>
1 parent b4c7bc4 commit afdf64f

29 files changed

Lines changed: 3905 additions & 395 deletions

architecture/gateway.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ The storage schema is intentionally narrow:
8383
| `version` | Optional monotonically increasing version for scoped records. |
8484
| `status` | Optional workflow state for records such as policy revisions or draft policy chunks. |
8585
| `dedup_key` and `hit_count` | Optional policy-advisor fields for coalescing repeated observations. |
86+
| `resource_version` | Monotonically increasing counter for optimistic concurrency control. Incremented atomically on each update. |
8687
| `payload` | Prost-encoded protobuf payload for the full domain object. |
8788
| `created_at_ms` and `updated_at_ms` | Gateway timestamps used for ordering and list output. |
8889
| `labels` | JSON object carrying Kubernetes-style object labels for filtering and organization. |
@@ -113,6 +114,92 @@ default WAL journal mode), which mirror the same sensitive contents.
113114
Persisted state includes sandboxes, providers, SSH sessions, policy revisions,
114115
settings, inference configuration, and deployment records.
115116

117+
### Optimistic Concurrency (CAS)
118+
119+
Every object row carries a `resource_version` that the database increments
120+
atomically on each write. Concurrent mutations use compare-and-swap (CAS): the
121+
writer reads the current version, applies changes, and writes back with a
122+
`WHERE resource_version = <expected>` guard. If another writer updated the row
123+
in between, the guard fails and the caller receives a `Conflict` error.
124+
125+
This matters for HA deployments where multiple gateway replicas share the same
126+
Postgres database, and for single-node deployments where concurrent gRPC
127+
handlers or the reconciler mutate the same sandbox.
128+
129+
**Compile-time enforcement.** The unconditional write methods `put` and
130+
`put_message` are gated behind `#[cfg(test)]`. Production code must use
131+
`put_if` with an explicit `WriteCondition` or `update_message_cas`. The
132+
compiler rejects any other write path, making non-CAS writes structurally
133+
impossible outside of tests.
134+
135+
Every write goes through one of three conditions:
136+
137+
- `MustCreate` -- insert-only. The database rejects the write with a
138+
`UniqueViolation` error if a row with that ID already exists. Handlers match
139+
on the structured `PersistenceError::UniqueViolation { .. }` variant to
140+
distinguish creation conflicts from other failures.
141+
- `MatchResourceVersion(v)` -- update-only. The database rejects the write
142+
with a `Conflict` error if the current version differs from `v`.
143+
- `Unconditional` -- test-only; not reachable in production builds.
144+
145+
**Creates.** All create paths use `MustCreate` and hydrate the response
146+
directly from the `WriteResult` returned by `put_if`, which carries the
147+
assigned `resource_version`, `created_at_ms`, and `updated_at_ms`. This
148+
eliminates a read-after-write round trip and the race window that would come
149+
with it.
150+
151+
**Updates.** The `update_message_cas` helper makes a single CAS attempt: it
152+
fetches the current object, applies a mutation closure, and writes with a
153+
`MatchResourceVersion` condition. On conflict the persistence layer returns a
154+
`Conflict` error, which gRPC handlers map to `ABORTED` status so the client
155+
(or the next watch/reconcile event) can retry with fresh state. There is no
156+
automatic retry loop.
157+
158+
The helper accepts an `expected_version` parameter that selects between two
159+
modes:
160+
161+
- **Server-driven** (`expected_version = 0`): the helper uses the version it
162+
just read from the database. Internal operations (reconciler, policy status
163+
reports, compute phase transitions) use this mode because the caller does
164+
not track versions.
165+
- **Client-driven** (`expected_version != 0`): the helper validates that the
166+
caller's version matches the current database version before applying the
167+
mutation. If they diverge it returns `Conflict` without attempting the
168+
write. Client-facing operations that carry an `expected_resource_version`
169+
field use this mode: `AttachSandboxProvider`, `DetachSandboxProvider`,
170+
`UpdateProvider`, and `UpdateConfig` (policy backfill path).
171+
172+
**Lists.** The `list_messages` and `list_messages_with_selector` helpers decode
173+
protobuf payloads from list results and hydrate `resource_version` from the
174+
authoritative database column into each decoded message, mirroring the
175+
`get_message` pattern. This ensures list responses carry correct versions
176+
without requiring callers to manually hydrate each record.
177+
178+
**Deletes.** Delete operations are not yet CAS-protected -- the delete request
179+
protos do not carry `expected_resource_version`. A `delete_if` primitive exists
180+
in the persistence layer but is not wired into gRPC handlers.
181+
182+
**Coverage.** All `ObjectMeta`-bearing message types have write-condition
183+
coverage:
184+
185+
| Type | Create | Update | List |
186+
|---|---|---|---|
187+
| Sandbox | `MustCreate` | `update_message_cas` | `list_messages` |
188+
| Provider | `MustCreate` | `update_message_cas` | `list_messages` |
189+
| ProviderProfile | `MustCreate` | (immutable) | `list_messages` |
190+
| InferenceRoute | `MustCreate` | `update_message_cas` | `list_messages` |
191+
| SandboxPolicy | scoped versioning | scoped versioning | scoped query |
192+
| Settings | `Mutex`-guarded | `Mutex`-guarded | single-row |
193+
194+
Global settings updates use a Tokio `Mutex` to serialize multi-step
195+
validation within a single gateway process, with CAS on the underlying
196+
persistence write as defense in depth. In an HA deployment with multiple
197+
gateways, the Mutex alone would be insufficient. Sandbox-scoped settings
198+
rely entirely on CAS without a Mutex.
199+
200+
The `resource_version` is surfaced to clients through `ObjectMeta` in proto
201+
responses. Database migrations backfill existing rows with version 1.
202+
116203
Policy and runtime settings are delivered together through the effective sandbox
117204
config path. A gateway-global policy can override sandbox-scoped policy. The
118205
sandbox supervisor polls for config revisions and hot-reloads dynamic policy

crates/openshell-cli/src/run.rs

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2389,6 +2389,11 @@ pub async fn sandbox_get(
23892389
println!(" {} {}", "Id:".dimmed(), id);
23902390
println!(" {} {}", "Name:".dimmed(), name);
23912391
println!(" {} {}", "Phase:".dimmed(), phase_name(sandbox.phase));
2392+
println!(
2393+
" {} {}",
2394+
"Resource version:".dimmed(),
2395+
sandbox.metadata.as_ref().map_or(0, |m| m.resource_version)
2396+
);
23922397

23932398
// Display labels if present
23942399
if let Some(metadata) = &sandbox.metadata
@@ -3154,6 +3159,7 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
31543159
"id": sandbox.object_id(),
31553160
"name": sandbox.object_name(),
31563161
"labels": labels,
3162+
"resource_version": meta.map_or(0, |m| m.resource_version),
31573163
"created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)),
31583164
"phase": phase_name(sandbox.phase),
31593165
"current_policy_version": sandbox.current_policy_version,
@@ -3186,14 +3192,38 @@ pub async fn sandbox_provider_attach(
31863192
tls: &TlsOptions,
31873193
) -> Result<()> {
31883194
let mut client = grpc_client(server, tls).await?;
3189-
let response = client
3195+
3196+
// Fetch current sandbox to get resource_version for CAS
3197+
let sandbox = client
3198+
.get_sandbox(GetSandboxRequest {
3199+
name: name.to_string(),
3200+
})
3201+
.await
3202+
.into_diagnostic()?
3203+
.into_inner()
3204+
.sandbox
3205+
.ok_or_else(|| miette::miette!("sandbox not found"))?;
3206+
3207+
let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version);
3208+
3209+
let response = match client
31903210
.attach_sandbox_provider(AttachSandboxProviderRequest {
31913211
sandbox_name: name.to_string(),
31923212
provider_name: provider.to_string(),
3213+
expected_resource_version: resource_version,
31933214
})
31943215
.await
3195-
.into_diagnostic()?
3196-
.into_inner();
3216+
{
3217+
Ok(response) => response.into_inner(),
3218+
Err(status) if status.code() == Code::Aborted => {
3219+
return Err(miette::miette!(
3220+
"Failed to attach provider: sandbox was modified by another operation.\n\
3221+
Please retry the command."
3222+
)
3223+
.with_source_code(status.message().to_string()));
3224+
}
3225+
Err(e) => return Err(e).into_diagnostic(),
3226+
};
31973227

31983228
if response.attached {
31993229
println!(
@@ -3215,14 +3245,38 @@ pub async fn sandbox_provider_detach(
32153245
tls: &TlsOptions,
32163246
) -> Result<()> {
32173247
let mut client = grpc_client(server, tls).await?;
3218-
let response = client
3248+
3249+
// Fetch current sandbox to get resource_version for CAS
3250+
let sandbox = client
3251+
.get_sandbox(GetSandboxRequest {
3252+
name: name.to_string(),
3253+
})
3254+
.await
3255+
.into_diagnostic()?
3256+
.into_inner()
3257+
.sandbox
3258+
.ok_or_else(|| miette::miette!("sandbox not found"))?;
3259+
3260+
let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version);
3261+
3262+
let response = match client
32193263
.detach_sandbox_provider(DetachSandboxProviderRequest {
32203264
sandbox_name: name.to_string(),
32213265
provider_name: provider.to_string(),
3266+
expected_resource_version: resource_version,
32223267
})
32233268
.await
3224-
.into_diagnostic()?
3225-
.into_inner();
3269+
{
3270+
Ok(response) => response.into_inner(),
3271+
Err(status) if status.code() == Code::Aborted => {
3272+
return Err(miette::miette!(
3273+
"Failed to detach provider: sandbox was modified by another operation.\n\
3274+
Please retry the command."
3275+
)
3276+
.with_source_code(status.message().to_string()));
3277+
}
3278+
Err(e) => return Err(e).into_diagnostic(),
3279+
};
32263280

32273281
if response.detached {
32283282
println!(
@@ -3557,6 +3611,7 @@ async fn auto_create_provider(
35573611
name: exact_name.to_string(),
35583612
created_at_ms: 0,
35593613
labels: HashMap::new(),
3614+
resource_version: 0,
35603615
}),
35613616
r#type: provider_type.to_string(),
35623617
credentials: discovered.credentials.clone(),
@@ -3597,6 +3652,7 @@ async fn auto_create_provider(
35973652
name: name.clone(),
35983653
created_at_ms: 0,
35993654
labels: HashMap::new(),
3655+
resource_version: 0,
36003656
}),
36013657
r#type: provider_type.to_string(),
36023658
credentials: discovered.credentials.clone(),
@@ -4009,6 +4065,7 @@ pub async fn provider_create(
40094065
name: name.to_string(),
40104066
created_at_ms: 0,
40114067
labels: HashMap::new(),
4068+
resource_version: 0,
40124069
}),
40134070
r#type: provider_type.clone(),
40144071
credentials: credential_map,
@@ -4053,6 +4110,11 @@ pub async fn provider_get(server: &str, name: &str, tls: &TlsOptions) -> Result<
40534110
println!(" {} {}", "Id:".dimmed(), provider.object_id());
40544111
println!(" {} {}", "Name:".dimmed(), provider.object_name());
40554112
println!(" {} {}", "Type:".dimmed(), provider.r#type);
4113+
println!(
4114+
" {} {}",
4115+
"Resource version:".dimmed(),
4116+
provider.metadata.as_ref().map_or(0, |m| m.resource_version)
4117+
);
40564118
println!(
40574119
" {} {}",
40584120
"Credential keys:".dimmed(),
@@ -4509,6 +4571,7 @@ pub async fn provider_update(
45094571
name: name.to_string(),
45104572
created_at_ms: 0,
45114573
labels: HashMap::new(),
4574+
resource_version: 0,
45124575
}),
45134576
r#type: String::new(),
45144577
credentials: credential_map,
@@ -5063,6 +5126,7 @@ pub async fn sandbox_policy_set_global(
50635126
delete_setting: false,
50645127
global: true,
50655128
merge_operations: vec![],
5129+
expected_resource_version: 0,
50665130
})
50675131
.await
50685132
.into_diagnostic()?
@@ -5261,6 +5325,7 @@ pub async fn gateway_setting_set(
52615325
delete_setting: false,
52625326
global: true,
52635327
merge_operations: vec![],
5328+
expected_resource_version: 0,
52645329
})
52655330
.await
52665331
.into_diagnostic()?
@@ -5295,6 +5360,7 @@ pub async fn sandbox_setting_set(
52955360
delete_setting: false,
52965361
global: false,
52975362
merge_operations: vec![],
5363+
expected_resource_version: 0,
52985364
})
52995365
.await
53005366
.into_diagnostic()?
@@ -5329,6 +5395,7 @@ pub async fn gateway_setting_delete(
53295395
delete_setting: true,
53305396
global: true,
53315397
merge_operations: vec![],
5398+
expected_resource_version: 0,
53325399
})
53335400
.await
53345401
.into_diagnostic()?
@@ -5363,6 +5430,7 @@ pub async fn sandbox_setting_delete(
53635430
delete_setting: true,
53645431
global: false,
53655432
merge_operations: vec![],
5433+
expected_resource_version: 0,
53665434
})
53675435
.await
53685436
.into_diagnostic()?
@@ -5421,6 +5489,7 @@ pub async fn sandbox_policy_set(
54215489
delete_setting: false,
54225490
global: false,
54235491
merge_operations: vec![],
5492+
expected_resource_version: 0,
54245493
})
54255494
.await
54265495
.into_diagnostic()?;
@@ -5595,6 +5664,7 @@ pub async fn sandbox_policy_update(
55955664
delete_setting: false,
55965665
global: false,
55975666
merge_operations: plan.merge_operations,
5667+
expected_resource_version: 0,
55985668
})
55995669
.await
56005670
.into_diagnostic()?

crates/openshell-cli/tests/ensure_providers_integration.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ impl TestOpenShell {
6262
name: name.to_string(),
6363
created_at_ms: 0,
6464
labels: HashMap::new(),
65+
resource_version: 0,
6566
}),
6667
r#type: provider_type.to_string(),
6768
credentials: HashMap::new(),
@@ -326,6 +327,7 @@ impl OpenShell for TestOpenShell {
326327
name: provider_metadata.name,
327328
created_at_ms: existing_metadata.created_at_ms,
328329
labels: existing_metadata.labels,
330+
resource_version: 0,
329331
}),
330332
r#type: existing.r#type,
331333
credentials: merge(existing.credentials, provider.credentials),

crates/openshell-cli/tests/provider_commands_integration.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use openshell_core::proto::{
2020
GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse,
2121
ListProvidersRequest, ListProvidersResponse, ListSandboxProvidersRequest,
2222
ListSandboxProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider,
23-
ProviderProfile, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse,
23+
ProviderProfile, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox,
2424
SandboxResponse, SandboxStreamEvent, ServiceStatus, SupervisorMessage, UpdateProviderRequest,
2525
WatchSandboxRequest,
2626
};
@@ -83,9 +83,25 @@ impl OpenShell for TestOpenShell {
8383

8484
async fn get_sandbox(
8585
&self,
86-
_request: tonic::Request<GetSandboxRequest>,
86+
request: tonic::Request<GetSandboxRequest>,
8787
) -> Result<Response<SandboxResponse>, Status> {
88-
Ok(Response::new(SandboxResponse::default()))
88+
let name = request.into_inner().name;
89+
// Return a minimal sandbox with metadata for CAS operations
90+
Ok(Response::new(SandboxResponse {
91+
sandbox: Some(Sandbox {
92+
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
93+
id: format!("sb-{name}"),
94+
name,
95+
created_at_ms: 0,
96+
labels: HashMap::new(),
97+
resource_version: 1,
98+
}),
99+
spec: None,
100+
status: None,
101+
phase: 0,
102+
current_policy_version: 0,
103+
}),
104+
}))
89105
}
90106

91107
async fn list_sandboxes(
@@ -155,7 +171,7 @@ impl OpenShell for TestOpenShell {
155171
providers.push(request.provider_name.clone());
156172
true
157173
};
158-
let sandbox = openshell_core::proto::Sandbox {
174+
let sandbox = Sandbox {
159175
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
160176
name: request.sandbox_name,
161177
..Default::default()
@@ -192,7 +208,7 @@ impl OpenShell for TestOpenShell {
192208
let before_len = providers.len();
193209
providers.retain(|name| name != &request.provider_name);
194210
let detached = providers.len() != before_len;
195-
let sandbox = openshell_core::proto::Sandbox {
211+
let sandbox = Sandbox {
196212
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
197213
name: request.sandbox_name,
198214
..Default::default()
@@ -447,6 +463,7 @@ impl OpenShell for TestOpenShell {
447463
name: provider_metadata.name,
448464
created_at_ms: existing_metadata.created_at_ms,
449465
labels: existing_metadata.labels,
466+
resource_version: 0,
450467
}),
451468
r#type: existing.r#type,
452469
credentials: merge(existing.credentials, provider.credentials),

0 commit comments

Comments
 (0)