Replace Worker list revisions with ordered subscriptions
This commit is contained in:
@@ -12,7 +12,7 @@ use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::Event as WorkerProtocolEvent;
|
||||
|
||||
pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 1;
|
||||
pub const SUBSCRIPTION_PROTOCOL_VERSION: u16 = 2;
|
||||
/// Browser/runtime wire guardrails. These bounds are generated into the Workspace
|
||||
/// Browser validator so aggregate limits remain part of the Rust-owned contract.
|
||||
pub const MAX_SUBSCRIPTION_FRAME_JSON_BYTES: usize = 16 * 1024 * 1024;
|
||||
@@ -426,7 +426,6 @@ pub enum SubscriptionResponse {
|
||||
request_id: SubscriptionRequestId,
|
||||
subscription_id: SubscriptionId,
|
||||
selector: EventSubscriptionSelector,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
},
|
||||
Unsubscribed {
|
||||
@@ -512,7 +511,6 @@ pub enum SubscriptionTerminationCode {
|
||||
pub enum SubscriptionEvent {
|
||||
Event {
|
||||
subscription_id: SubscriptionId,
|
||||
subject_revision: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
},
|
||||
SubscriptionClosed {
|
||||
@@ -666,9 +664,7 @@ pub struct SubscriptionWorker {
|
||||
/// `Unavailable` preserves catalog membership without claiming that execution stopped.
|
||||
#[serde(default)]
|
||||
pub availability: SubscriptionWorkerAvailability,
|
||||
/// Producer-owned monotonic revision for this Worker subject.
|
||||
pub subject_revision: u64,
|
||||
/// Latest revisioned foreground state observed from the Worker. This remains
|
||||
/// Latest foreground state observed from the Worker. This remains
|
||||
/// absent until an authoritative Worker snapshot/event has been applied.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_state: Option<crate::WorkerStateSnapshot>,
|
||||
@@ -970,7 +966,6 @@ mod tests {
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
availability: SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: 0,
|
||||
worker_state: None,
|
||||
state: SubscriptionWorkerState::Idle,
|
||||
has_running_internal_workers: false,
|
||||
@@ -1093,7 +1088,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"protocol_version": 2,
|
||||
"frame": "request",
|
||||
"message": {
|
||||
"method": "subscribe_events",
|
||||
@@ -1119,7 +1114,6 @@ mod tests {
|
||||
request_id: request_id(),
|
||||
subscription_id: subscription_id(),
|
||||
selector: EventSubscriptionSelector::RuntimeWorkers,
|
||||
snapshot_revision: 7,
|
||||
snapshot: SubscriptionSnapshot::Workers {
|
||||
workers: vec![worker("worker-1")],
|
||||
},
|
||||
@@ -1158,10 +1152,10 @@ mod tests {
|
||||
subscription_id: subscription_id(),
|
||||
},
|
||||
));
|
||||
frame.protocol_version = 2;
|
||||
frame.protocol_version = 1;
|
||||
assert!(matches!(
|
||||
frame.validate(),
|
||||
Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 2 })
|
||||
Err(SubscriptionValidationError::UnsupportedProtocolVersion { actual: 1 })
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1180,7 +1174,6 @@ mod tests {
|
||||
|
||||
let event = SubscriptionEvent::Event {
|
||||
subscription_id: subscription_id(),
|
||||
subject_revision: 8,
|
||||
payload: SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id: worker_id("worker-2"),
|
||||
runtime_id: None,
|
||||
@@ -1211,7 +1204,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
serde_json::to_value(frame).unwrap(),
|
||||
serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"protocol_version": 2,
|
||||
"frame": "worker_protocol",
|
||||
"message": {
|
||||
"subscription_id": "subscription-1",
|
||||
@@ -1253,7 +1246,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
serde_json::to_value(frame).unwrap(),
|
||||
serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"protocol_version": 2,
|
||||
"frame": "event",
|
||||
"message": {
|
||||
"event": "subscription_closed",
|
||||
|
||||
@@ -230,7 +230,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
runtime_id: Some("fixture-runtime".to_string()),
|
||||
resource_key: Some("fixture-worker-resource".to_string()),
|
||||
availability: SubscriptionWorkerAvailability::default(),
|
||||
subject_revision: 1,
|
||||
worker_state: None,
|
||||
state: SubscriptionWorkerState::Idle,
|
||||
has_running_internal_workers: false,
|
||||
@@ -250,7 +249,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
||||
snapshot_revision: 1,
|
||||
snapshot: SubscriptionSnapshot::Workers {
|
||||
workers: vec![worker],
|
||||
},
|
||||
@@ -259,7 +257,6 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
let pending =
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id,
|
||||
subject_revision: 2,
|
||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||
worker_id,
|
||||
event: Event::PendingSubmissionsChanged {
|
||||
|
||||
@@ -1334,7 +1334,6 @@ async fn runtime_protocol_ws_session(
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
selector: selector.clone(),
|
||||
snapshot_revision: subscription.snapshot_revision(),
|
||||
snapshot: subscription.snapshot().clone(),
|
||||
},
|
||||
));
|
||||
@@ -1354,7 +1353,6 @@ async fn runtime_protocol_ws_session(
|
||||
SubscriptionFramePayload::Event(
|
||||
SubscriptionEvent::Event {
|
||||
subscription_id: event_subscription_id.clone(),
|
||||
subject_revision: update.subject_revision,
|
||||
payload: update.payload,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -36,5 +36,8 @@ pub mod workspace_request;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
pub use management::RuntimeOptions;
|
||||
pub use runtime::{Runtime, RuntimeWorkspaceScope};
|
||||
pub use runtime::{
|
||||
Runtime, RuntimeEventSelectorSubscription, RuntimeSubscriptionRecvError,
|
||||
RuntimeSubscriptionUpdate, RuntimeWorkspaceScope,
|
||||
};
|
||||
pub use session_store::UploadedFileUploadContext;
|
||||
|
||||
@@ -151,7 +151,6 @@ impl RuntimeWorkerRestoreResult {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeSubscriptionUpdate {
|
||||
pub subject_revision: u64,
|
||||
pub payload: SubscriptionEventPayload,
|
||||
}
|
||||
|
||||
@@ -168,7 +167,6 @@ pub enum RuntimeSubscriptionRecvError {
|
||||
pub struct RuntimeEventSelectorSubscription {
|
||||
subscription_id: u64,
|
||||
selector: EventSubscriptionSelector,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
receiver: mpsc::Receiver<RuntimeSubscriptionUpdate>,
|
||||
lagged: Arc<AtomicBool>,
|
||||
@@ -180,10 +178,6 @@ impl RuntimeEventSelectorSubscription {
|
||||
&self.selector
|
||||
}
|
||||
|
||||
pub fn snapshot_revision(&self) -> u64 {
|
||||
self.snapshot_revision
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> &SubscriptionSnapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
@@ -1280,7 +1274,6 @@ impl Runtime {
|
||||
state.persist_runtime_snapshot()?;
|
||||
}
|
||||
let snapshot = state.subscription_snapshot(scope, &selector)?;
|
||||
let snapshot_revision = state.subscription_revision;
|
||||
let subscription_id = state.next_event_subscription_id;
|
||||
state.next_event_subscription_id = state.next_event_subscription_id.saturating_add(1);
|
||||
let (sender, receiver) = mpsc::channel(SUBSCRIPTION_QUEUE_CAPACITY);
|
||||
@@ -1298,7 +1291,6 @@ impl Runtime {
|
||||
Ok(RuntimeEventSelectorSubscription {
|
||||
subscription_id,
|
||||
selector,
|
||||
snapshot_revision,
|
||||
snapshot,
|
||||
receiver,
|
||||
lagged,
|
||||
@@ -2217,9 +2209,9 @@ impl Runtime {
|
||||
if let Some(payload) = input_protocol_event(&input) {
|
||||
state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||
}
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
|
||||
Ok(WorkerInteractionAck {
|
||||
worker_ref: worker_ref.clone(),
|
||||
@@ -2500,9 +2492,9 @@ impl Runtime {
|
||||
worker.workdir_attachments = workdir_attachments;
|
||||
worker.detail()
|
||||
};
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
@@ -2541,8 +2533,8 @@ impl Runtime {
|
||||
state
|
||||
.observation_events
|
||||
.retain(|event| event.worker_ref != *worker_ref);
|
||||
state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.publish_worker_removed(worker_ref.worker_id, workspace_id.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2564,9 +2556,9 @@ impl Runtime {
|
||||
let mut state = self.lock()?;
|
||||
let worker = state.worker_mut(worker_ref)?;
|
||||
worker.apply_worker_state(&snapshot);
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2870,8 +2862,8 @@ impl Runtime {
|
||||
state
|
||||
.observation_events
|
||||
.retain(|event| event.worker_ref != *worker_ref);
|
||||
state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?;
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.publish_worker_removed(worker_ref.worker_id, removed_workspace_id.as_deref())?;
|
||||
Ok(WorkerDeleteResult {
|
||||
worker_id: removed.worker_id,
|
||||
deleted: true,
|
||||
@@ -2959,13 +2951,13 @@ impl Runtime {
|
||||
let worker_state_changed =
|
||||
state.project_protocol_event_to_worker_state(worker_ref, &payload);
|
||||
let activity_changed = state.project_internal_worker_activity(worker_ref, &payload);
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed {
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||
Ok(event)
|
||||
}
|
||||
@@ -3021,13 +3013,13 @@ impl Runtime {
|
||||
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
|
||||
let activity_changed =
|
||||
state.project_internal_worker_activity(&worker_ref, &payload);
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed {
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
Ok(state.push_worker_observation_event(worker_ref, payload))
|
||||
}),
|
||||
)
|
||||
@@ -3054,13 +3046,13 @@ impl Runtime {
|
||||
state.project_protocol_event_to_worker_state(&worker_ref, &payload);
|
||||
let activity_changed =
|
||||
state.project_internal_worker_activity(&worker_ref, &payload);
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed {
|
||||
state.persist_runtime_snapshot()?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
}
|
||||
if worker_state_changed || activity_changed {
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
}
|
||||
Ok(state.push_worker_observation_event(worker_ref, payload))
|
||||
}),
|
||||
)
|
||||
@@ -3454,8 +3446,8 @@ impl Runtime {
|
||||
logical_workdir_attachments_from_statuses(&workdir_attachments, claims);
|
||||
candidate.workdir_attachments = workdir_attachments;
|
||||
state.workers.insert(worker_ref.worker_id, candidate);
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
state.persist_worker(&worker_ref.worker_id)?;
|
||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3702,8 +3694,6 @@ struct RuntimeState {
|
||||
workspace_config_latest: BTreeMap<String, ConfigBundleRef>,
|
||||
workspace_config_fetch_gates: BTreeMap<String, Arc<Mutex<()>>>,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
subscription_revision: u64,
|
||||
worker_subject_revisions: BTreeMap<WorkerId, u64>,
|
||||
next_event_subscription_id: u64,
|
||||
subscriptions: BTreeMap<u64, SubscriptionSink>,
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -3733,8 +3723,6 @@ impl RuntimeState {
|
||||
workspace_config_latest: BTreeMap::new(),
|
||||
workspace_config_fetch_gates: BTreeMap::new(),
|
||||
diagnostics: Vec::new(),
|
||||
subscription_revision: 0,
|
||||
worker_subject_revisions: BTreeMap::new(),
|
||||
next_event_subscription_id: 1,
|
||||
subscriptions: BTreeMap::new(),
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -3765,8 +3753,6 @@ impl RuntimeState {
|
||||
workspace_config_latest: BTreeMap::new(),
|
||||
workspace_config_fetch_gates: BTreeMap::new(),
|
||||
diagnostics: Vec::new(),
|
||||
subscription_revision: 0,
|
||||
worker_subject_revisions: BTreeMap::new(),
|
||||
next_event_subscription_id: 1,
|
||||
subscriptions: BTreeMap::new(),
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -3895,8 +3881,6 @@ impl RuntimeState {
|
||||
workspace_config_fetch_gates: BTreeMap::new(),
|
||||
workspace_owners: persisted.workspace_owners,
|
||||
diagnostics,
|
||||
subscription_revision: 0,
|
||||
worker_subject_revisions: BTreeMap::new(),
|
||||
next_event_subscription_id: 1,
|
||||
subscriptions: BTreeMap::new(),
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -4215,11 +4199,6 @@ impl RuntimeState {
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: self
|
||||
.worker_subject_revisions
|
||||
.get(&worker.worker_id)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
worker_state: worker.worker_state.clone(),
|
||||
state: subscription_worker_state(worker.status),
|
||||
has_running_internal_workers: worker.internal_workers.has_running_worker(),
|
||||
@@ -4232,25 +4211,17 @@ impl RuntimeState {
|
||||
}
|
||||
|
||||
fn publish_worker_upsert(&mut self, worker_id: WorkerId) -> Result<(), RuntimeError> {
|
||||
self.subscription_revision = self.subscription_revision.saturating_add(1);
|
||||
let subject_revision = {
|
||||
let revision = self.worker_subject_revisions.entry(worker_id).or_insert(0);
|
||||
*revision = revision.saturating_add(1);
|
||||
*revision
|
||||
};
|
||||
let worker = self
|
||||
.workers
|
||||
.get(&worker_id)
|
||||
.ok_or(RuntimeError::WorkerNotFound { worker_id })?;
|
||||
let workspace_id = worker.workspace_id.clone();
|
||||
let mut projected = self.subscription_worker(worker)?;
|
||||
projected.subject_revision = subject_revision;
|
||||
let projected = self.subscription_worker(worker)?;
|
||||
let projected_worker_id = projected.worker_id.clone();
|
||||
self.deliver_worker_subscription_update(
|
||||
&projected_worker_id,
|
||||
workspace_id.as_deref(),
|
||||
RuntimeSubscriptionUpdate {
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker: projected },
|
||||
},
|
||||
);
|
||||
@@ -4262,19 +4233,12 @@ impl RuntimeState {
|
||||
worker_id: WorkerId,
|
||||
workspace_id: Option<&str>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
self.subscription_revision = self.subscription_revision.saturating_add(1);
|
||||
let subject_revision = {
|
||||
let revision = self.worker_subject_revisions.entry(worker_id).or_insert(0);
|
||||
*revision = revision.saturating_add(1);
|
||||
*revision
|
||||
};
|
||||
let worker_id = SubscriptionWorkerId::new(worker_id.to_string())
|
||||
.map_err(subscription_validation_error)?;
|
||||
self.deliver_worker_subscription_update(
|
||||
&worker_id,
|
||||
workspace_id,
|
||||
RuntimeSubscriptionUpdate {
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id: worker_id.clone(),
|
||||
runtime_id: None,
|
||||
@@ -4417,8 +4381,8 @@ impl RuntimeState {
|
||||
worker_ref: Some(worker_ref.clone()),
|
||||
});
|
||||
self.workers.insert(worker_ref.worker_id, candidate);
|
||||
self.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
self.persist_runtime_snapshot()?;
|
||||
self.publish_worker_upsert(worker_ref.worker_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6637,7 +6601,6 @@ mod tests {
|
||||
let mut subscription = runtime
|
||||
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
|
||||
.unwrap();
|
||||
assert_eq!(subscription.snapshot_revision(), 0);
|
||||
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
|
||||
panic!("runtime_workers must return a Worker snapshot");
|
||||
};
|
||||
@@ -6645,11 +6608,9 @@ mod tests {
|
||||
|
||||
let created = runtime.create_worker(task_request("live")).unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 1);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
||||
assert_eq!(worker.subject_revision, 1);
|
||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||
}
|
||||
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||
@@ -6666,7 +6627,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 2);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||
@@ -6685,7 +6645,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 3);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||
@@ -6727,7 +6686,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 4);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||
@@ -6738,7 +6696,6 @@ mod tests {
|
||||
|
||||
runtime.stop_worker(&created.worker_ref, None).unwrap();
|
||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||
assert_eq!(update.subject_revision, 5);
|
||||
match update.payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
||||
@@ -6750,6 +6707,87 @@ mod tests {
|
||||
runtime.stop_runtime().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_snapshot_and_worker_creation_cross_the_boundary_exactly_once() {
|
||||
let runtime = runtime_with_backend();
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let subscribing_runtime = runtime.clone();
|
||||
let subscribing_barrier = barrier.clone();
|
||||
let subscriber = std::thread::spawn(move || {
|
||||
subscribing_barrier.wait();
|
||||
subscribing_runtime
|
||||
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
|
||||
.unwrap()
|
||||
});
|
||||
let creating_runtime = runtime.clone();
|
||||
let creating_barrier = barrier.clone();
|
||||
let creator = std::thread::spawn(move || {
|
||||
creating_barrier.wait();
|
||||
creating_runtime
|
||||
.create_worker(task_request("concurrent-boundary"))
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let mut subscription = subscriber.join().unwrap();
|
||||
let created = creator.join().unwrap();
|
||||
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
|
||||
panic!("runtime_workers must return a Worker snapshot");
|
||||
};
|
||||
let in_snapshot = workers
|
||||
.iter()
|
||||
.any(|worker| worker.worker_id.as_str() == created.worker_id.to_string());
|
||||
let in_event = match subscription.receiver.try_recv() {
|
||||
Ok(RuntimeSubscriptionUpdate {
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker },
|
||||
}) => worker.worker_id.as_str() == created.worker_id.to_string(),
|
||||
Ok(update) => panic!("unexpected subscription update: {update:?}"),
|
||||
Err(mpsc::error::TryRecvError::Empty) => false,
|
||||
Err(error) => panic!("unexpected subscription receive error: {error}"),
|
||||
};
|
||||
assert_ne!(
|
||||
in_snapshot, in_event,
|
||||
"the concurrent creation must appear in the snapshot or its ordered suffix, never both"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
#[test]
|
||||
fn restarted_runtime_subscription_starts_from_persisted_authoritative_snapshot() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let options = crate::fs_store::FsRuntimeStoreOptions {
|
||||
root: root.path().join("runtime"),
|
||||
runtime_id: "subscription-restart".to_string(),
|
||||
display_name: None,
|
||||
};
|
||||
let runtime = Runtime::with_fs_store_and_execution_backend(
|
||||
options.clone(),
|
||||
Arc::new(TestExecutionBackend::default()),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let worker = runtime
|
||||
.create_worker(task_request("persisted-subscription-worker"))
|
||||
.unwrap();
|
||||
drop(runtime);
|
||||
|
||||
let restarted = Runtime::with_fs_store_and_execution_backend(
|
||||
options,
|
||||
Arc::new(TestExecutionBackend::default()),
|
||||
)
|
||||
.unwrap();
|
||||
let subscription = restarted
|
||||
.subscribe_event_selector(EventSubscriptionSelector::RuntimeWorkers)
|
||||
.unwrap();
|
||||
let SubscriptionSnapshot::Workers { workers } = subscription.snapshot() else {
|
||||
panic!("runtime_workers must return a Worker snapshot");
|
||||
};
|
||||
assert!(workers.iter().any(|candidate| {
|
||||
candidate.worker_id.as_str() == worker.worker_id.to_string()
|
||||
&& candidate.availability
|
||||
== protocol::subscription::SubscriptionWorkerAvailability::Observed
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_subscription_delivers_only_selected_workers() {
|
||||
let runtime = runtime_with_backend();
|
||||
|
||||
@@ -914,31 +914,15 @@ CREATE TABLE worker_registry_observations (
|
||||
worker_id TEXT NOT NULL,
|
||||
availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')),
|
||||
worker_json TEXT,
|
||||
connection_generation INTEGER NOT NULL,
|
||||
subject_revision INTEGER NOT NULL,
|
||||
snapshot_revision INTEGER NOT NULL,
|
||||
projection_revision INTEGER NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id, worker_id)
|
||||
REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE worker_registry_projection_cursors (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
connection_generation INTEGER NOT NULL,
|
||||
snapshot_revision INTEGER NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id)
|
||||
);
|
||||
CREATE TABLE worker_registry_projection_revisions (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE worker_registry_projection_removals (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
projection_revision INTEGER NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, worker_id)
|
||||
);
|
||||
CREATE TABLE worker_registry_projection_diagnostics (
|
||||
|
||||
@@ -33,26 +33,19 @@ pub enum RuntimeSubscriptionBrokerError {
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BrokerSubscriptionEvent {
|
||||
Snapshot {
|
||||
connection_generation: u64,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
},
|
||||
Event {
|
||||
connection_generation: u64,
|
||||
subject_revision: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
},
|
||||
Disconnected {
|
||||
connection_generation: u64,
|
||||
message: String,
|
||||
},
|
||||
Rejected {
|
||||
connection_generation: u64,
|
||||
code: SubscriptionRejectionCode,
|
||||
message: String,
|
||||
},
|
||||
Closed {
|
||||
connection_generation: u64,
|
||||
code: SubscriptionTerminationCode,
|
||||
message: String,
|
||||
},
|
||||
@@ -60,7 +53,6 @@ pub enum BrokerSubscriptionEvent {
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RuntimeSubscriptionBrokerStatus {
|
||||
pub connection_generation: u64,
|
||||
pub connected: bool,
|
||||
pub desired_selectors: usize,
|
||||
pub upstream_subscriptions: usize,
|
||||
@@ -82,7 +74,30 @@ impl BrokerSubscription {
|
||||
&self.selector
|
||||
}
|
||||
pub async fn recv(&mut self) -> Option<BrokerSubscriptionEvent> {
|
||||
self.receiver.recv().await
|
||||
loop {
|
||||
if let Some(event) = self.receiver.recv().await {
|
||||
return Some(event);
|
||||
}
|
||||
if self.restart().is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restart(&mut self) -> Result<(), RuntimeSubscriptionBrokerError> {
|
||||
self.commands
|
||||
.send(Command::Unsubscribe(self.downstream_id))
|
||||
.map_err(|_| RuntimeSubscriptionBrokerError::Closed)?;
|
||||
let (events, receiver) = mpsc::channel(DOWNSTREAM_QUEUE_CAPACITY);
|
||||
self.commands
|
||||
.send(Command::Subscribe {
|
||||
downstream_id: self.downstream_id,
|
||||
selector: self.selector.clone(),
|
||||
events,
|
||||
})
|
||||
.map_err(|_| RuntimeSubscriptionBrokerError::Closed)?;
|
||||
self.receiver = receiver;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,10 +135,7 @@ impl RuntimeSubscriptionBroker {
|
||||
pub fn register_remote_runtime(&self, config: RemoteRuntimeConfig) -> u64 {
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let (commands, receiver) = mpsc::unbounded_channel();
|
||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
..Default::default()
|
||||
}));
|
||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus::default()));
|
||||
let registration = Registration {
|
||||
generation,
|
||||
commands: commands.clone(),
|
||||
@@ -156,7 +168,6 @@ impl RuntimeSubscriptionBroker {
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let (commands, receiver) = mpsc::unbounded_channel();
|
||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
connected: true,
|
||||
..Default::default()
|
||||
}));
|
||||
@@ -257,7 +268,6 @@ impl RuntimeSubscriptionBroker {
|
||||
.clone();
|
||||
if !initial_status.connected {
|
||||
let _ = initial_events.try_send(BrokerSubscriptionEvent::Disconnected {
|
||||
connection_generation: initial_status.connection_generation,
|
||||
message: "Runtime subscription connection is not currently available".to_string(),
|
||||
});
|
||||
}
|
||||
@@ -286,8 +296,7 @@ struct SelectorState {
|
||||
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||
upstream_id: Option<SubscriptionId>,
|
||||
pending: bool,
|
||||
snapshot: Option<(u64, SubscriptionSnapshot)>,
|
||||
revisions: HashMap<String, u64>,
|
||||
snapshot: Option<SubscriptionSnapshot>,
|
||||
}
|
||||
impl SelectorState {
|
||||
fn new() -> Self {
|
||||
@@ -296,7 +305,6 @@ impl SelectorState {
|
||||
upstream_id: None,
|
||||
pending: false,
|
||||
snapshot: None,
|
||||
revisions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,11 +342,9 @@ impl State {
|
||||
selector.upstream_id = None;
|
||||
selector.pending = false;
|
||||
selector.snapshot = None;
|
||||
selector.revisions.clear();
|
||||
broadcast(
|
||||
&mut selector.downstreams,
|
||||
BrokerSubscriptionEvent::Disconnected {
|
||||
connection_generation: self.generation,
|
||||
message: message.clone(),
|
||||
},
|
||||
);
|
||||
@@ -347,12 +353,77 @@ impl State {
|
||||
}
|
||||
|
||||
struct EmbeddedEntry {
|
||||
lifetime: u64,
|
||||
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
enum EmbeddedUpdate {
|
||||
Event {
|
||||
selector: EventSubscriptionSelector,
|
||||
lifetime: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
},
|
||||
Closed {
|
||||
selector: EventSubscriptionSelector,
|
||||
lifetime: u64,
|
||||
code: SubscriptionTerminationCode,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn start_embedded_subscription(
|
||||
runtime: &worker_runtime::Runtime,
|
||||
runtime_id: &str,
|
||||
selector: EventSubscriptionSelector,
|
||||
lifetime: u64,
|
||||
updates: mpsc::UnboundedSender<EmbeddedUpdate>,
|
||||
) -> Result<(SubscriptionSnapshot, tokio::task::JoinHandle<()>), worker_runtime::error::RuntimeError>
|
||||
{
|
||||
let mut subscription = runtime.subscribe_event_selector(selector.clone())?;
|
||||
let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), runtime_id);
|
||||
let task_runtime_id = runtime_id.to_string();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
match subscription.recv().await {
|
||||
Ok(update) => {
|
||||
let payload = project_payload_runtime(update.payload, &task_runtime_id);
|
||||
if updates
|
||||
.send(EmbeddedUpdate::Event {
|
||||
selector: selector.clone(),
|
||||
lifetime,
|
||||
payload,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(worker_runtime::RuntimeSubscriptionRecvError::Lagged) => {
|
||||
let _ = updates.send(EmbeddedUpdate::Closed {
|
||||
selector,
|
||||
lifetime,
|
||||
code: SubscriptionTerminationCode::Lagged,
|
||||
message: "embedded Runtime subscription lagged; resubscribing from a fresh snapshot".to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Err(worker_runtime::RuntimeSubscriptionRecvError::Closed) => {
|
||||
let _ = updates.send(EmbeddedUpdate::Closed {
|
||||
selector,
|
||||
lifetime,
|
||||
code: SubscriptionTerminationCode::ServerShutdown,
|
||||
message: "embedded Runtime subscription closed".to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((snapshot, task))
|
||||
}
|
||||
|
||||
async fn run_embedded_connection(
|
||||
runtime_id: String,
|
||||
runtime: worker_runtime::Runtime,
|
||||
@@ -364,6 +435,7 @@ async fn run_embedded_connection(
|
||||
let (updates, mut update_receiver) = mpsc::unbounded_channel();
|
||||
let mut entries = HashMap::<EventSubscriptionSelector, EmbeddedEntry>::new();
|
||||
let mut downstream_index = HashMap::<u64, EventSubscriptionSelector>::new();
|
||||
let mut next_lifetime = 1_u64;
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => match command {
|
||||
@@ -371,30 +443,38 @@ async fn run_embedded_connection(
|
||||
downstream_index.insert(downstream_id, selector.clone());
|
||||
if let Some(entry) = entries.get_mut(&selector) {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: generation,
|
||||
snapshot_revision: entry.snapshot_revision,
|
||||
snapshot: entry.snapshot.clone(),
|
||||
});
|
||||
entry.downstreams.insert(downstream_id, events);
|
||||
} else {
|
||||
match runtime.subscribe_event_selector(selector.clone()) {
|
||||
Ok(mut subscription) => {
|
||||
let snapshot_revision = subscription.snapshot_revision();
|
||||
let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), &runtime_id);
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { connection_generation: generation, snapshot_revision, snapshot: snapshot.clone() });
|
||||
let sender = updates.clone();
|
||||
let task_selector = selector.clone();
|
||||
let task_runtime_id = runtime_id.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok(update) = subscription.recv().await {
|
||||
let payload = project_payload_runtime(update.payload, &task_runtime_id);
|
||||
if sender.send((task_selector.clone(), update.subject_revision, payload)).is_err() { break; }
|
||||
}
|
||||
let lifetime = next_lifetime;
|
||||
next_lifetime = next_lifetime.saturating_add(1);
|
||||
match start_embedded_subscription(
|
||||
&runtime,
|
||||
&runtime_id,
|
||||
selector.clone(),
|
||||
lifetime,
|
||||
updates.clone(),
|
||||
) {
|
||||
Ok((snapshot, task)) => {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
entries.insert(selector, EmbeddedEntry { downstreams: HashMap::from([(downstream_id, events)]), snapshot_revision, snapshot, task });
|
||||
entries.insert(
|
||||
selector,
|
||||
EmbeddedEntry {
|
||||
lifetime,
|
||||
downstreams: HashMap::from([(downstream_id, events)]),
|
||||
snapshot,
|
||||
task,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Rejected { connection_generation: generation, code: SubscriptionRejectionCode::UnsupportedSelector, message: error.to_string() });
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Rejected {
|
||||
code: SubscriptionRejectionCode::UnsupportedSelector,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,7 +487,7 @@ async fn run_embedded_connection(
|
||||
}
|
||||
Some(Command::Shutdown(replacement)) => {
|
||||
for entry in entries.values_mut() {
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { connection_generation: generation, code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime generation {generation} was fenced by {replacement}") });
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime registration {generation} was replaced by {replacement}") });
|
||||
entry.task.abort();
|
||||
}
|
||||
return;
|
||||
@@ -415,16 +495,68 @@ async fn run_embedded_connection(
|
||||
None => return,
|
||||
},
|
||||
update = update_receiver.recv() => {
|
||||
let Some((selector, subject_revision, payload)) = update else { return; };
|
||||
if let Some(entry) = entries.get_mut(&selector) {
|
||||
entry.snapshot_revision = entry.snapshot_revision.saturating_add(1);
|
||||
apply_event_to_cached_snapshot(&mut entry.snapshot, &payload);
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { connection_generation: generation, subject_revision, payload });
|
||||
let Some(update) = update else { return; };
|
||||
match update {
|
||||
EmbeddedUpdate::Event { selector, lifetime, payload } => {
|
||||
if let Some(entry) = entries
|
||||
.get_mut(&selector)
|
||||
.filter(|entry| entry.lifetime == lifetime)
|
||||
{
|
||||
apply_event_to_cached_snapshot(&mut entry.snapshot, &payload);
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { payload });
|
||||
}
|
||||
}
|
||||
EmbeddedUpdate::Closed { selector, lifetime, code, message } => {
|
||||
if !entries
|
||||
.get(&selector)
|
||||
.is_some_and(|entry| entry.lifetime == lifetime)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(mut entry) = entries.remove(&selector) else { continue; };
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Closed { code, message },
|
||||
);
|
||||
if entry.downstreams.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let new_lifetime = next_lifetime;
|
||||
next_lifetime = next_lifetime.saturating_add(1);
|
||||
match start_embedded_subscription(
|
||||
&runtime,
|
||||
&runtime_id,
|
||||
selector.clone(),
|
||||
new_lifetime,
|
||||
updates.clone(),
|
||||
) {
|
||||
Ok((snapshot, task)) => {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
snapshot: snapshot.clone(),
|
||||
},
|
||||
);
|
||||
entry.snapshot = snapshot;
|
||||
entry.task = task;
|
||||
entry.lifetime = new_lifetime;
|
||||
entries.insert(selector, entry);
|
||||
}
|
||||
Err(error) => {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Rejected {
|
||||
code: SubscriptionRejectionCode::Internal,
|
||||
message: error.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
connected: true,
|
||||
desired_selectors: entries.len(),
|
||||
upstream_subscriptions: entries.len(),
|
||||
@@ -597,10 +729,8 @@ async fn apply_online(
|
||||
.selectors
|
||||
.entry(selector.clone())
|
||||
.or_insert_with(SelectorState::new);
|
||||
if let Some((revision, snapshot)) = &entry.snapshot {
|
||||
if let Some(snapshot) = &entry.snapshot {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: state.generation,
|
||||
snapshot_revision: *revision,
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
}
|
||||
@@ -696,7 +826,6 @@ async fn handle_frame(
|
||||
request_id,
|
||||
subscription_id,
|
||||
selector,
|
||||
snapshot_revision,
|
||||
snapshot,
|
||||
}) => {
|
||||
if state.pending.remove(&request_id) != Some(selector.clone()) {
|
||||
@@ -706,18 +835,13 @@ async fn handle_frame(
|
||||
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
||||
entry.pending = false;
|
||||
entry.upstream_id = Some(subscription_id.clone());
|
||||
entry.snapshot = Some((snapshot_revision, snapshot.clone()));
|
||||
entry.revisions = snapshot_revisions(&snapshot);
|
||||
entry.snapshot = Some(snapshot.clone());
|
||||
state
|
||||
.upstream_index
|
||||
.insert(subscription_id, selector.clone());
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: state.generation,
|
||||
snapshot_revision,
|
||||
snapshot,
|
||||
},
|
||||
BrokerSubscriptionEvent::Snapshot { snapshot },
|
||||
);
|
||||
if entry.downstreams.is_empty() {
|
||||
maybe_unsubscribe(socket, state, selector).await?;
|
||||
@@ -734,18 +858,13 @@ async fn handle_frame(
|
||||
if let Some(mut entry) = state.selectors.remove(&selector) {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Rejected {
|
||||
connection_generation: state.generation,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
BrokerSubscriptionEvent::Rejected { code, message },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id,
|
||||
subject_revision,
|
||||
payload,
|
||||
}) => {
|
||||
let selector = state
|
||||
@@ -756,24 +875,12 @@ async fn handle_frame(
|
||||
payload.validate_for_selector(&selector).map_err(|_| ())?;
|
||||
let payload = project_payload_runtime(payload, &state.runtime_id);
|
||||
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
||||
if let Some(subject) = event_subject(&payload) {
|
||||
let revision = entry.revisions.entry(subject).or_insert(0);
|
||||
if subject_revision <= *revision {
|
||||
return Ok(());
|
||||
}
|
||||
*revision = subject_revision;
|
||||
}
|
||||
if let Some((snapshot_revision, snapshot)) = entry.snapshot.as_mut() {
|
||||
*snapshot_revision = snapshot_revision.saturating_add(1);
|
||||
if let Some(snapshot) = entry.snapshot.as_mut() {
|
||||
apply_event_to_cached_snapshot(snapshot, &payload);
|
||||
}
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Event {
|
||||
connection_generation: state.generation,
|
||||
subject_revision,
|
||||
payload,
|
||||
},
|
||||
BrokerSubscriptionEvent::Event { payload },
|
||||
);
|
||||
}
|
||||
SubscriptionFramePayload::Event(SubscriptionEvent::SubscriptionClosed {
|
||||
@@ -785,14 +892,9 @@ async fn handle_frame(
|
||||
let should_resubscribe = if let Some(entry) = state.selectors.get_mut(&selector) {
|
||||
entry.upstream_id = None;
|
||||
entry.snapshot = None;
|
||||
entry.revisions.clear();
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation: state.generation,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
BrokerSubscriptionEvent::Closed { code, message },
|
||||
);
|
||||
!entry.downstreams.is_empty()
|
||||
} else {
|
||||
@@ -822,10 +924,9 @@ fn close_all(state: &mut State, replacement: u64) {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation: state.generation,
|
||||
code: SubscriptionTerminationCode::ServerShutdown,
|
||||
message: format!(
|
||||
"Runtime connection generation {} was fenced by generation {replacement}",
|
||||
"Runtime registration {} was replaced by {replacement}",
|
||||
state.generation
|
||||
),
|
||||
},
|
||||
@@ -846,51 +947,6 @@ fn broadcast(
|
||||
downstreams.retain(|id, _| !closed.contains(id));
|
||||
}
|
||||
|
||||
fn snapshot_revisions(snapshot: &SubscriptionSnapshot) -> HashMap<String, u64> {
|
||||
match snapshot {
|
||||
SubscriptionSnapshot::Workers { workers } => workers
|
||||
.iter()
|
||||
.map(|worker| {
|
||||
(
|
||||
format!(
|
||||
"{}:{}",
|
||||
worker.runtime_id.as_deref().unwrap_or_default(),
|
||||
worker.worker_id
|
||||
),
|
||||
worker.subject_revision,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
SubscriptionSnapshot::WorkerProtocol { worker_id, .. } => {
|
||||
HashMap::from([(worker_id.to_string(), 0)])
|
||||
}
|
||||
SubscriptionSnapshot::WorkspaceWorkdirs { .. } => HashMap::new(),
|
||||
}
|
||||
}
|
||||
fn event_subject(payload: &SubscriptionEventPayload) -> Option<String> {
|
||||
Some(match payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => format!(
|
||||
"{}:{}",
|
||||
worker.runtime_id.as_deref().unwrap_or_default(),
|
||||
worker.worker_id
|
||||
),
|
||||
SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id,
|
||||
runtime_id,
|
||||
} => format!(
|
||||
"{}:{}",
|
||||
runtime_id.as_deref().unwrap_or_default(),
|
||||
worker_id
|
||||
),
|
||||
SubscriptionEventPayload::WorkerProtocol { worker_id, .. } => worker_id.to_string(),
|
||||
SubscriptionEventPayload::WorkdirUpserted { workdir } => {
|
||||
workdir.working_directory_id.to_string()
|
||||
}
|
||||
SubscriptionEventPayload::WorkdirRemoved {
|
||||
working_directory_id,
|
||||
} => working_directory_id.to_string(),
|
||||
})
|
||||
}
|
||||
async fn send_frame(socket: &mut RuntimeSocket, frame: SubscriptionFrame) -> Result<(), ()> {
|
||||
frame.validate().map_err(|_| ())?;
|
||||
socket
|
||||
@@ -979,7 +1035,6 @@ fn runtime_token(
|
||||
}
|
||||
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
|
||||
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: state.generation,
|
||||
connected,
|
||||
desired_selectors: state
|
||||
.selectors
|
||||
|
||||
@@ -264,16 +264,9 @@ async fn replacing_runtime_registration_fences_the_old_generation() {
|
||||
assert!(second_generation > first_generation);
|
||||
assert!(matches!(
|
||||
next_event(&mut old).await,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation,
|
||||
..
|
||||
} if connection_generation == first_generation
|
||||
BrokerSubscriptionEvent::Closed { .. }
|
||||
));
|
||||
let status = wait_for_status(&broker, "runtime-test", |status| {
|
||||
status.connection_generation == second_generation && status.connected
|
||||
})
|
||||
.await;
|
||||
assert_eq!(status.connection_generation, second_generation);
|
||||
wait_for_status(&broker, "runtime-test", |status| status.connected).await;
|
||||
server.abort();
|
||||
}
|
||||
|
||||
@@ -430,6 +423,75 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_downstream_overflow_recovers_through_a_fresh_snapshot() {
|
||||
let (runtime, _config, server) = fixture().await;
|
||||
let worker = runtime.list_workers().unwrap().remove(0);
|
||||
let broker = RuntimeSubscriptionBroker::new("local");
|
||||
broker.register_embedded_runtime("embedded-worker-runtime", runtime.clone());
|
||||
let mut subscription = broker
|
||||
.subscribe(
|
||||
"embedded-worker-runtime",
|
||||
EventSubscriptionSelector::RuntimeWorkers,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
next_snapshot(&mut subscription).await,
|
||||
BrokerSubscriptionEvent::Snapshot { .. }
|
||||
));
|
||||
|
||||
let final_command_id = (DOWNSTREAM_QUEUE_CAPACITY as u64) * 3;
|
||||
for command_id in 1..=final_command_id {
|
||||
let mut snapshot = worker.worker_state.clone().unwrap();
|
||||
snapshot.last_command_id = command_id;
|
||||
runtime
|
||||
.observe_worker_event(
|
||||
&worker.worker_ref,
|
||||
protocol::Event::WorkerState { snapshot },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut saw_recovery_snapshot = false;
|
||||
let mut converged = false;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !converged {
|
||||
match subscription
|
||||
.recv()
|
||||
.await
|
||||
.expect("broker subscription closed")
|
||||
{
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||
} => {
|
||||
saw_recovery_snapshot = true;
|
||||
converged = workers.iter().any(|candidate| {
|
||||
candidate.worker_id.as_str() == worker.worker_id.to_string()
|
||||
&& candidate
|
||||
.worker_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.last_command_id == final_command_id)
|
||||
});
|
||||
}
|
||||
BrokerSubscriptionEvent::Event {
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker: candidate },
|
||||
} if saw_recovery_snapshot => {
|
||||
converged = candidate.worker_id.as_str() == worker.worker_id.to_string()
|
||||
&& candidate
|
||||
.worker_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.last_command_id == final_command_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("overflow recovery did not converge");
|
||||
assert!(saw_recovery_snapshot);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
|
||||
let config = RemoteRuntimeConfig::new(
|
||||
|
||||
@@ -1501,10 +1501,11 @@ impl WorkerRemovalService {
|
||||
/// and WorkerRemove use the retention commit below; compensation preserves
|
||||
/// its pre-create semantics while sharing the atomic catalog contract.
|
||||
fn commit_catalog_removal(&self, target: &RuntimeWorkerRef) -> crate::Result<()> {
|
||||
let commit = self
|
||||
.store
|
||||
.delete_worker_registry(&self.workspace_id, target)?;
|
||||
self.worker_projection.publish_commit(commit)
|
||||
self.worker_projection.publish_ordered(|| {
|
||||
self.store
|
||||
.delete_worker_registry(&self.workspace_id, target)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_cleanup_removal(&self, candidate: &CleanupWorkerCandidate) -> ApiResult<()> {
|
||||
@@ -1631,18 +1632,17 @@ impl WorkerRemovalService {
|
||||
));
|
||||
}
|
||||
};
|
||||
match self.store.commit_worker_removal(
|
||||
&self.workspace_id,
|
||||
&prepared.plan.operation_id,
|
||||
&prepared.plan.input_fingerprint,
|
||||
&result,
|
||||
) {
|
||||
Ok(commit) => {
|
||||
self.worker_projection
|
||||
.publish_commit(commit.catalog)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(worker_remove_success_response(target))
|
||||
}
|
||||
match self.worker_projection.publish_ordered(|| {
|
||||
self.store
|
||||
.commit_worker_removal(
|
||||
&self.workspace_id,
|
||||
&prepared.plan.operation_id,
|
||||
&prepared.plan.input_fingerprint,
|
||||
&result,
|
||||
)
|
||||
.map(|commit| ((), commit.catalog))
|
||||
}) {
|
||||
Ok(()) => Ok(worker_remove_success_response(target)),
|
||||
Err(error) => Ok(worker_retention_error_response(error)),
|
||||
}
|
||||
}
|
||||
@@ -1764,12 +1764,12 @@ impl WorkerRemovalService {
|
||||
.map_err(|_| "Worker removal recovery authority is unavailable".to_string())?;
|
||||
if let Some(prepared) = prepared {
|
||||
if prepared.plan.state == crate::retention::WorkerRemovalPlanState::Succeeded {
|
||||
let commit = self
|
||||
.store
|
||||
.recover_succeeded_worker_removal_catalog(&self.workspace_id, target)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.worker_projection
|
||||
.publish_commit(commit)
|
||||
.publish_ordered(|| {
|
||||
self.store
|
||||
.recover_succeeded_worker_removal_catalog(&self.workspace_id, target)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
return Ok(worker_remove_success_response(target));
|
||||
}
|
||||
@@ -1947,17 +1947,17 @@ impl WorkerRemovalService {
|
||||
));
|
||||
}
|
||||
};
|
||||
match self.store.commit_worker_removal(
|
||||
&self.workspace_id,
|
||||
&plan.operation_id,
|
||||
&plan.input_fingerprint,
|
||||
&retention_result,
|
||||
) {
|
||||
Ok(commit) => {
|
||||
self.worker_projection
|
||||
.publish_commit(commit.catalog)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
match self.worker_projection.publish_ordered(|| {
|
||||
self.store
|
||||
.commit_worker_removal(
|
||||
&self.workspace_id,
|
||||
&plan.operation_id,
|
||||
&plan.input_fingerprint,
|
||||
&retention_result,
|
||||
)
|
||||
.map(|commit| ((), commit.catalog))
|
||||
}) {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
let _ = self.store.fail_worker_removal(
|
||||
&self.workspace_id,
|
||||
@@ -3454,17 +3454,19 @@ impl WorkspaceApi {
|
||||
};
|
||||
let bound_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
let runtime_run_id = crate::backend_job::runtime_run_id(&reservation.attempt.attempt_id);
|
||||
let (commit, newly_bound) = self.store.bind_backend_job_attempt_worker(
|
||||
&self.config.workspace_id,
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
&worker.worker,
|
||||
Some(&runtime_run_id),
|
||||
&bound_at,
|
||||
)?;
|
||||
if newly_bound {
|
||||
self.worker_projection.publish_commit(commit)?;
|
||||
} else {
|
||||
let newly_bound = self.worker_projection.publish_ordered(|| {
|
||||
self.store
|
||||
.bind_backend_job_attempt_worker(
|
||||
&self.config.workspace_id,
|
||||
&request.job_id,
|
||||
&reservation.attempt.attempt_id,
|
||||
&worker.worker,
|
||||
Some(&runtime_run_id),
|
||||
&bound_at,
|
||||
)
|
||||
.map(|(commit, newly_bound)| (newly_bound, commit))
|
||||
})?;
|
||||
if !newly_bound {
|
||||
let job = self
|
||||
.store
|
||||
.get_backend_job(&self.config.workspace_id, &request.job_id)?
|
||||
@@ -19435,10 +19437,11 @@ async fn scoped_start_workspace_orchestrator(
|
||||
deleted.diagnostics,
|
||||
));
|
||||
}
|
||||
let removal = api
|
||||
.store
|
||||
.delete_worker_registry(&api.config.workspace_id, &existing.worker)?;
|
||||
api.worker_projection.publish_commit(removal)?;
|
||||
api.worker_projection.publish_ordered(|| {
|
||||
api.store
|
||||
.delete_worker_registry(&api.config.workspace_id, &existing.worker)
|
||||
.map(|commit| ((), commit))
|
||||
})?;
|
||||
disposition = "recreated";
|
||||
}
|
||||
|
||||
@@ -21768,22 +21771,23 @@ async fn set_worker_retention(
|
||||
}
|
||||
}
|
||||
let retention_state = if pinned { "pinned" } else { "normal" };
|
||||
let commit = api.store.update_worker_retention(
|
||||
&api.config.workspace_id,
|
||||
&worker_ref,
|
||||
retention_state,
|
||||
now_registry_timestamp().as_str(),
|
||||
)?;
|
||||
if commit.changes.is_empty() {
|
||||
let changed = api.worker_projection.publish_ordered(|| {
|
||||
api.store
|
||||
.update_worker_retention(
|
||||
&api.config.workspace_id,
|
||||
&worker_ref,
|
||||
retention_state,
|
||||
now_registry_timestamp().as_str(),
|
||||
)
|
||||
.map(|commit| (!commit.changes.is_empty(), commit))
|
||||
})?;
|
||||
if !changed {
|
||||
return Err(cleanup_api_error(
|
||||
runtime_id.as_str(),
|
||||
"workspace_worker_retention_unknown_worker",
|
||||
"Worker is not known to the Backend registry",
|
||||
));
|
||||
}
|
||||
api.worker_projection
|
||||
.publish_commit(commit)
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(Json(WorkerRetentionResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
runtime_id: worker_ref.runtime_id,
|
||||
@@ -26680,7 +26684,7 @@ fn project_observed_workspace_workers(
|
||||
|
||||
fn workers_response(api: WorkspaceApi) -> ApiResult<server_api::WorkerListResponse> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let (_, projections) = api
|
||||
let projections = api
|
||||
.store
|
||||
.worker_registry_projection_snapshot(&api.config.workspace_id, limit)?;
|
||||
let workdir_records = api
|
||||
@@ -27478,9 +27482,12 @@ fn record_worker_summary(
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
};
|
||||
let catalog_commit = api.store.upsert_worker_registry(&record)?;
|
||||
api.worker_projection
|
||||
.publish_commit(catalog_commit)
|
||||
.publish_ordered(|| {
|
||||
api.store
|
||||
.upsert_worker_registry(&record)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.map_err(ApiError::from)?;
|
||||
if let Ok(worker_id) =
|
||||
protocol::subscription::SubscriptionWorkerId::new(worker.worker.worker_id.clone())
|
||||
@@ -27496,7 +27503,6 @@ fn record_worker_summary(
|
||||
runtime_id: Some(worker.worker.runtime_id.clone()),
|
||||
resource_key: None,
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: 0,
|
||||
worker_state: worker.worker_state.clone(),
|
||||
state,
|
||||
has_running_internal_workers: false,
|
||||
@@ -29427,7 +29433,6 @@ mod tests {
|
||||
runtime_id: Some("runtime-a".to_string()),
|
||||
resource_key: Some("W-1".to_string()),
|
||||
availability,
|
||||
subject_revision: 4,
|
||||
worker_state: Some(protocol::WorkerStateSnapshot::initial()),
|
||||
state: protocol::subscription::SubscriptionWorkerState::Idle,
|
||||
has_running_internal_workers: false,
|
||||
@@ -29438,10 +29443,6 @@ mod tests {
|
||||
workdir_attachments: Vec::new(),
|
||||
},
|
||||
availability,
|
||||
connection_generation: 2,
|
||||
subject_revision: 4,
|
||||
snapshot_revision: 7,
|
||||
projection_revision: 9,
|
||||
observed_at: "2026-09-24T00:00:01Z".to_string(),
|
||||
}),
|
||||
job: None,
|
||||
@@ -33018,7 +33019,6 @@ mod tests {
|
||||
runtime_id: Some(EMBEDDED_WORKER_RUNTIME_ID.to_string()),
|
||||
resource_key: None,
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: 1,
|
||||
worker_state: None,
|
||||
state: protocol::subscription::SubscriptionWorkerState::Paused,
|
||||
has_running_internal_workers: false,
|
||||
@@ -44053,7 +44053,8 @@ mod tests {
|
||||
let summary = api.runtime.worker(&target).unwrap();
|
||||
sync_worker_observation(&api, &summary).unwrap();
|
||||
seed_worker_control_grant(&api, &source, &target, "embedded-valid-proof");
|
||||
let mut subscriber = api.worker_projection.subscribe();
|
||||
let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap();
|
||||
let _ = subscriber.take_snapshot();
|
||||
|
||||
let response = WorkerRemovalService::new(&api)
|
||||
.execute_async(
|
||||
@@ -44086,7 +44087,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let removal = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
let event = subscriber.recv().await.expect("projection event");
|
||||
if event.changes.iter().any(|change| {
|
||||
@@ -44098,7 +44099,6 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect("WorkerRemove catalog removal broadcast");
|
||||
assert!(removal.revision > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -44135,7 +44135,8 @@ mod tests {
|
||||
let worker = spawned.worker.unwrap().worker;
|
||||
let summary = api.runtime.worker(&worker).unwrap();
|
||||
sync_worker_observation(&api, &summary).unwrap();
|
||||
let mut subscriber = api.worker_projection.subscribe();
|
||||
let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap();
|
||||
let _ = subscriber.take_snapshot();
|
||||
let diagnostics = compensate_failed_worker_spawn(
|
||||
&api,
|
||||
&summary,
|
||||
@@ -44151,7 +44152,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let removal = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
loop {
|
||||
let event = subscriber.recv().await.expect("projection event");
|
||||
if event.changes.iter().any(|change| {
|
||||
@@ -44163,7 +44164,6 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect("spawn compensation catalog removal broadcast");
|
||||
assert!(removal.revision > 0);
|
||||
sync_worker_observation(&api, &summary).unwrap();
|
||||
assert!(
|
||||
api.store
|
||||
@@ -46005,7 +46005,8 @@ mod tests {
|
||||
.expect("singleton Worker cleanup candidate")
|
||||
.clone();
|
||||
assert_eq!(candidate.blocking_reason, None);
|
||||
let mut subscriber = api.worker_projection.subscribe();
|
||||
let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap();
|
||||
let _ = subscriber.take_snapshot();
|
||||
|
||||
WorkerRemovalService::new(&api)
|
||||
.execute_cleanup_removal(&candidate)
|
||||
@@ -46129,7 +46130,8 @@ mod tests {
|
||||
.iter()
|
||||
.any(|item| item.worker_id.as_str() == worker_id)
|
||||
);
|
||||
let mut subscriber = api.worker_projection.subscribe();
|
||||
let mut subscriber = api.worker_projection.subscribe_ordered(10).unwrap();
|
||||
let _ = subscriber.take_snapshot();
|
||||
let plan = build_runtime_cleanup_plan(&api, EMBEDDED_WORKER_RUNTIME_ID)
|
||||
.unwrap_or_else(|error| panic!("cleanup plan: {}", error.error));
|
||||
let candidate = plan
|
||||
@@ -46173,20 +46175,18 @@ mod tests {
|
||||
event.changes.as_slice(),
|
||||
[crate::store::WorkerCatalogChange::Removed(removed)] if removed == &worker
|
||||
));
|
||||
let acting_revision = tokio::time::timeout(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
next_workspace_worker_removal(&mut acting_socket, worker_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.expect("acting Workspace Worker subscription removal");
|
||||
let observing_revision = tokio::time::timeout(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
next_workspace_worker_removal(&mut observing_socket, worker_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.expect("independent Workspace Worker subscription removal");
|
||||
assert_eq!(acting_revision, event.revision);
|
||||
assert_eq!(observing_revision, event.revision);
|
||||
assert!(
|
||||
api.store
|
||||
.get_worker_registry(&api.config.workspace_id, &worker)
|
||||
@@ -46202,11 +46202,10 @@ mod tests {
|
||||
recovered.plan.state,
|
||||
crate::retention::WorkerRemovalPlanState::Succeeded
|
||||
);
|
||||
let (snapshot_revision, snapshot) = api
|
||||
let snapshot = api
|
||||
.store
|
||||
.worker_registry_projection_snapshot(&api.config.workspace_id, 100)
|
||||
.unwrap();
|
||||
assert_eq!(snapshot_revision, event.revision);
|
||||
assert!(
|
||||
snapshot
|
||||
.iter()
|
||||
@@ -46228,8 +46227,8 @@ mod tests {
|
||||
.all(|item| item.worker_id.as_str() != worker_id)
|
||||
);
|
||||
|
||||
// A retry replays the durable fence at the same revision, healing an
|
||||
// in-process broadcast omitted after the first DB commit.
|
||||
// A retry replays the durable fence, healing an in-process broadcast
|
||||
// omitted after the first DB commit.
|
||||
WorkerRemovalService::new(&api)
|
||||
.commit_catalog_removal(&worker)
|
||||
.unwrap();
|
||||
@@ -46237,7 +46236,6 @@ mod tests {
|
||||
.await
|
||||
.expect("catalog removal replay")
|
||||
.expect("projection replay");
|
||||
assert_eq!(replay.revision, event.revision);
|
||||
assert!(matches!(
|
||||
replay.changes.as_slice(),
|
||||
[crate::store::WorkerCatalogChange::Removed(removed)] if removed == &worker
|
||||
@@ -49669,10 +49667,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_workspace_worker_removal(
|
||||
socket: &mut TestWebSocket,
|
||||
expected_worker_id: &str,
|
||||
) -> u64 {
|
||||
async fn next_workspace_worker_removal(socket: &mut TestWebSocket, expected_worker_id: &str) {
|
||||
loop {
|
||||
let Message::Text(text) = socket.next().await.unwrap().unwrap() else {
|
||||
continue;
|
||||
@@ -49681,7 +49676,6 @@ mod tests {
|
||||
serde_json::from_str(text.as_str()).unwrap();
|
||||
if let protocol::subscription::SubscriptionFramePayload::Event(
|
||||
protocol::subscription::SubscriptionEvent::Event {
|
||||
subject_revision,
|
||||
payload:
|
||||
protocol::subscription::SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id,
|
||||
@@ -49692,7 +49686,7 @@ mod tests {
|
||||
) = frame.payload
|
||||
&& worker_id.as_str() == expected_worker_id
|
||||
{
|
||||
return subject_revision;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49812,7 +49806,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(spawned.state, InternalWorkerOperationState::Accepted);
|
||||
let worker_id = spawned.worker.unwrap().worker.worker_id;
|
||||
let (_, projections) = api
|
||||
let projections = api
|
||||
.store
|
||||
.worker_registry_projection_snapshot(TEST_WORKSPACE_ID, 10)
|
||||
.unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,10 @@ use tokio::task::JoinHandle;
|
||||
use crate::runtime_subscription::{
|
||||
BrokerSubscriptionEvent, RuntimeSubscriptionBroker, RuntimeSubscriptionBrokerError,
|
||||
};
|
||||
use crate::store::{ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, WorkerCatalogChange, WorkerRegistryProjectionCommit,
|
||||
WorkerRegistryProjectionRecord,
|
||||
};
|
||||
use worker_runtime::identity::RuntimeWorkerRef;
|
||||
|
||||
const PROJECTION_EVENT_CAPACITY: usize = 256;
|
||||
@@ -20,21 +23,42 @@ pub type WorkerProjectionChange = WorkerCatalogChange;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerProjectionEvent {
|
||||
pub revision: u64,
|
||||
pub changes: Vec<WorkerProjectionChange>,
|
||||
}
|
||||
|
||||
/// An authoritative durable snapshot followed by changes committed after that snapshot.
|
||||
pub struct WorkerProjectionSubscription {
|
||||
snapshot: Vec<WorkerRegistryProjectionRecord>,
|
||||
receiver: broadcast::Receiver<WorkerProjectionEvent>,
|
||||
}
|
||||
|
||||
impl WorkerProjectionSubscription {
|
||||
pub fn take_snapshot(&mut self) -> Vec<WorkerRegistryProjectionRecord> {
|
||||
std::mem::take(&mut self.snapshot)
|
||||
}
|
||||
|
||||
pub async fn recv(&mut self) -> Result<WorkerProjectionEvent, broadcast::error::RecvError> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn try_recv(&mut self) -> Result<WorkerProjectionEvent, broadcast::error::TryRecvError> {
|
||||
self.receiver.try_recv()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the durable Runtime-to-Workspace Worker projection and its independent
|
||||
/// low-frequency publication stream.
|
||||
///
|
||||
/// One Runtime subscription is kept alive per registered Runtime even when no
|
||||
/// browser is connected. Browser subscriptions therefore never become the
|
||||
/// authority for populating `worker_registry` observations.
|
||||
/// The ordering gate covers both durable commits and subscription snapshot creation.
|
||||
/// Every subscription therefore starts with one authoritative snapshot and receives
|
||||
/// only commits that completed after it, in the same order in which they were stored.
|
||||
pub struct WorkerProjectionService {
|
||||
workspace_id: String,
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
events: broadcast::Sender<WorkerProjectionEvent>,
|
||||
connection_generation_epoch: u64,
|
||||
ordering: Mutex<()>,
|
||||
attach_gate: tokio::sync::Mutex<()>,
|
||||
tasks: Mutex<HashMap<String, JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
@@ -45,8 +69,8 @@ impl WorkerProjectionService {
|
||||
workspace_id: workspace_id.into(),
|
||||
store,
|
||||
events,
|
||||
connection_generation_epoch: Utc::now().timestamp_nanos_opt().unwrap_or_default().max(0)
|
||||
as u64,
|
||||
ordering: Mutex::new(()),
|
||||
attach_gate: tokio::sync::Mutex::new(()),
|
||||
tasks: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
@@ -62,8 +86,38 @@ impl WorkerProjectionService {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<WorkerProjectionEvent> {
|
||||
self.events.subscribe()
|
||||
pub fn subscribe_ordered(&self, limit: usize) -> crate::Result<WorkerProjectionSubscription> {
|
||||
let _ordering = self
|
||||
.ordering
|
||||
.lock()
|
||||
.expect("Worker projection ordering lock poisoned");
|
||||
let receiver = self.events.subscribe();
|
||||
let snapshot_limit = limit.saturating_add(1);
|
||||
let mut snapshot = self
|
||||
.store
|
||||
.worker_registry_projection_snapshot(self.workspace_id.as_str(), snapshot_limit)?;
|
||||
if snapshot.len() > limit {
|
||||
return Err(crate::Error::Store(format!(
|
||||
"authoritative Worker projection exceeds the configured snapshot limit of {limit} records"
|
||||
)));
|
||||
}
|
||||
snapshot.shrink_to_fit();
|
||||
Ok(WorkerProjectionSubscription { snapshot, receiver })
|
||||
}
|
||||
|
||||
/// Serialize one durable projection mutation with its publication. The closure
|
||||
/// must return the exact projection payload captured by the same transaction.
|
||||
pub(crate) fn publish_ordered<T, E>(
|
||||
&self,
|
||||
mutation: impl FnOnce() -> std::result::Result<(T, WorkerRegistryProjectionCommit), E>,
|
||||
) -> std::result::Result<T, E> {
|
||||
let _ordering = self
|
||||
.ordering
|
||||
.lock()
|
||||
.expect("Worker projection ordering lock poisoned");
|
||||
let (result, commit) = mutation()?;
|
||||
self.publish_commit(commit);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn attach_runtime(
|
||||
@@ -71,6 +125,20 @@ impl WorkerProjectionService {
|
||||
broker: &RuntimeSubscriptionBroker,
|
||||
runtime_id: &str,
|
||||
) -> Result<(), RuntimeSubscriptionBrokerError> {
|
||||
// Serialize replacement and wait for the old consumer to stop before the new
|
||||
// lifetime can apply its snapshot. Late notifications can therefore never
|
||||
// cross a Runtime ownership boundary.
|
||||
let _attach = self.attach_gate.lock().await;
|
||||
let previous = self
|
||||
.tasks
|
||||
.lock()
|
||||
.expect("Worker projection task lock poisoned")
|
||||
.remove(runtime_id);
|
||||
if let Some(previous) = previous {
|
||||
previous.abort();
|
||||
let _ = previous.await;
|
||||
}
|
||||
|
||||
let mut subscription =
|
||||
broker.subscribe(runtime_id, EventSubscriptionSelector::RuntimeWorkers)?;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
@@ -80,12 +148,14 @@ impl WorkerProjectionService {
|
||||
break;
|
||||
};
|
||||
let initialized = matches!(event, BrokerSubscriptionEvent::Snapshot { .. });
|
||||
if let Err(error) = self.apply_broker_event(runtime_id, event).await {
|
||||
if let Err(error) = self.apply_broker_event(runtime_id, event) {
|
||||
tracing::warn!(
|
||||
runtime_id,
|
||||
error = %error,
|
||||
"failed to initialize durable Worker projection"
|
||||
"failed to initialize durable Worker projection; restarting from a fresh snapshot"
|
||||
);
|
||||
subscription.restart()?;
|
||||
continue;
|
||||
}
|
||||
if initialized {
|
||||
break;
|
||||
@@ -99,26 +169,22 @@ impl WorkerProjectionService {
|
||||
let Some(service) = service.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = service
|
||||
.apply_broker_event(task_runtime_id.as_str(), event)
|
||||
.await
|
||||
{
|
||||
if let Err(error) = service.apply_broker_event(task_runtime_id.as_str(), event) {
|
||||
tracing::warn!(
|
||||
runtime_id = %task_runtime_id,
|
||||
error = %error,
|
||||
"failed to update durable Worker projection"
|
||||
"failed to update durable Worker projection; restarting from a fresh snapshot"
|
||||
);
|
||||
if subscription.restart().is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(previous) = self
|
||||
.tasks
|
||||
self.tasks
|
||||
.lock()
|
||||
.expect("Worker projection task lock poisoned")
|
||||
.insert(runtime_id, task)
|
||||
{
|
||||
previous.abort();
|
||||
}
|
||||
.insert(runtime_id, task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -128,110 +194,96 @@ impl WorkerProjectionService {
|
||||
worker: &protocol::subscription::SubscriptionWorker,
|
||||
observed_at: &str,
|
||||
) -> crate::Result<()> {
|
||||
let commit = self.store.apply_worker_registry_observation(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
0,
|
||||
worker,
|
||||
observed_at,
|
||||
)?;
|
||||
self.publish_commit(commit)
|
||||
self.publish_ordered(|| {
|
||||
self.store
|
||||
.apply_worker_registry_observation(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
worker,
|
||||
observed_at,
|
||||
)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh(&self, worker: &RuntimeWorkerRef) -> crate::Result<()> {
|
||||
let commit = self
|
||||
.store
|
||||
.commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)?;
|
||||
self.publish_commit(commit)
|
||||
self.publish_ordered(|| {
|
||||
self.store
|
||||
.commit_worker_registry_projection_refresh(self.workspace_id.as_str(), worker)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_broker_event(
|
||||
fn apply_broker_event(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
event: BrokerSubscriptionEvent,
|
||||
) -> crate::Result<()> {
|
||||
let observed_at = Utc::now().to_rfc3339();
|
||||
let projection_generation = |connection_generation: u64| {
|
||||
self.connection_generation_epoch
|
||||
.saturating_add(connection_generation)
|
||||
};
|
||||
let commit = match event {
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation,
|
||||
snapshot_revision,
|
||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||
} => self.store.reconcile_worker_registry_snapshot(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
projection_generation(connection_generation),
|
||||
snapshot_revision,
|
||||
workers.as_slice(),
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Snapshot { .. } => return Ok(()),
|
||||
BrokerSubscriptionEvent::Event {
|
||||
connection_generation,
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker },
|
||||
..
|
||||
} => self.store.apply_worker_registry_observation(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
projection_generation(connection_generation),
|
||||
&worker,
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Event {
|
||||
connection_generation,
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. },
|
||||
} => self.store.mark_worker_registry_observation_unavailable(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
projection_generation(connection_generation),
|
||||
Some(worker_id.as_str()),
|
||||
Some(subject_revision),
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Event { .. } => return Ok(()),
|
||||
BrokerSubscriptionEvent::Disconnected {
|
||||
connection_generation,
|
||||
..
|
||||
}
|
||||
| BrokerSubscriptionEvent::Rejected {
|
||||
connection_generation,
|
||||
..
|
||||
}
|
||||
| BrokerSubscriptionEvent::Closed {
|
||||
connection_generation,
|
||||
..
|
||||
} => self.store.mark_worker_registry_observation_unavailable(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
projection_generation(connection_generation),
|
||||
None,
|
||||
None,
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
};
|
||||
self.publish_commit(commit)
|
||||
self.publish_ordered(|| {
|
||||
let commit = match event {
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||
} => self.store.reconcile_worker_registry_snapshot(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
workers.as_slice(),
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Snapshot { .. } => {
|
||||
return Ok((
|
||||
(),
|
||||
WorkerRegistryProjectionCommit {
|
||||
changes: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
BrokerSubscriptionEvent::Event {
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { worker },
|
||||
} => self.store.apply_worker_registry_observation(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
&worker,
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Event {
|
||||
payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. },
|
||||
} => self.store.mark_worker_registry_observation_unavailable(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
Some(worker_id.as_str()),
|
||||
observed_at.as_str(),
|
||||
)?,
|
||||
BrokerSubscriptionEvent::Event { .. } => {
|
||||
return Ok((
|
||||
(),
|
||||
WorkerRegistryProjectionCommit {
|
||||
changes: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
BrokerSubscriptionEvent::Disconnected { .. }
|
||||
| BrokerSubscriptionEvent::Rejected { .. }
|
||||
| BrokerSubscriptionEvent::Closed { .. } => {
|
||||
self.store.mark_worker_registry_observation_unavailable(
|
||||
self.workspace_id.as_str(),
|
||||
runtime_id,
|
||||
None,
|
||||
observed_at.as_str(),
|
||||
)?
|
||||
}
|
||||
};
|
||||
Ok(((), commit))
|
||||
})
|
||||
}
|
||||
|
||||
/// Broadcast an already committed catalog payload. Persistence always happens
|
||||
/// before this call; a process crash therefore closes subscribers, whose next
|
||||
/// subscription starts from the durable snapshot. Retrying a removal returns
|
||||
/// the same durable change and can heal an in-process missed broadcast.
|
||||
pub(crate) fn publish_commit(
|
||||
&self,
|
||||
commit: WorkerRegistryProjectionCommit,
|
||||
) -> crate::Result<()> {
|
||||
fn publish_commit(&self, commit: WorkerRegistryProjectionCommit) {
|
||||
if commit.changes.is_empty() {
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
let _ = self.events.send(WorkerProjectionEvent {
|
||||
revision: commit.revision,
|
||||
changes: commit.changes,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,3 +292,159 @@ impl Drop for WorkerProjectionService {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Barrier, Mutex};
|
||||
|
||||
use crate::store::{
|
||||
AccountRecord, ControlPlaneStore, SqliteWorkspaceStore, WorkerCatalogChange,
|
||||
WorkerRegistryRecord, WorkspaceRecord,
|
||||
};
|
||||
use worker_runtime::identity::RuntimeWorkerRef;
|
||||
|
||||
use super::WorkerProjectionService;
|
||||
|
||||
const WORKSPACE_ID: &str = "projection-ordering-test";
|
||||
|
||||
async fn fixture() -> (
|
||||
Arc<SqliteWorkspaceStore>,
|
||||
Arc<WorkerProjectionService>,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap());
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: "owner".to_string(),
|
||||
kind: "user".to_string(),
|
||||
handle: "owner".to_string(),
|
||||
display_name: "Owner".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: WORKSPACE_ID.to_string(),
|
||||
owner_account_id: "owner".to_string(),
|
||||
display_name: "Projection ordering".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let service = WorkerProjectionService::new(WORKSPACE_ID, store.clone());
|
||||
(store, service, temp)
|
||||
}
|
||||
|
||||
fn worker(runtime_id: &str, worker_id: &str) -> WorkerRegistryRecord {
|
||||
WorkerRegistryRecord {
|
||||
workspace_id: WORKSPACE_ID.to_string(),
|
||||
worker: RuntimeWorkerRef::new(runtime_id, worker_id),
|
||||
display_name: format!("{runtime_id}-{worker_id}"),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
retention_state: "normal".to_string(),
|
||||
transcript_ref: None,
|
||||
session_ref: None,
|
||||
summary_ref: None,
|
||||
diagnostics_ref: None,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_registration_is_atomic_with_concurrent_publication() {
|
||||
let (store, service, _temp) = fixture().await;
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let subscriber_service = service.clone();
|
||||
let subscriber_barrier = barrier.clone();
|
||||
let subscriber = std::thread::spawn(move || {
|
||||
subscriber_barrier.wait();
|
||||
subscriber_service.subscribe_ordered(10).unwrap()
|
||||
});
|
||||
let publisher_service = service.clone();
|
||||
let publisher_barrier = barrier.clone();
|
||||
let publisher_store = store.clone();
|
||||
let record = worker("runtime-a", "worker-a");
|
||||
let publisher = std::thread::spawn(move || {
|
||||
publisher_barrier.wait();
|
||||
publisher_service
|
||||
.publish_ordered(|| {
|
||||
publisher_store
|
||||
.upsert_worker_registry(&record)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut subscription = subscriber.join().unwrap();
|
||||
publisher.join().unwrap();
|
||||
let in_snapshot = subscription
|
||||
.take_snapshot()
|
||||
.iter()
|
||||
.any(|record| record.registry.worker.worker_id == "worker-a");
|
||||
let in_event = match subscription.try_recv() {
|
||||
Ok(event) => matches!(
|
||||
event.changes.as_slice(),
|
||||
[WorkerCatalogChange::Upsert(record)]
|
||||
if record.registry.worker.worker_id == "worker-a"
|
||||
),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => false,
|
||||
Err(error) => panic!("unexpected projection receive error: {error}"),
|
||||
};
|
||||
assert_ne!(
|
||||
in_snapshot, in_event,
|
||||
"the commit must cross the boundary exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn concurrent_commits_are_published_in_durable_execution_order() {
|
||||
let (store, service, _temp) = fixture().await;
|
||||
let mut subscription = service.subscribe_ordered(10).unwrap();
|
||||
assert!(subscription.take_snapshot().is_empty());
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let durable_order = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut publishers = Vec::new();
|
||||
for worker_id in ["worker-a", "worker-b"] {
|
||||
let service = service.clone();
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
let durable_order = durable_order.clone();
|
||||
let record = worker("runtime-a", worker_id);
|
||||
publishers.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
service
|
||||
.publish_ordered(|| {
|
||||
durable_order
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(record.worker.worker_id.clone());
|
||||
store
|
||||
.upsert_worker_registry(&record)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.unwrap();
|
||||
}));
|
||||
}
|
||||
barrier.wait();
|
||||
for publisher in publishers {
|
||||
publisher.join().unwrap();
|
||||
}
|
||||
|
||||
let published_order = [
|
||||
subscription.try_recv().unwrap(),
|
||||
subscription.try_recv().unwrap(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|event| match event.changes.as_slice() {
|
||||
[WorkerCatalogChange::Upsert(record)] => record.registry.worker.worker_id.clone(),
|
||||
changes => panic!("unexpected projection changes: {changes:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(published_order, *durable_order.lock().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"worker_orphan_diagnostics",
|
||||
"worker_registry",
|
||||
"worker_registry_observations",
|
||||
"worker_registry_projection_cursors",
|
||||
"worker_registry_projection_diagnostics",
|
||||
"worker_registry_projection_removals",
|
||||
"worker_registry_projection_revisions",
|
||||
"worker_removal_operations",
|
||||
"worker_retention_audit_events",
|
||||
"worker_session_archives",
|
||||
|
||||
@@ -271,7 +271,6 @@ async fn run_worker_protocol(
|
||||
worker_id: worker_id.clone(),
|
||||
runtime_id: Some(runtime_id),
|
||||
},
|
||||
snapshot_revision: 0,
|
||||
snapshot: SubscriptionSnapshot::WorkerProtocol {
|
||||
worker_id: worker_id.clone(),
|
||||
events: vec![initial_event],
|
||||
@@ -284,13 +283,10 @@ async fn run_worker_protocol(
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut subject_revision = 0_u64;
|
||||
while let Some(event) = events.recv().await {
|
||||
subject_revision = subject_revision.saturating_add(1);
|
||||
let frame =
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id: subscription_id.clone(),
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||
worker_id: worker_id.clone(),
|
||||
event,
|
||||
@@ -333,12 +329,10 @@ async fn run_workspace_workers(
|
||||
subscription_id: SubscriptionId,
|
||||
outbound: mpsc::Sender<WsMessage>,
|
||||
) {
|
||||
// Subscribe before reading so commits racing with the snapshot are replayed.
|
||||
let mut events = api.worker_projection.subscribe();
|
||||
let Ok((snapshot_revision, records)) = api.store.worker_registry_projection_snapshot(
|
||||
&api.config.workspace_id,
|
||||
api.config.max_records.max(1),
|
||||
) else {
|
||||
let Ok(mut projection) = api
|
||||
.worker_projection
|
||||
.subscribe_ordered(api.config.max_records.max(1))
|
||||
else {
|
||||
let _ = send_rejected(
|
||||
&outbound,
|
||||
request_id,
|
||||
@@ -348,7 +342,8 @@ async fn run_workspace_workers(
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
let mut workers = records
|
||||
let mut workers = projection
|
||||
.take_snapshot()
|
||||
.into_iter()
|
||||
.filter_map(|record| project_registry_worker(&api, record))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -360,7 +355,6 @@ async fn run_workspace_workers(
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
||||
snapshot_revision,
|
||||
snapshot: SubscriptionSnapshot::Workers { workers },
|
||||
},
|
||||
)),
|
||||
@@ -372,16 +366,14 @@ async fn run_workspace_workers(
|
||||
}
|
||||
|
||||
loop {
|
||||
match events.recv().await {
|
||||
Ok(event) if event.revision <= snapshot_revision => continue,
|
||||
match projection.recv().await {
|
||||
Ok(event) => {
|
||||
for change in event.changes {
|
||||
let payload = match change {
|
||||
crate::worker_projection::WorkerProjectionChange::Upsert(record) => {
|
||||
let Some(mut worker) = project_registry_worker(&api, record) else {
|
||||
let Some(worker) = project_registry_worker(&api, record) else {
|
||||
continue;
|
||||
};
|
||||
worker.subject_revision = event.revision;
|
||||
SubscriptionEventPayload::WorkerUpserted { worker }
|
||||
}
|
||||
crate::worker_projection::WorkerProjectionChange::Removed(worker) => {
|
||||
@@ -396,7 +388,7 @@ async fn run_workspace_workers(
|
||||
}
|
||||
}
|
||||
};
|
||||
if send_event(&outbound, &subscription_id, event.revision, payload)
|
||||
if send_event(&outbound, &subscription_id, payload)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -431,7 +423,6 @@ fn project_registry_worker(
|
||||
let mut worker = if let Some(observation) = record.observation {
|
||||
let mut worker = observation.worker;
|
||||
worker.availability = observation.availability;
|
||||
worker.subject_revision = observation.projection_revision;
|
||||
worker
|
||||
} else {
|
||||
SubscriptionWorker {
|
||||
@@ -442,7 +433,6 @@ fn project_registry_worker(
|
||||
runtime_id: Some(runtime_id.clone()),
|
||||
resource_key: record.resource_key.clone(),
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable,
|
||||
subject_revision: 0,
|
||||
worker_state: None,
|
||||
state: protocol::subscription::SubscriptionWorkerState::Stopped,
|
||||
has_running_internal_workers: false,
|
||||
@@ -559,14 +549,12 @@ fn sort_workers(workers: &mut [SubscriptionWorker]) {
|
||||
async fn send_event(
|
||||
outbound: &mpsc::Sender<WsMessage>,
|
||||
subscription_id: &SubscriptionId,
|
||||
subject_revision: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
) -> Result<(), ()> {
|
||||
send_frame(
|
||||
outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id: subscription_id.clone(),
|
||||
subject_revision,
|
||||
payload,
|
||||
})),
|
||||
)
|
||||
@@ -617,7 +605,6 @@ mod tests {
|
||||
runtime_id: Some("runtime-1".to_string()),
|
||||
resource_key: Some("W-1".to_string()),
|
||||
availability: protocol::subscription::SubscriptionWorkerAvailability::Observed,
|
||||
subject_revision: 1,
|
||||
worker_state: None,
|
||||
state: protocol::subscription::SubscriptionWorkerState::Idle,
|
||||
has_running_internal_workers: false,
|
||||
|
||||
@@ -227,7 +227,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
if (frame?.message?.method !== "subscribe_events") return;
|
||||
const requestId = frame.message.params.request_id;
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
@@ -235,7 +235,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
request_id: requestId,
|
||||
subscription_id: "fixture-workers",
|
||||
selector: frame.message.params.selector,
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: "workers", data: { workers: [] } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -129,7 +129,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
if (frame?.message?.method !== "subscribe_events") return;
|
||||
const requestId = frame.message.params.request_id;
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
@@ -137,7 +137,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
request_id: requestId,
|
||||
subscription_id: "fixture-workers",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: "workers", data: { workers: [] } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -138,7 +138,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const frame = JSON.parse(String(event.data));
|
||||
if (frame?.message?.method !== "subscribe_events") return;
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
@@ -146,7 +146,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
request_id: frame.message.params.request_id,
|
||||
subscription_id: "fixture-workers",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: "workers", data: { workers: [] } },
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -304,11 +304,7 @@ resource_key?: string | null,
|
||||
*/
|
||||
availability: SubscriptionWorkerAvailability,
|
||||
/**
|
||||
* Producer-owned monotonic revision for this Worker subject.
|
||||
*/
|
||||
subject_revision: number,
|
||||
/**
|
||||
* Latest revisioned foreground state observed from the Worker. This remains
|
||||
* Latest foreground state observed from the Worker. This remains
|
||||
* absent until an authoritative Worker snapshot/event has been applied.
|
||||
*/
|
||||
worker_state?: WorkerStateSnapshot | null,
|
||||
@@ -336,9 +332,9 @@ export type SubscriptionRequest = { "method": "subscribe_events", "params": { re
|
||||
|
||||
export type SubscriptionWorkerProtocolMethod = { subscription_id: SubscriptionId, method: Method, };
|
||||
|
||||
export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot_revision: number, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } };
|
||||
export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } };
|
||||
|
||||
export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, subject_revision: number, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } };
|
||||
export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } };
|
||||
|
||||
export type SubscriptionFramePayload = { "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod };
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ test('invalid inbound frame closes before dispatch and reconnects for a fresh sn
|
||||
|
||||
first.message(
|
||||
JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'subscription_closed',
|
||||
@@ -141,7 +141,7 @@ test('valid subscription closure remains isolated and resubscribes only its sele
|
||||
: { topic: 'workspace_workdirs', data: { workdirs: [] } };
|
||||
socket.message(
|
||||
JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
@@ -149,7 +149,6 @@ test('valid subscription closure remains isolated and resubscribes only its sele
|
||||
request_id: request.message.params.request_id,
|
||||
subscription_id: `subscription-${index + 1}`,
|
||||
selector,
|
||||
snapshot_revision: 1,
|
||||
snapshot,
|
||||
},
|
||||
},
|
||||
@@ -160,7 +159,7 @@ test('valid subscription closure remains isolated and resubscribes only its sele
|
||||
const sentBeforeClosure = socket.sent.length;
|
||||
socket.message(
|
||||
JSON.stringify({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'subscription_closed',
|
||||
|
||||
@@ -138,7 +138,7 @@ export class WorkspaceMultiplexer {
|
||||
subscription.requestId = requestId;
|
||||
this.#requests.set(requestId, subscription.clientId);
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'request',
|
||||
message: {
|
||||
method: 'subscribe_events',
|
||||
@@ -213,7 +213,7 @@ export class WorkspaceMultiplexer {
|
||||
const subscription = this.#subscriptions.get(clientId);
|
||||
if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open');
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'worker_protocol',
|
||||
message: { subscription_id: subscription.subscriptionId, method },
|
||||
});
|
||||
@@ -225,7 +225,7 @@ export class WorkspaceMultiplexer {
|
||||
this.#subscriptions.delete(clientId);
|
||||
if (subscription.subscriptionId && this.#socket?.readyState === WebSocket.OPEN) {
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'request',
|
||||
message: {
|
||||
method: 'unsubscribe_events',
|
||||
|
||||
@@ -16,7 +16,7 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||
|
||||
function subscribedFrame(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
@@ -24,7 +24,6 @@ function subscribedFrame(overrides: Record<string, unknown> = {}): Record<string
|
||||
request_id: 'request-1',
|
||||
subscription_id: 'subscription-1',
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: 'workers', data: { workers: [] } },
|
||||
...overrides,
|
||||
},
|
||||
@@ -86,7 +85,6 @@ Deno.test('generated subscription validator accepts an external Workdir Worker',
|
||||
runtime_id: 'runtime-1',
|
||||
resource_key: 'worker-resource-1',
|
||||
availability: 'observed',
|
||||
subject_revision: 1,
|
||||
state: 'idle',
|
||||
has_running_internal_workers: false,
|
||||
workspace_id: 'workspace-1',
|
||||
@@ -123,8 +121,8 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
|
||||
'unknown nested field',
|
||||
subscribedFrame({ snapshot: { topic: 'workers', data: { workers: [], future: true } } }),
|
||||
],
|
||||
['wrong field type', subscribedFrame({ snapshot_revision: '1' })],
|
||||
['unsafe integer', subscribedFrame({ snapshot_revision: Number.MAX_SAFE_INTEGER + 1 })],
|
||||
['wrong field type', { ...subscribedFrame(), protocol_version: '2' }],
|
||||
['unsafe integer', { ...subscribedFrame(), protocol_version: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
[
|
||||
'missing serialized Worker defaults',
|
||||
subscribedFrame({
|
||||
@@ -132,23 +130,22 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
|
||||
topic: 'workers',
|
||||
data: {
|
||||
workers: [
|
||||
{ worker_id: 'worker-1', subject_revision: 1, state: 'idle' },
|
||||
{ worker_id: 'worker-1', state: 'idle' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
['unsupported version', { ...subscribedFrame(), protocol_version: 2 }],
|
||||
['unsupported version', { ...subscribedFrame(), protocol_version: 1 }],
|
||||
[
|
||||
'newer Worker event',
|
||||
{
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_protocol',
|
||||
data: { worker_id: 'worker-1', event: { event: 'future_event' } },
|
||||
@@ -160,13 +157,12 @@ Deno.test('generated subscription validator rejects malformed and newer shapes',
|
||||
[
|
||||
'out-of-range Rust u32',
|
||||
{
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_protocol',
|
||||
data: {
|
||||
@@ -207,7 +203,7 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
[
|
||||
'oversized rejection message',
|
||||
{
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscription_rejected',
|
||||
@@ -242,7 +238,6 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
worker_id: 'worker-1',
|
||||
runtime_id: 'runtime-1',
|
||||
availability: 'observed',
|
||||
subject_revision: 1,
|
||||
state: 'idle',
|
||||
has_running_internal_workers: false,
|
||||
},
|
||||
@@ -250,7 +245,6 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
worker_id: 'worker-1',
|
||||
runtime_id: 'runtime-1',
|
||||
availability: 'observed',
|
||||
subject_revision: 2,
|
||||
state: 'running',
|
||||
has_running_internal_workers: false,
|
||||
},
|
||||
@@ -262,7 +256,7 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
[
|
||||
'server-to-Browser direction mismatch',
|
||||
{
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'request',
|
||||
message: {
|
||||
method: 'unsubscribe_events',
|
||||
@@ -281,13 +275,12 @@ Deno.test('generated subscription validator enforces Rust semantic bounds and se
|
||||
Deno.test('aggregate limits include keys in unconstrained Worker JSON values', () => {
|
||||
const oversizedKey = 'k'.repeat(MAX_SUBSCRIPTION_STRING_BYTES + 1);
|
||||
const result = decode({
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_protocol',
|
||||
data: {
|
||||
@@ -306,13 +299,12 @@ Deno.test('aggregate limits include keys in unconstrained Worker JSON values', (
|
||||
|
||||
Deno.test('stateful selector validation fences a routed event before projection', () => {
|
||||
const frame: SubscriptionFrame = {
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_protocol',
|
||||
data: { worker_id: 'worker-2', event: { event: 'thinking_start' } },
|
||||
|
||||
@@ -43,7 +43,7 @@ function snapshot(
|
||||
availability: SubscriptionWorker["availability"],
|
||||
): SubscriptionFrame {
|
||||
return {
|
||||
protocol_version: 1,
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
@@ -51,7 +51,6 @@ function snapshot(
|
||||
request_id: "request-1",
|
||||
subscription_id: "subscription-1",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot_revision: 1,
|
||||
snapshot: {
|
||||
topic: "workers",
|
||||
data: {
|
||||
@@ -60,7 +59,6 @@ function snapshot(
|
||||
runtime_id: "runtime-a",
|
||||
resource_key: "W-1",
|
||||
availability,
|
||||
subject_revision: 1,
|
||||
state,
|
||||
// An absent foreground snapshot must not prevent stopping an active catalog Worker.
|
||||
worker_state: null,
|
||||
|
||||
@@ -6,55 +6,55 @@ import type {
|
||||
|
||||
export type WorkspaceWorkersProjection = {
|
||||
workers: Map<string, SubscriptionWorker>;
|
||||
revisions: Map<string, number>;
|
||||
subscriptionId: string | null;
|
||||
};
|
||||
|
||||
export function createWorkspaceWorkersProjection(): WorkspaceWorkersProjection {
|
||||
return { workers: new Map(), revisions: new Map() };
|
||||
return { workers: new Map(), subscriptionId: null };
|
||||
}
|
||||
|
||||
export function applyWorkspaceWorkersFrame(
|
||||
projection: WorkspaceWorkersProjection,
|
||||
frame: SubscriptionFrame,
|
||||
): void {
|
||||
if (frame.protocol_version !== 1) throw new Error('unsupported Worker subscription protocol');
|
||||
if (frame.protocol_version !== 2) throw new Error('unsupported Worker subscription protocol');
|
||||
if (frame.frame === 'response' && frame.message.result === 'subscribed') {
|
||||
if (frame.message.payload.selector.topic !== 'workspace_workers') return;
|
||||
const snapshot = frame.message.payload.snapshot;
|
||||
if (snapshot.topic !== 'workers') throw new Error('workspace_workers returned a non-Worker snapshot');
|
||||
projection.workers.clear();
|
||||
projection.revisions.clear();
|
||||
|
||||
// Build the replacement before mutating the live projection so one malformed
|
||||
// Worker cannot partially clear the previous subscription lifetime.
|
||||
const workers = new Map<string, SubscriptionWorker>();
|
||||
for (const worker of snapshot.data.workers) {
|
||||
const key = workerKey(worker.runtime_id, worker.worker_id);
|
||||
projection.workers.set(key, worker);
|
||||
projection.revisions.set(key, worker.subject_revision);
|
||||
workers.set(workerKey(worker.runtime_id, worker.worker_id), worker);
|
||||
}
|
||||
projection.workers = workers;
|
||||
projection.subscriptionId = frame.message.payload.subscription_id;
|
||||
return;
|
||||
}
|
||||
if (frame.frame !== 'event' || frame.message.event !== 'event') return;
|
||||
applyPayload(projection, frame.message.data.subject_revision, frame.message.data.payload);
|
||||
const subscriptionId = frame.message.data.subscription_id;
|
||||
if (!projection.subscriptionId) {
|
||||
throw new Error('workspace_workers event arrived before its snapshot');
|
||||
}
|
||||
if (subscriptionId !== projection.subscriptionId) return;
|
||||
applyPayload(projection, frame.message.data.payload);
|
||||
}
|
||||
|
||||
function applyPayload(
|
||||
projection: WorkspaceWorkersProjection,
|
||||
subjectRevision: number,
|
||||
payload: SubscriptionEventPayload,
|
||||
): void {
|
||||
if (payload.event === 'worker_upserted') {
|
||||
const worker = payload.data.worker;
|
||||
const key = workerKey(worker.runtime_id, worker.worker_id);
|
||||
if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return;
|
||||
projection.revisions.set(key, subjectRevision);
|
||||
projection.workers.set(key, worker);
|
||||
projection.workers.set(workerKey(worker.runtime_id, worker.worker_id), worker);
|
||||
} else if (payload.event === 'worker_removed') {
|
||||
const key = workerKey(payload.data.runtime_id, payload.data.worker_id);
|
||||
if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return;
|
||||
projection.revisions.set(key, subjectRevision);
|
||||
projection.workers.delete(key);
|
||||
projection.workers.delete(workerKey(payload.data.runtime_id, payload.data.worker_id));
|
||||
}
|
||||
}
|
||||
|
||||
function workerKey(runtimeId: string | null | undefined, workerId: string): string {
|
||||
if (!runtimeId) throw new Error('Workspace Worker projection is missing runtime_id');
|
||||
return `${runtimeId}:${workerId}`;
|
||||
return JSON.stringify([runtimeId, workerId]);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import type { SubscriptionFrame, SubscriptionWorker } from '$lib/generated/protocol';
|
||||
import type {
|
||||
SubscriptionEventPayload,
|
||||
SubscriptionFrame,
|
||||
SubscriptionWorker,
|
||||
} from '$lib/generated/protocol';
|
||||
|
||||
import { liveWorkerState } from './worker-state';
|
||||
import {
|
||||
applyWorkspaceWorkersFrame,
|
||||
createWorkspaceWorkersProjection,
|
||||
} from './worker-subscription-model';
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
import { liveWorkerState } from './worker-state';
|
||||
import {
|
||||
applyWorkspaceWorkersFrame,
|
||||
createWorkspaceWorkersProjection,
|
||||
} from './worker-subscription-model';
|
||||
|
||||
function assertThrows(fn: () => void, message: string): void {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
@@ -18,7 +32,6 @@ declare const Deno: {
|
||||
function worker(
|
||||
runtimeId: string,
|
||||
workerId: string,
|
||||
revision: number,
|
||||
hasRunningInternalWorkers = false,
|
||||
availability: 'observed' | 'unavailable' = 'observed',
|
||||
): SubscriptionWorker {
|
||||
@@ -26,7 +39,6 @@ function worker(
|
||||
worker_id: workerId,
|
||||
runtime_id: runtimeId,
|
||||
availability,
|
||||
subject_revision: revision,
|
||||
state: 'idle',
|
||||
has_running_internal_workers: hasRunningInternalWorkers,
|
||||
workspace_id: 'workspace-test',
|
||||
@@ -36,15 +48,52 @@ function worker(
|
||||
};
|
||||
}
|
||||
|
||||
function key(runtimeId: string, workerId: string): string {
|
||||
return JSON.stringify([runtimeId, workerId]);
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
subscriptionId: string,
|
||||
workers: SubscriptionWorker[],
|
||||
): SubscriptionFrame {
|
||||
return {
|
||||
protocol_version: 2,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
payload: {
|
||||
request_id: `request-${subscriptionId}`,
|
||||
subscription_id: subscriptionId,
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot: { topic: 'workers', data: { workers } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function event(
|
||||
subscriptionId: string,
|
||||
payload: SubscriptionEventPayload,
|
||||
): SubscriptionFrame {
|
||||
return {
|
||||
protocol_version: 2,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: { subscription_id: subscriptionId, payload },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test('Worker list state uses the authoritative live snapshot separately from lifecycle', () => {
|
||||
const active = worker('runtime-a', 'worker-1', 1);
|
||||
const active = worker('runtime-a', 'worker-1');
|
||||
active.worker_state = {
|
||||
last_command_id: 1,
|
||||
state: { kind: 'busy', state: { kind: 'run', state: 'paused' } },
|
||||
};
|
||||
assertEquals(liveWorkerState(active), 'paused');
|
||||
|
||||
const unavailable = worker('runtime-a', 'worker-2', 1, false, 'unavailable');
|
||||
const unavailable = worker('runtime-a', 'worker-2', false, 'unavailable');
|
||||
unavailable.worker_state = {
|
||||
last_command_id: 2,
|
||||
state: { kind: 'busy', state: { kind: 'run', state: 'running' } },
|
||||
@@ -67,127 +116,118 @@ Deno.test('Worker list state uses the authoritative live snapshot separately fro
|
||||
|
||||
Deno.test('workspace Worker snapshot keeps equal local ids from different Runtimes', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
const frame: SubscriptionFrame = {
|
||||
protocol_version: 1,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
payload: {
|
||||
request_id: 'request-1',
|
||||
subscription_id: 'subscription-1',
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot_revision: 1,
|
||||
snapshot: {
|
||||
topic: 'workers',
|
||||
data: { workers: [worker('runtime-a', '1', 1), worker('runtime-b', '1', 1)] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
applyWorkspaceWorkersFrame(projection, frame);
|
||||
assertEquals([...projection.workers.keys()].sort(), ['runtime-a:1', 'runtime-b:1']);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-1', [worker('runtime-a', '1'), worker('runtime-b', '1')]),
|
||||
);
|
||||
assertEquals([...projection.workers.keys()].sort(), [key('runtime-a', '1'), key('runtime-b', '1')]);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker snapshot and live update preserve canonical Job metadata', () => {
|
||||
Deno.test('workspace Worker snapshot and ordered live update preserve canonical Job metadata', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
const snapshotWorker = worker('runtime-a', 'job-worker', 1);
|
||||
const snapshotWorker = worker('runtime-a', 'job-worker');
|
||||
snapshotWorker.job = {
|
||||
job_id: 'check:T-1:r1',
|
||||
attempt_id: 'check:T-1:r1:attempt:1',
|
||||
purpose: 'ticket_item_check',
|
||||
};
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
payload: {
|
||||
request_id: 'request-1',
|
||||
subscription_id: 'subscription-1',
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: 'workers', data: { workers: [snapshotWorker] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.workers.get('runtime-a:job-worker')?.job, snapshotWorker.job);
|
||||
applyWorkspaceWorkersFrame(projection, snapshot('subscription-1', [snapshotWorker]));
|
||||
assertEquals(projection.workers.get(key('runtime-a', 'job-worker'))?.job, snapshotWorker.job);
|
||||
|
||||
const updated = { ...snapshotWorker, subject_revision: 2 };
|
||||
const updated = { ...snapshotWorker };
|
||||
updated.job = { ...snapshotWorker.job, attempt_id: 'check:T-1:r1:attempt:2' };
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: { event: 'worker_upserted', data: { worker: updated } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.workers.get('runtime-a:job-worker')?.job, updated.job);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', { event: 'worker_upserted', data: { worker: updated } }),
|
||||
);
|
||||
assertEquals(projection.workers.get(key('runtime-a', 'job-worker'))?.job, updated.job);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker reducer ignores stale events and removes composite subject', () => {
|
||||
Deno.test('fresh subscription snapshot replaces old state and fences delayed old events', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 3));
|
||||
projection.revisions.set('runtime-a:1', 3);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-old', [worker('runtime-a', 'old'), worker('runtime-b', 'same')]),
|
||||
);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-old', {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', 'before-reconnect') },
|
||||
}),
|
||||
);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: { event: 'worker_upserted', data: { worker: worker('runtime-a', '1', 2) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 3);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-new', [worker('runtime-a', 'current')]),
|
||||
);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-old', {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', 'late-old-event') },
|
||||
}),
|
||||
);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 4,
|
||||
payload: {
|
||||
event: 'worker_removed',
|
||||
data: { worker_id: '1', runtime_id: 'runtime-a' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.workers.size, 0);
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 4);
|
||||
assertEquals(projection.subscriptionId, 'subscription-new');
|
||||
assertEquals([...projection.workers.keys()], [key('runtime-a', 'current')]);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker reducer applies ordered updates and composite removal', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-1', [worker('runtime-a', '1'), worker('runtime-b', '1')]),
|
||||
);
|
||||
|
||||
const updated = worker('runtime-a', '1');
|
||||
updated.state = 'running';
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', { event: 'worker_upserted', data: { worker: updated } }),
|
||||
);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', {
|
||||
event: 'worker_removed',
|
||||
data: { worker_id: '1', runtime_id: 'runtime-a' },
|
||||
}),
|
||||
);
|
||||
|
||||
assertEquals([...projection.workers.keys()], [key('runtime-b', '1')]);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker event before a snapshot is rejected', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
assertThrows(
|
||||
() =>
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', '1') },
|
||||
}),
|
||||
),
|
||||
'event-before-snapshot must be rejected',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('fatal child stop replaces the running-child sidebar projection', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 1, true));
|
||||
projection.revisions.set('runtime-a:1', 1);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
snapshot('subscription-1', [worker('runtime-a', '1', true)]),
|
||||
);
|
||||
applyWorkspaceWorkersFrame(
|
||||
projection,
|
||||
event('subscription-1', {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', '1', false) },
|
||||
}),
|
||||
);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: {
|
||||
event: 'worker_upserted',
|
||||
data: { worker: worker('runtime-a', '1', 2, false) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assertEquals(projection.workers.get('runtime-a:1')?.has_running_internal_workers, false);
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 2);
|
||||
assertEquals(
|
||||
projection.workers.get(key('runtime-a', '1'))?.has_running_internal_workers,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user