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
15 changes: 15 additions & 0 deletions docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ support is not evidence of multi-node safety.
Newest checkpoints first. Each entry is exact to the source, tree and runs it
names, including unsuccessful observations.

October 20, namespace update:

- **Source.** Native serves `PUT /api/namespaces/{name}`, as published PHP's
NamespaceController::update does. A description replaces the stored one
only when given. Retention follows PHP's rules: a forever namespace becoming
bounded needs `retention_days`; forever refuses days; bounded refuses null
days; days run 1–365. A missing namespace is `404 namespace_not_found`.
Only an admin may update a namespace. Native still has no retention pass,
so the stored retention is not enforced yet.
- **Checks.** The `namespace-isolation` probe now updates its first
namespace at the end through the published SDK, and updates a missing one.
The contract checks both. Locally, published PHP 2.5.14, embedded PHP and
native passed, and the comparison passed. A native test covers the
retention rules on SQLite.

October 19, before #478, #476, #494 and #460 merged:

- **Open native PRs.** [#478](https://github.com/durable-workflow/server/pull/478)
Expand Down
1 change: 1 addition & 0 deletions rust/src/runtime/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ impl Runtime {
delegate!(describe_namespace(name: &str) -> Result<Value>);
delegate!(create_namespace(body: Value) -> Result<Value>);
delegate!(set_external_policy(name: &str, body: Value) -> Result<Value>);
delegate!(update_namespace(name: &str, body: Value) -> Result<Value>);
delegate!(
upload_external_local(
local: crate::external_payload::LocalStorage,
Expand Down
17 changes: 13 additions & 4 deletions rust/src/runtime/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ pub fn router(runtime: Runtime) -> Router {
"/api/namespaces",
get(list_namespaces).post(create_namespace),
)
.route("/api/namespaces/{namespace}", get(describe_namespace))
.route(
"/api/namespaces/{namespace}",
get(describe_namespace).put(update_namespace),
)
.route(
"/api/namespaces/{namespace}/external-storage",
axum::routing::put(super::external_transport::set_policy),
Expand Down Expand Up @@ -268,9 +271,8 @@ async fn guard(State(runtime): State<Runtime>, mut request: Request, next: Next)
&["worker"]
} else if path.starts_with("/api/workers/")
|| path == "/api/namespaces" && request.method() == Method::POST
|| path.starts_with("/api/namespaces/")
&& path.ends_with("/external-storage")
&& request.method() == Method::PUT
// Updating a namespace or its external storage needs an admin.
|| path.starts_with("/api/namespaces/") && request.method() == Method::PUT
{
&["admin"]
} else {
Expand Down Expand Up @@ -465,6 +467,13 @@ async fn describe_namespace(
) -> Result<Json<Value>> {
Ok(Json(runtime.describe_namespace(&namespace).await?))
}
async fn update_namespace(
Extension(runtime): Extension<Runtime>,
Path(namespace): Path<String>,
Json(body): Json<Value>,
) -> Result<Json<Value>> {
Ok(Json(runtime.update_namespace(&namespace, body).await?))
}
async fn create_namespace(
Extension(runtime): Extension<Runtime>,
Json(body): Json<Value>,
Expand Down
68 changes: 68 additions & 0 deletions rust/src/runtime/namespaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,74 @@ where
Self::namespace_metadata(&row)
}

/// PHP's NamespaceController::update: a description replaces the stored
/// one only when given, and retention follows retentionForUpdate.
pub(super) async fn update_namespace(&self, name: &str, body: Value) -> Result<Value> {
let invalid = || {
refuse(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid_namespace_definition",
)
};
let name = name.to_ascii_lowercase();
let description = match body.get("description") {
None | Some(Value::Null) => None,
Some(Value::String(text)) if text.chars().count() <= 1000 => Some(text.as_str()),
Some(_) => return Err(invalid()),
};
let requested_mode = match body.get("retention_mode") {
None => None,
Some(Value::String(mode)) if matches!(mode.as_str(), "bounded" | "forever") => {
Some(mode.as_str())
}
Some(_) => return Err(invalid()),
};
let requested_days = match body.get("retention_days") {
None => None,
Some(Value::Null) => Some(None),
Some(value) => Some(Some(
value
.as_i64()
.filter(|days| (1..=365).contains(days))
.ok_or_else(invalid)?,
)),
};
let mut tx = self.begin().await?;
let row = Self::query("SELECT * FROM workflow_namespaces WHERE name=$1")
.bind(&name)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| RuntimeError::Protocol {
status: StatusCode::NOT_FOUND,
response: json!({"reason":"namespace_not_found","namespace":name,"message":format!("Namespace [{name}] not found.")}),
})?;
let mode = match requested_mode {
Some(mode) => mode.to_owned(),
None => DB::string(&row, "retention_mode")?,
};
let days = match requested_days {
Some(days) => days,
None => DB::optional_number(&row, "retention_days")?,
};
let forever = mode == "forever";
if forever && requested_days.is_some_and(|days| days.is_some())
|| !forever && (requested_days == Some(None) || days.is_none())
{
return Err(invalid());
}
let days = if forever { 0 } else { days.unwrap_or_default() };
Self::query("UPDATE workflow_namespaces SET description=COALESCE($1,description),retention_mode=$2,retention_days=NULLIF($3,0),updated_at=$4 WHERE name=$5")
.bind(description).bind(&mode).bind(days).bind(DB::bind_time(now())).bind(&name)
.execute(&mut *tx).await?;
let row = Self::query("SELECT * FROM workflow_namespaces WHERE name=$1")
.bind(&name)
.fetch_one(&mut *tx)
.await?;
let response = Self::namespace_metadata(&row)?;
tx.commit().await?;
Ok(response)
}

pub(super) async fn set_external_policy(&self, name: &str, body: Value) -> Result<Value> {
let policy = super::external_transport::local_policy(body)?;
let name = name.to_ascii_lowercase();
Expand Down
54 changes: 54 additions & 0 deletions rust/tests/execution/namespaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,3 +611,57 @@ async fn same_worker_id_isolated_claims_completion_and_deregistration_by_namespa
runtime.close().await;
database.remove().await;
}

#[tokio::test]
async fn namespace_updates_follow_published_retention_rules() {
let database = TestDatabase::new().await;
let runtime = database.open().await.unwrap();
let app = router(runtime.clone());
create(&app, "updated").await;
let update = |body: Value| {
let app = app.clone();
async move { scoped(&app, "default", "PUT", "/api/namespaces/UPDATED", body).await }
};
let (status, body) = update(json!({"description":"changed λ","retention_days":60})).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["name"], "updated");
assert_eq!(body["description"], "changed λ");
assert_eq!(body["retention_mode"], "bounded");
assert_eq!(body["retention_days"], 60);
// A null description keeps the stored one.
let (status, body) = update(json!({"description":null,"retention_mode":"forever"})).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["description"], "changed λ");
assert_eq!(body["retention_mode"], "forever");
assert_eq!(body["retention_days"], Value::Null);
for refused in [
json!({"retention_mode":"bounded"}),
json!({"retention_mode":"forever","retention_days":7}),
json!({"retention_mode":"bounded","retention_days":null}),
json!({"retention_days":366}),
json!({"retention_mode":"sometimes"}),
json!({"description":"λ".repeat(1001)}),
] {
let (status, body) = update(refused.clone()).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{refused} {body}");
}
let (status, body) = update(json!({"retention_mode":"bounded","retention_days":14})).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["retention_days"], 14);
let (status, body) = scoped(&app, "default", "PUT", "/api/namespaces/missing", json!({})).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["reason"], "namespace_not_found");
assert_eq!(body["message"], "Namespace [missing] not found.");
let (_, described) = scoped(
&app,
"default",
"GET",
"/api/namespaces/updated",
Value::Null,
)
.await;
assert_eq!(described["retention_days"], 14);
assert_eq!(described["description"], "changed λ");
runtime.close().await;
database.remove().await;
}
12 changes: 12 additions & 0 deletions scripts/conformance/server-parity/namespace-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ export function checkNamespaces(fixture, observation, workflowId, checkRun) {
assert.deepStrictEqual(refusal.request, original.request, 'foreign completion retains actual original command and claim body');
assert.equal(original.status, 200, 'original namespace can still commit the authored completion');
}
const updated = state.updated;
const created = state.administration[0].created;
assert.deepStrictEqual([updated.name, updated.description, updated.status, updated.retention_mode, updated.retention_days,
updated.external_payload_storage, updated.created_at],
[names[0], definition.description + ' updated', 'active', 'bounded', definition.retention_days + 1, null, created.created_at],
'a namespace update changes only its description and retention');
assert.ok(Date.parse(updated.updated_at) >= Date.parse(created.updated_at), 'the update advances updated_at');
const missing = state.update_missing;
const missingName = 'missing-' + names[0];
assert.equal(missing.status, 404, 'updating a missing namespace is refused');
assert.deepStrictEqual([missing.response.reason, missing.response.namespace, missing.response.message],
['namespace_not_found', missingName, `Namespace [${missingName}] not found.`], 'missing namespace refusal');
}
return {namespaces: names, runs: projections};
}
5 changes: 5 additions & 0 deletions scripts/conformance/server-parity/namespace-probe.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ function finishHttpNamespaces(Client $admin, array $fixture, string $workflowId,
$state['second_worker_heartbeat'] = $clients[1]->heartbeatWorker($state['worker_id']);
}
}
// Updates come last, so every earlier observation keeps the original metadata.
$state['updated'] = $admin->updateNamespace(strtoupper($names[0]), $definition['description'].' updated',
$definition['retention_days'] + 1)->raw;
$state['update_missing'] = namespaceReceipt('update-missing', static fn (): array => $admin->updateNamespace(
'missing-'.$names[0], 'missing', 7)->raw);

return $state;
}
Expand Down
Loading