Compare commits
3 commits
52bbc661a2
...
1c8d6ab912
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c8d6ab912 | |||
| 2027e61d84 | |||
| 564c762be9 |
166 changed files with 8932 additions and 5983 deletions
|
|
@ -3,4 +3,4 @@ rustc-workspace-wrapper = "tools/actor-control-flow-lint/rustc-wrapper.py"
|
||||||
|
|
||||||
[alias]
|
[alias]
|
||||||
xtask = "run --package xtask --"
|
xtask = "run --package xtask --"
|
||||||
myelin-chat = "run --package xtask -- myelin-chat"
|
lint = "clippy --workspace --all-targets --all-features"
|
||||||
|
|
|
||||||
2
.config/nextest.toml
Normal file
2
.config/nextest.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[profile.default]
|
||||||
|
slow-timeout = { period = "60s", terminate-after = 1 }
|
||||||
119
.github/workflows/myelin-properties.yml
vendored
119
.github/workflows/myelin-properties.yml
vendored
|
|
@ -1,119 +0,0 @@
|
||||||
name: Myelin properties
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
schedule:
|
|
||||||
- cron: "17 3 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
component-properties:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install pinned Rust toolchain
|
|
||||||
run: rustup toolchain install nightly-2026-02-07 --profile minimal --component rustfmt --component rustc-dev --component rust-src --component llvm-tools-preview
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt --all -- --check
|
|
||||||
- name: Check default workspace members
|
|
||||||
run: cargo check
|
|
||||||
- name: Check compiler-policy contracts
|
|
||||||
run: cargo test -p actor-control-flow-lint-tests --test contracts compiler_policy_contracts -- --exact
|
|
||||||
- name: Engine scheduling properties
|
|
||||||
run: |
|
|
||||||
cargo test -p swactor-engine --test engine_unit generated_actor_timers_and_completion_are_bounded -- --exact
|
|
||||||
cargo test -p swactor-engine --test engine_unit lifecycle_invariant_detects_injected_duplicate_completion -- --exact
|
|
||||||
cargo test -p swactor-engine --test engine_unit lifecycle_invariant_detects_injected_uncancelled_periodic_timer -- --exact
|
|
||||||
- name: Process adapter properties
|
|
||||||
run: |
|
|
||||||
cargo test -p swactor-process --lib operations::properties::generated_stream_observations_close_once_and_stay_closed -- --exact
|
|
||||||
cargo test -p swactor-process --lib operations::properties::generated_lifecycle_actions_make_stop_idempotent_and_exit_terminal -- --exact
|
|
||||||
cargo test -p swactor-process --lib operations::properties::generated_stop_notifications_are_delivered_at_most_once -- --exact
|
|
||||||
cargo test -p swactor-process --lib operations::properties::generated_stdin_commands_and_eof_notify_once -- --exact
|
|
||||||
cargo test -p swactor-process --lib operations::properties::property_invariants_reject_controlled_defects -- --exact
|
|
||||||
cargo test -p swactor-process --lib operations::properties::trivial_real_child_exit_has_a_hard_timeout -- --exact
|
|
||||||
- name: Myelin component properties (exclude job and reconciler tests)
|
|
||||||
run: |
|
|
||||||
cargo test -p myelin --lib orchestration::manual_control::tests::aggressive_random_event_stream_preserves_control_invariants -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::manual_control::tests::rental_free_end_to_end_sequences_converge -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::manual_control::tests::manual_actor_generated_public_actions_and_callbacks_are_bounded -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::manual_control::tests::fixed_helper_cardinality_invariant_detects_controlled_extra_spawn -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::manual_control::tests::callback_panic_reports_typed_failure_without_poisoning_work_actor -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::manual_control::tests::callback_panic_invariant_detects_controlled_unguarded_panic -- --exact
|
|
||||||
cargo test -p myelin --lib provisioning::tests::mock_vastai_handle_state_survives_random_create_and_stop_sequences -- --exact
|
|
||||||
cargo test -p myelin --lib provisioning::tests::docker_generated_attempt_lifecycles_are_idempotent_and_bounded -- --exact
|
|
||||||
cargo test -p myelin --lib provisioning::tests::docker_duplicate_resource_detector_rejects_controlled_fault -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::offer_status_classes_are_offers_or_typed_rejections -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::malformed_offer_bodies_are_typed_rejections -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::wrong_or_missing_offer_fields_are_typed_rejections -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::duplicate_offer_records_remain_explicit_values -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::provider_monitor_preserves_contract_identity_and_cardinality -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::provider_monitor_terminal_polling_stops_after_one_typed_outcome -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::provider_monitor_poll_stop_orderings_cease_polling -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::duplicate_terminal_detector_rejects_controlled_fault -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::ssh_bootstrap_output_lines_preserve_stream_and_protocol -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::ssh_bootstrap_malformed_protocol_is_data_not_poison -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::ssh_bootstrap_eof_orderings_stop_relay_and_actor -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::ssh_bootstrap_child_failures_have_typed_attempt_outcomes -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::ssh_bootstrap_timeout_is_typed_and_stops_polling -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::provider_adapters::vastai::tests::ssh_bootstrap_stop_orderings_emit_one_terminal_and_stop_all_actors -- --exact
|
|
||||||
cargo test -p myelin --lib node::worker_node_runtime::control_flow_properties::runtime_actors_generated_transitions_complete_once_on_one_worker -- --exact
|
|
||||||
cargo test -p myelin --lib node::worker_node_runtime::control_flow_properties::runtime_invariant_checker_rejects_duplicate_readiness_publication -- --exact
|
|
||||||
cargo test -p myelin --lib node::worker_node_runtime::control_flow_properties::helper_wait_generated_terminal_sequences_complete_once_on_one_worker -- --exact
|
|
||||||
cargo test -p myelin --lib node::worker_node_runtime::control_flow_properties::helper_invariant_checker_rejects_expected_output_after_terminal_error -- --exact
|
|
||||||
cargo test -p myelin --lib node::worker_node_runtime::control_flow_properties::stage_fetch_generated_observations_complete_once_on_one_worker -- --exact
|
|
||||||
cargo test -p myelin --lib node::worker_node_runtime::control_flow_properties::stage_invariant_checker_rejects_wrong_terminal_classification -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::app::serve_cluster_properties::serve_cluster_production_transitions_converge_once_without_growth -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::app::serve_cluster_properties::serve_cluster_lifecycle_invariants_reject_injected_duplicate_and_growth -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::control::properties::generated_http_bridge_sequences_terminate_without_control_actor_growth -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::control::properties::generated_duplicate_control_replies_deliver_first_once_and_remove_observer -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::control::properties::reply_observer_disappearance_returns_a_bounded_terminal_http_response -- --exact
|
|
||||||
cargo test -p myelin --lib orchestration::control::properties::http_bridge_invariant_rejects_a_controlled_duplicate_forward -- --exact
|
|
||||||
- name: Controlled E2E oracle contract
|
|
||||||
run: cargo test -p myelin --features test-support --test stateful_vastai e2e_oracle_rejects_controlled_lifecycle_faults -- --exact
|
|
||||||
- name: Dashboard bridge properties
|
|
||||||
run: |
|
|
||||||
cargo test -p dashboard --features demo-control --lib control::properties::generated_concurrent_bridge_commands_forward_once_and_shutdown -- --exact
|
|
||||||
cargo test -p dashboard --features demo-control --lib control::properties::bridge_invariant_rejects_a_controlled_duplicate_delivery -- --exact
|
|
||||||
cargo test -p dashboard --features demo-control --lib server::tests::generated_control_http_sequences_are_bounded_and_typed -- --exact
|
|
||||||
cargo test -p dashboard --features demo-control --lib server::tests::control_http_invariant_rejects_a_controlled_server_error -- --exact
|
|
||||||
- name: Demo actor properties
|
|
||||||
run: |
|
|
||||||
cargo test -p xtask --bin xtask demo::control::properties::generated_control_commands_forward_only_after_supervisor_registration -- --exact
|
|
||||||
cargo test -p xtask --bin xtask demo::control::properties::control_transition_oracle_rejects_duplicate_forwarding -- --exact
|
|
||||||
cargo test -p xtask --bin xtask demo::feed::properties::generated_supervisor_transitions_are_once_only_nonblocking_and_clean -- --exact
|
|
||||||
cargo test -p xtask --bin xtask demo::feed::properties::supervisor_transition_oracle_rejects_duplicate_identity_resources -- --exact
|
|
||||||
cargo test -p xtask --bin xtask demo::node::properties::generated_node_runtime_transitions_emit_heartbeats_and_stop_once -- --exact
|
|
||||||
cargo test -p xtask --bin xtask demo::node::properties::node_transition_oracle_rejects_duplicate_resources -- --exact
|
|
||||||
cargo test -p xtask --bin xtask demo::provider::properties::generated_process_reports_complete_exit_watchers_once_and_preserve_last_state -- --exact
|
|
||||||
cargo test -p xtask --bin xtask demo::provider::properties::process_relay_oracle_rejects_lost_exit -- --exact
|
|
||||||
cargo test -p xtask --bin xtask demo::properties::direct_binary_signal_smoke_has_a_hard_timeout -- --exact
|
|
||||||
|
|
||||||
process-e2e:
|
|
||||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
||||||
needs: component-properties
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install pinned Rust toolchain
|
|
||||||
run: rustup toolchain install nightly-2026-02-07 --profile minimal --component rustc-dev --component rust-src --component llvm-tools-preview
|
|
||||||
- name: Stateful VastAI process E2E
|
|
||||||
env:
|
|
||||||
PROPTEST_CASES: "4"
|
|
||||||
run: |
|
|
||||||
set -o pipefail
|
|
||||||
mkdir -p artifacts
|
|
||||||
cargo test -p myelin --features test-support --test stateful_vastai stateful_vastai_dashboard_control_survives_restarts -- --ignored --exact --nocapture 2>&1 | tee artifacts/stateful-vastai.log
|
|
||||||
- name: Preserve E2E failure artifacts
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: stateful-vastai-failure-${{ github.run_id }}
|
|
||||||
path: |
|
|
||||||
artifacts/stateful-vastai.log
|
|
||||||
apps/myelin/proptest-regressions/tests/e2e_vastai.txt
|
|
||||||
if-no-files-found: warn
|
|
||||||
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -962,7 +962,9 @@ dependencies = [
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"libc",
|
"libc",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"proptest",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"swactor",
|
"swactor",
|
||||||
"swactor-engine",
|
"swactor-engine",
|
||||||
|
|
@ -4335,9 +4337,11 @@ dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"loom",
|
"loom",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"proc-macro2",
|
||||||
"proptest",
|
"proptest",
|
||||||
"proptest-state-machine",
|
"proptest-state-machine",
|
||||||
"serde",
|
"serde",
|
||||||
|
"syn",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time 0.2.4",
|
"web-time 0.2.4",
|
||||||
]
|
]
|
||||||
|
|
@ -4489,11 +4493,13 @@ name = "telemetry"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
|
"futures-channel",
|
||||||
"iroh",
|
"iroh",
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"swactor",
|
"swactor",
|
||||||
|
"swactor-engine",
|
||||||
"swactor-transport",
|
"swactor-transport",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
11
Cargo.toml
11
Cargo.toml
|
|
@ -75,4 +75,15 @@ criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
proptest-state-machine = "0.3"
|
proptest-state-machine = "0.3"
|
||||||
loom = "0.7"
|
loom = "0.7"
|
||||||
|
proc-macro2 = "1"
|
||||||
|
syn = { version = "2", features = ["full", "visit"] }
|
||||||
|
|
||||||
|
|
||||||
|
[workspace.lints.rust]
|
||||||
|
warnings = "deny"
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
allow_attributes = "forbid"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ autobins = false
|
||||||
[features]
|
[features]
|
||||||
default = ["dashboard"]
|
default = ["dashboard"]
|
||||||
dashboard = []
|
dashboard = []
|
||||||
test-support = ["swactor-vastai/test-support"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
telemetry = { path = "../../crates/telemetry" }
|
telemetry = { path = "../../crates/telemetry" }
|
||||||
|
|
@ -41,10 +40,6 @@ tempfile = "3"
|
||||||
swactor-vastai = { path = "../../tools/vastai", features = ["test-support"] }
|
swactor-vastai = { path = "../../tools/vastai", features = ["test-support"] }
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|
||||||
[[test]]
|
|
||||||
name = "stateful_vastai"
|
|
||||||
path = "tests/stateful_vastai.rs"
|
|
||||||
required-features = ["test-support"]
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
@ -65,3 +60,6 @@ path = "src/bin/job_worker.rs"
|
||||||
name = "myelin-job"
|
name = "myelin-job"
|
||||||
path = "src/bin/job.rs"
|
path = "src/bin/job.rs"
|
||||||
|
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE_IMAGE=${BASE_IMAGE:-myelin-node-base:cuda12.6}
|
|
||||||
IMAGE=${IMAGE:-myelin-node:latest}
|
|
||||||
CONTAINER=${CONTAINER:-myelin-node-e2e-$$}
|
|
||||||
GPUS=${MYELIN_CUDA_GPUS:-all}
|
|
||||||
PROMPT=${MYELIN_NODE_SELF_TEST_PROMPT:-ping}
|
|
||||||
TIMEOUT_SECS=${MYELIN_NODE_E2E_TIMEOUT_SECS:-1800}
|
|
||||||
FRAME_LOG=${MYELIN_TELEMETRY_FRAME_LOG:-/var/log/myelin-telemetry.ndjson}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
cargo build --release -p myelin --bin myelin-worker
|
|
||||||
docker build -f apps/myelin/node-image/Dockerfile.base -t "$BASE_IMAGE" .
|
|
||||||
docker build -f apps/myelin/node-image/Dockerfile --build-arg BASE_IMAGE="$BASE_IMAGE" -t "$IMAGE" .
|
|
||||||
|
|
||||||
docker run -d \
|
|
||||||
--name "$CONTAINER" \
|
|
||||||
--gpus "$GPUS" \
|
|
||||||
-e MYELIN_NODE_SELF_TEST_PROMPT="$PROMPT" \
|
|
||||||
-e MYELIN_NODE_MAX_RUNTIME_SECS=1 \
|
|
||||||
-e MYELIN_SELF_TEST_MAX_TOKENS="${MYELIN_SELF_TEST_MAX_TOKENS:-1}" \
|
|
||||||
-e MYELIN_MODEL_CACHE_DIR=/var/cache/myelin-models \
|
|
||||||
-e MYELIN_TELEMETRY_FRAME_LOG="$FRAME_LOG" \
|
|
||||||
${HF_TOKEN:+-e HF_TOKEN="$HF_TOKEN"} \
|
|
||||||
"$IMAGE" >/dev/null
|
|
||||||
|
|
||||||
deadline=$((SECONDS + TIMEOUT_SECS))
|
|
||||||
while (( SECONDS < deadline )); do
|
|
||||||
logs=$(docker logs "$CONTAINER" 2>&1 || true)
|
|
||||||
if grep -q '"type":"ready"' <<<"$logs" && grep -q '"type":"self_test_completed"' <<<"$logs"; then
|
|
||||||
frames=$(docker exec "$CONTAINER" cat "$FRAME_LOG" 2>/dev/null || true)
|
|
||||||
if grep -q '"channel":"myelin.node.ready"' <<<"$frames" &&
|
|
||||||
grep -q '"channel":"myelin.worker.weights"' <<<"$frames" &&
|
|
||||||
grep -q '"channel":"myelin.worker.prompt"' <<<"$frames"; then
|
|
||||||
printf '%s\n' "$logs"
|
|
||||||
printf '%s\n' "$frames"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if grep -q 'WorkerFatal\|myelin-node: .*failed\|ModelLoadFailed\|GgufDownloadFailed' <<<"$logs"; then
|
|
||||||
printf '%s\n' "$logs" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
docker logs "$CONTAINER" 2>&1 || true
|
|
||||||
echo "myelin-node Docker E2E timed out after ${TIMEOUT_SECS}s" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Seeds for stateful VastAI E2E failures. Proptest replays these before generated cases.
|
|
||||||
cc 26c3cd5944cb25de70a984389373e7380a0c6e6ba1c8f3e6eff5a0f78dbef168 # current-strategy replay; E2eCase is derived from e2e_case()
|
|
||||||
cc 5efb0a6c5c348475c859a2c4267d5177aaecd5adefcc5e8b7fc2e6d728deecd1 # shrinks to case = E2eCase { seed: 7099259626237328177, node_seed: 245, kill_mask: 24, offer_offset: 3, actions: [ConcurrentQueries, Search { count: 2 }, Restart { mode: FlushSafeAbrupt }, Query, Kill { node_slot: 218, command_slot: 5 }, Query, Provision { command_slot: 5, use_searched_offers: true }, EndpointProbe { node_slot: 217 }, Restart { mode: Graceful }, Flush, Kill { node_slot: 217, command_slot: 5 }] }
|
|
||||||
cc 6d005687858520ed65af20ec5b2de056efcda15771b6420d7f67e3cb6c246d5b # shrinks to case = E2eCase { seed: 17649392557414661864, node_seed: 249, kill_mask: 159, offer_offset: 5, actions: [Kill { node_slot: 88, command_slot: 242 }, Flush, ConcurrentQueries, EndpointProbe { node_slot: 88 }, Query, Search { count: 2 }, Provision { command_slot: 242, use_searched_offers: true }, Restart { mode: FlushSafeAbrupt }, Kill { node_slot: 89, command_slot: 242 }, Query, Restart { mode: Graceful }] }
|
|
||||||
cc 2e36f51e7cb347b571ac838f81638748f8414a32ec91cb9f4b2730363d72be61 # shrinks to case = E2eCase { seed: 13301618846512983428, node_seed: 99, kill_mask: 30, offer_offset: 2, actions: [Query, Restart { mode: FlushSafeAbrupt }, Kill { node_slot: 136, command_slot: 187 }, ConcurrentQueries, Restart { mode: Graceful }, Query, EndpointProbe { node_slot: 136 }, Kill { node_slot: 137, command_slot: 187 }, Provision { command_slot: 187, use_searched_offers: false }, Flush, Search { count: 0 }] }
|
|
||||||
197
apps/myelin/src/data_namespace.rs
Normal file
197
apps/myelin/src/data_namespace.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use data_plane::blob_transfer::BlobTransferSender;
|
||||||
|
use data_plane::control::{DataNamespaceService, DataPlaneControl};
|
||||||
|
use data_plane::namespace::{NamespaceClient, NamespaceClientActor, NamespaceDiscovery};
|
||||||
|
use data_plane::source::BlobSourcePublisher;
|
||||||
|
use distribution::directory_actor::DirectoryIn;
|
||||||
|
use distribution::registry_actor::{RegistryIn, RegistryView};
|
||||||
|
use iroh_driver::{ActorRegistrar, IrohBlobTransferSender, IrohDriver};
|
||||||
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
|
use crate::orchestration::distribution_stack::DistributionRuntimeStack;
|
||||||
|
|
||||||
|
pub(crate) const DATA_DIRECTORY_SERVICE: &str = "swactor.data-directory";
|
||||||
|
const NAMESPACE_RETRY: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct LocalActorPublisher {
|
||||||
|
runtime: Runtime,
|
||||||
|
distribution_directory: ActorAddress,
|
||||||
|
registrar: ActorRegistrar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalActorPublisher {
|
||||||
|
fn publish(&self, actor: ActorAddress) -> Result<(), String> {
|
||||||
|
let claim = self.registrar.register_actor(actor, 1);
|
||||||
|
self.runtime
|
||||||
|
.send_to(self.distribution_directory, DirectoryIn::Register(claim))
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobSourcePublisher for LocalActorPublisher {
|
||||||
|
fn publish_source(&self, source: ActorAddress) -> Result<(), String> {
|
||||||
|
self.publish(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RegistryNamespaceDiscovery {
|
||||||
|
view: RegistryView,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamespaceDiscovery for RegistryNamespaceDiscovery {
|
||||||
|
fn current_directory(&self) -> Option<ActorAddress> {
|
||||||
|
self.view
|
||||||
|
.read()
|
||||||
|
.expect("registry view poisoned")
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == DATA_DIRECTORY_SERVICE && !entry.tombstone)
|
||||||
|
.map(|entry| entry.actor_addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum NamespaceServicePublisherIn {
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NamespaceServicePublisher {
|
||||||
|
registry: ActorAddress,
|
||||||
|
view: RegistryView,
|
||||||
|
directory: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for NamespaceServicePublisher {
|
||||||
|
type Incoming = NamespaceServicePublisherIn;
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn handle(&mut self, ctx: &Ctx<'_>, message: NamespaceServicePublisherIn) {
|
||||||
|
match message {
|
||||||
|
NamespaceServicePublisherIn::Tick => {
|
||||||
|
let published = self
|
||||||
|
.view
|
||||||
|
.read()
|
||||||
|
.expect("registry view poisoned")
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| {
|
||||||
|
entry.name == DATA_DIRECTORY_SERVICE
|
||||||
|
&& !entry.tombstone
|
||||||
|
&& entry.actor_addr == self.directory
|
||||||
|
});
|
||||||
|
if !published {
|
||||||
|
let _ = ctx.send(
|
||||||
|
self.registry,
|
||||||
|
RegistryIn::RegisterName {
|
||||||
|
name: DATA_DIRECTORY_SERVICE.to_owned(),
|
||||||
|
actor_addr: self.directory,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct DataNamespaceAuthority {
|
||||||
|
service: DataNamespaceService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataNamespaceAuthority {
|
||||||
|
pub(crate) fn start(
|
||||||
|
stack: &DistributionRuntimeStack,
|
||||||
|
driver: &IrohDriver,
|
||||||
|
state_path: impl AsRef<Path>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let runtime = stack.runtime.clone();
|
||||||
|
let source_sender: Arc<dyn BlobTransferSender> = Arc::new(IrohBlobTransferSender::new(
|
||||||
|
driver.edge_connector(),
|
||||||
|
&stack.engine,
|
||||||
|
));
|
||||||
|
let publisher = Arc::new(LocalActorPublisher {
|
||||||
|
runtime: runtime.clone(),
|
||||||
|
distribution_directory: stack.actors.directory,
|
||||||
|
registrar: driver.actor_registrar(),
|
||||||
|
});
|
||||||
|
let source_publisher: Arc<dyn BlobSourcePublisher> = publisher.clone();
|
||||||
|
let service = DataNamespaceService::recover(
|
||||||
|
runtime.clone(),
|
||||||
|
state_path,
|
||||||
|
source_sender,
|
||||||
|
source_publisher,
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("recover data namespace: {error}"))?;
|
||||||
|
let directory = service.directory();
|
||||||
|
publisher.publish(directory)?;
|
||||||
|
runtime
|
||||||
|
.send_to(
|
||||||
|
stack.actors.registry,
|
||||||
|
RegistryIn::RegisterName {
|
||||||
|
name: DATA_DIRECTORY_SERVICE.to_owned(),
|
||||||
|
actor_addr: directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("publish data directory service: {error}"))?;
|
||||||
|
let service_publisher = runtime
|
||||||
|
.spawn(NamespaceServicePublisher {
|
||||||
|
registry: stack.actors.registry,
|
||||||
|
view: Arc::clone(&stack.registry_view),
|
||||||
|
directory,
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("spawn namespace service publisher: {error}"))?;
|
||||||
|
stack.engine.send_every(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
runtime.create_sender(),
|
||||||
|
service_publisher,
|
||||||
|
NamespaceServicePublisherIn::Tick,
|
||||||
|
);
|
||||||
|
Ok(Self { service })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn directory(&self) -> ActorAddress {
|
||||||
|
self.service.directory()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn control(&self) -> DataPlaneControl {
|
||||||
|
self.service.control()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct InstalledNamespaceClient {
|
||||||
|
pub(crate) client: NamespaceClient,
|
||||||
|
pub(crate) source_publisher: Arc<dyn BlobSourcePublisher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_namespace_client(
|
||||||
|
stack: &DistributionRuntimeStack,
|
||||||
|
driver: &IrohDriver,
|
||||||
|
) -> Result<InstalledNamespaceClient, String> {
|
||||||
|
let discovery: Arc<dyn NamespaceDiscovery> = Arc::new(RegistryNamespaceDiscovery {
|
||||||
|
view: Arc::clone(&stack.registry_view),
|
||||||
|
});
|
||||||
|
let proxy = stack
|
||||||
|
.runtime
|
||||||
|
.spawn(NamespaceClientActor::new(
|
||||||
|
stack.engine.clone(),
|
||||||
|
stack.runtime.create_sender(),
|
||||||
|
discovery,
|
||||||
|
NAMESPACE_RETRY,
|
||||||
|
))
|
||||||
|
.map_err(|error| format!("spawn namespace client: {error}"))?;
|
||||||
|
stack.register_local_actor(driver.register_actor(proxy, 1));
|
||||||
|
let source_publisher: Arc<dyn BlobSourcePublisher> = Arc::new(LocalActorPublisher {
|
||||||
|
runtime: stack.runtime.clone(),
|
||||||
|
distribution_directory: stack.actors.directory,
|
||||||
|
registrar: driver.actor_registrar(),
|
||||||
|
});
|
||||||
|
Ok(InstalledNamespaceClient {
|
||||||
|
client: NamespaceClient::new(stack.runtime.clone(), proxy),
|
||||||
|
source_publisher,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,15 @@ use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId as ArenaNodeId};
|
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId as ArenaNodeId};
|
||||||
use data_plane::blob::{BlobMetadata, ContentDigest};
|
use data_plane::blob_transfer::{BlobTransferReceiver, BlobTransferSender};
|
||||||
use data_plane::bootstrap::{self, BootstrapSpec, ENV_DATA_PLANE_ENDPOINT, JobHandoff};
|
use data_plane::bootstrap::{self, BootstrapSpec, ENV_DATA_PLANE_ENDPOINT, JobHandoff};
|
||||||
use data_plane::host::{
|
use data_plane::host::{
|
||||||
BlobSource, HostDataPlaneConfig, HostDataPlaneSessionActor, HostRouteRegistrar,
|
HostDataPlaneConfig, HostDataPlaneSessionActor, HostRouteRegistrar, install_session_env,
|
||||||
install_session_env,
|
|
||||||
};
|
};
|
||||||
use data_plane::path::{DataPath, JobContext};
|
use data_plane::namespace::NamespaceClient;
|
||||||
|
use data_plane::path::JobContext;
|
||||||
use data_plane::protocol::JobCapability;
|
use data_plane::protocol::JobCapability;
|
||||||
|
use data_plane::source::BlobSourcePublisher;
|
||||||
use distribution::transport_bridge::{OutboxRouteBinder, RouteBinder, RouteView};
|
use distribution::transport_bridge::{OutboxRouteBinder, RouteBinder, RouteView};
|
||||||
use distribution::types::NodeId;
|
use distribution::types::NodeId;
|
||||||
use swactor::actor::ActorAddress;
|
use swactor::actor::ActorAddress;
|
||||||
|
|
@ -78,17 +79,33 @@ pub(crate) struct ActorJobDataPlane {
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ActorJobDataPlaneConfig {
|
||||||
|
pub(crate) arena_bytes: u64,
|
||||||
|
pub(crate) arena_generation: u64,
|
||||||
|
pub(crate) session_generation: u64,
|
||||||
|
pub(crate) capability: JobCapability,
|
||||||
|
pub(crate) job_context: JobContext,
|
||||||
|
pub(crate) namespace: Option<NamespaceClient>,
|
||||||
|
pub(crate) transfer_receiver: Option<Arc<dyn BlobTransferReceiver>>,
|
||||||
|
pub(crate) source_sender: Option<Arc<dyn BlobTransferSender>>,
|
||||||
|
pub(crate) source_publisher: Option<Arc<dyn BlobSourcePublisher>>,
|
||||||
|
pub(crate) route_registrar: Option<Arc<dyn HostRouteRegistrar>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl ActorJobDataPlane {
|
impl ActorJobDataPlane {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(runtime: &Runtime, config: ActorJobDataPlaneConfig) -> Result<Self, String> {
|
||||||
runtime: &Runtime,
|
let ActorJobDataPlaneConfig {
|
||||||
arena_bytes: u64,
|
arena_bytes,
|
||||||
arena_generation: u64,
|
arena_generation,
|
||||||
session_generation: u64,
|
session_generation,
|
||||||
capability: JobCapability,
|
capability,
|
||||||
job_context: JobContext,
|
job_context,
|
||||||
blobs: BTreeMap<DataPath, BlobSource>,
|
namespace,
|
||||||
route_registrar: Option<Arc<dyn HostRouteRegistrar>>,
|
transfer_receiver,
|
||||||
) -> Result<Self, String> {
|
source_sender,
|
||||||
|
source_publisher,
|
||||||
|
route_registrar,
|
||||||
|
} = config;
|
||||||
let mut arena = ArenaManager::boot(ArenaConfig {
|
let mut arena = ArenaManager::boot(ArenaConfig {
|
||||||
node_id: ArenaNodeId(1),
|
node_id: ArenaNodeId(1),
|
||||||
reservation_ceiling: arena_bytes,
|
reservation_ceiling: arena_bytes,
|
||||||
|
|
@ -106,12 +123,16 @@ impl ActorJobDataPlane {
|
||||||
let host_session = runtime
|
let host_session = runtime
|
||||||
.spawn(
|
.spawn(
|
||||||
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
||||||
|
runtime: runtime.clone(),
|
||||||
arena,
|
arena,
|
||||||
arena_generation,
|
arena_generation,
|
||||||
session_generation,
|
session_generation,
|
||||||
capability,
|
capability,
|
||||||
job_context,
|
job_context,
|
||||||
blobs,
|
namespace,
|
||||||
|
transfer_receiver,
|
||||||
|
source_sender,
|
||||||
|
source_publisher,
|
||||||
route_registrar,
|
route_registrar,
|
||||||
})
|
})
|
||||||
.map_err(|error| format!("configure host data-plane session: {error}"))?,
|
.map_err(|error| format!("configure host data-plane session: {error}"))?,
|
||||||
|
|
@ -141,55 +162,6 @@ impl ActorJobDataPlane {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn begin_blob_source(
|
|
||||||
&self,
|
|
||||||
path: DataPath,
|
|
||||||
length: u64,
|
|
||||||
digest: Option<ContentDigest>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
futures_lite::future::block_on(async {
|
|
||||||
self.runtime
|
|
||||||
.ask::<data_plane::protocol::HostSessionIn, Result<(), data_plane::protocol::DataPlaneError>>(
|
|
||||||
self.host_session,
|
|
||||||
|reply_to| data_plane::protocol::HostSessionIn::BeginBlobSource {
|
|
||||||
path,
|
|
||||||
metadata: BlobMetadata { length, digest },
|
|
||||||
reply_to,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|error| format!("begin data-plane blob source: {error}"))?
|
|
||||||
.await
|
|
||||||
.map_err(|error| format!("begin data-plane blob source: {error}"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn push_blob_chunk(&self, path: DataPath, bytes: Vec<u8>) -> Result<(), String> {
|
|
||||||
self.runtime
|
|
||||||
.send_to(
|
|
||||||
self.host_session,
|
|
||||||
data_plane::protocol::HostSessionIn::BlobSourceChunk { path, bytes },
|
|
||||||
)
|
|
||||||
.map_err(|error| format!("publish data-plane blob chunk: {error}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish_blob_source(&self, path: DataPath) -> Result<(), String> {
|
|
||||||
self.runtime
|
|
||||||
.send_to(
|
|
||||||
self.host_session,
|
|
||||||
data_plane::protocol::HostSessionIn::FinishBlobSource { path },
|
|
||||||
)
|
|
||||||
.map_err(|error| format!("finish data-plane blob source: {error}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn fail_blob_source(&self, path: DataPath, reason: String) -> Result<(), String> {
|
|
||||||
self.runtime
|
|
||||||
.send_to(
|
|
||||||
self.host_session,
|
|
||||||
data_plane::protocol::HostSessionIn::FailBlobSource { path, reason },
|
|
||||||
)
|
|
||||||
.map_err(|error| format!("fault data-plane blob source: {error}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn close(&self) {
|
pub(crate) fn close(&self) {
|
||||||
let _ = self.runtime.send_to(
|
let _ = self.runtime.send_to(
|
||||||
self.host_session,
|
self.host_session,
|
||||||
|
|
@ -206,11 +178,6 @@ impl ActorJobDataPlane {
|
||||||
env
|
env
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn host_session(&self) -> ActorAddress {
|
|
||||||
self.host_session
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn arena_fd(&self) -> std::os::fd::RawFd {
|
pub(crate) fn arena_fd(&self) -> std::os::fd::RawFd {
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
//! in the directory, and exchanges its `EndpointAddr` + actor address
|
//! in the directory, and exchanges its `EndpointAddr` + actor address
|
||||||
//! out-of-band so each side can route to the other over the iroh actor plane.
|
//! out-of-band so each side can route to the other over the iroh actor plane.
|
||||||
|
|
||||||
|
use crate::job_data_plane::{
|
||||||
|
ActorJobDataPlane, ActorJobDataPlaneConfig, MyelinChildRouteRegistrar,
|
||||||
|
};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
@ -23,11 +26,12 @@ use swactor_job_runner::{
|
||||||
};
|
};
|
||||||
use swactor_transport::hex_encode;
|
use swactor_transport::hex_encode;
|
||||||
|
|
||||||
use data_plane::blob::BlobMetadata;
|
use data_plane::blob_transfer::{BlobTransferReceiver, BlobTransferSender};
|
||||||
use data_plane::edge_wire::WireEvent;
|
use data_plane::edge_wire::WireEvent;
|
||||||
use data_plane::host::BlobSource;
|
use data_plane::namespace::NamespaceClient;
|
||||||
use data_plane::path::{DataPath, JobContext};
|
use data_plane::path::{DataPath, JobContext};
|
||||||
use data_plane::protocol::{JobCapability, register_data_plane_codecs};
|
use data_plane::protocol::{JobCapability, register_data_plane_codecs};
|
||||||
|
use data_plane::source::BlobSourcePublisher;
|
||||||
use distribution::node::DistributedNodeConfig;
|
use distribution::node::DistributedNodeConfig;
|
||||||
use iroh::{EndpointAddr, RelayMode};
|
use iroh::{EndpointAddr, RelayMode};
|
||||||
use iroh_driver::{
|
use iroh_driver::{
|
||||||
|
|
@ -37,7 +41,6 @@ use iroh_driver::{
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
use crate::job_data_plane::{ActorJobDataPlane, MyelinChildRouteRegistrar};
|
|
||||||
use crate::orchestration::distribution_stack::DistributionRuntimeStack;
|
use crate::orchestration::distribution_stack::DistributionRuntimeStack;
|
||||||
|
|
||||||
const POLL: Duration = Duration::from_millis(25);
|
const POLL: Duration = Duration::from_millis(25);
|
||||||
|
|
@ -51,24 +54,15 @@ const JOB_OUTPUT_SOCKET: &str = "inference-results.sock";
|
||||||
const DATA_PLANE_CONNECT_DEADLINE: Duration = Duration::from_secs(30);
|
const DATA_PLANE_CONNECT_DEADLINE: Duration = Duration::from_secs(30);
|
||||||
const JOB_ARENA_BYTES: u64 = 1 << 20;
|
const JOB_ARENA_BYTES: u64 = 1 << 20;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
struct InputBlobAssignment {
|
|
||||||
edge_id: u64,
|
|
||||||
path: DataPath,
|
|
||||||
metadata: BlobMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
struct EmbeddedDataPlaneAssignment {
|
struct EmbeddedDataPlaneAssignment {
|
||||||
result_endpoint: EndpointAddr,
|
result_endpoint: EndpointAddr,
|
||||||
input_blobs: Vec<InputBlobAssignment>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actor-driven finite-blob ingress plus the retained temporary Unix output
|
/// Actor-driven finite-blob ingress plus the retained temporary Unix output
|
||||||
/// stream bridge. Remote bytes remain on `EDGE_ALPN`.
|
/// stream bridge. Remote bytes remain on `EDGE_ALPN`.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct EmbeddedJobDataPlane {
|
pub(crate) struct EmbeddedJobDataPlane {
|
||||||
input_paths: Arc<Mutex<BTreeMap<u64, DataPath>>>,
|
|
||||||
result_sink: Arc<Mutex<Option<EdgeSendHandle>>>,
|
result_sink: Arc<Mutex<Option<EdgeSendHandle>>>,
|
||||||
result_ready: Arc<Notify>,
|
result_ready: Arc<Notify>,
|
||||||
connector: EdgeConnector,
|
connector: EdgeConnector,
|
||||||
|
|
@ -77,14 +71,32 @@ pub(crate) struct EmbeddedJobDataPlane {
|
||||||
output_path: PathBuf,
|
output_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct EmbeddedJobDataPlaneConfig<'a> {
|
||||||
|
pub(crate) engine: EngineHandle,
|
||||||
|
pub(crate) connector: EdgeConnector,
|
||||||
|
pub(crate) root: &'a Path,
|
||||||
|
pub(crate) host_endpoint: EndpointAddr,
|
||||||
|
pub(crate) namespace: NamespaceClient,
|
||||||
|
pub(crate) transfer_receiver: Arc<dyn BlobTransferReceiver>,
|
||||||
|
pub(crate) source_sender: Arc<dyn BlobTransferSender>,
|
||||||
|
pub(crate) source_publisher: Arc<dyn BlobSourcePublisher>,
|
||||||
|
}
|
||||||
|
|
||||||
impl EmbeddedJobDataPlane {
|
impl EmbeddedJobDataPlane {
|
||||||
pub(crate) fn start(
|
pub(crate) fn start(
|
||||||
engine: EngineHandle,
|
|
||||||
connector: EdgeConnector,
|
|
||||||
root: &Path,
|
|
||||||
stack: &DistributionRuntimeStack,
|
stack: &DistributionRuntimeStack,
|
||||||
host_endpoint: EndpointAddr,
|
config: EmbeddedJobDataPlaneConfig<'_>,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
|
let EmbeddedJobDataPlaneConfig {
|
||||||
|
engine,
|
||||||
|
connector,
|
||||||
|
root,
|
||||||
|
host_endpoint,
|
||||||
|
namespace,
|
||||||
|
transfer_receiver,
|
||||||
|
source_sender,
|
||||||
|
source_publisher,
|
||||||
|
} = config;
|
||||||
std::fs::create_dir_all(root)
|
std::fs::create_dir_all(root)
|
||||||
.map_err(|error| format!("create job data-plane root {}: {error}", root.display()))?;
|
.map_err(|error| format!("create job data-plane root {}: {error}", root.display()))?;
|
||||||
let output_path = root.join(JOB_OUTPUT_SOCKET);
|
let output_path = root.join(JOB_OUTPUT_SOCKET);
|
||||||
|
|
@ -97,22 +109,26 @@ impl EmbeddedJobDataPlane {
|
||||||
));
|
));
|
||||||
let actor_plane = Arc::new(ActorJobDataPlane::new(
|
let actor_plane = Arc::new(ActorJobDataPlane::new(
|
||||||
&stack.runtime,
|
&stack.runtime,
|
||||||
JOB_ARENA_BYTES,
|
ActorJobDataPlaneConfig {
|
||||||
1,
|
arena_bytes: JOB_ARENA_BYTES,
|
||||||
1,
|
arena_generation: 1,
|
||||||
|
session_generation: 1,
|
||||||
capability,
|
capability,
|
||||||
JobContext {
|
job_context: JobContext {
|
||||||
run_id: "unconfigured".to_owned(),
|
run_id: "unconfigured".to_owned(),
|
||||||
read_prefixes: vec![DataPath::parse("/models").expect("static model prefix")],
|
read_prefixes: vec![DataPath::parse("/models").expect("static model prefix")],
|
||||||
write_prefixes: vec![DataPath::parse("/runs").expect("static run prefix")],
|
write_prefixes: vec![DataPath::parse("/runs").expect("static run prefix")],
|
||||||
},
|
},
|
||||||
BTreeMap::<DataPath, BlobSource>::new(),
|
namespace: Some(namespace),
|
||||||
Some(route_registrar),
|
transfer_receiver: Some(transfer_receiver),
|
||||||
|
source_sender: Some(source_sender),
|
||||||
|
source_publisher: Some(source_publisher),
|
||||||
|
route_registrar: Some(route_registrar),
|
||||||
|
},
|
||||||
)?);
|
)?);
|
||||||
let host_endpoint_json = serde_json::to_string(&host_endpoint)
|
let host_endpoint_json = serde_json::to_string(&host_endpoint)
|
||||||
.map_err(|error| format!("serialize host data-plane endpoint: {error}"))?;
|
.map_err(|error| format!("serialize host data-plane endpoint: {error}"))?;
|
||||||
|
|
||||||
let input_paths = Arc::new(Mutex::new(BTreeMap::new()));
|
|
||||||
let result_sink: Arc<Mutex<Option<EdgeSendHandle>>> = Arc::new(Mutex::new(None));
|
let result_sink: Arc<Mutex<Option<EdgeSendHandle>>> = Arc::new(Mutex::new(None));
|
||||||
let result_ready = Arc::new(Notify::new());
|
let result_ready = Arc::new(Notify::new());
|
||||||
|
|
||||||
|
|
@ -147,7 +163,6 @@ impl EmbeddedJobDataPlane {
|
||||||
.map_err(|error| format!("bind job data-plane output: {error}"))?;
|
.map_err(|error| format!("bind job data-plane output: {error}"))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
input_paths,
|
|
||||||
result_sink,
|
result_sink,
|
||||||
result_ready,
|
result_ready,
|
||||||
connector,
|
connector,
|
||||||
|
|
@ -156,40 +171,6 @@ impl EmbeddedJobDataPlane {
|
||||||
host_endpoint_json,
|
host_endpoint_json,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn drain_input_events(&self, events: &Arc<Mutex<Vec<WireEvent>>>) {
|
|
||||||
let input_paths = self.input_paths.lock().clone();
|
|
||||||
let mut events = events.lock();
|
|
||||||
let mut remaining = Vec::with_capacity(events.len());
|
|
||||||
for event in events.drain(..) {
|
|
||||||
match event {
|
|
||||||
WireEvent::BytesRead { edge_id, bytes, .. }
|
|
||||||
if input_paths.contains_key(&edge_id.0) =>
|
|
||||||
{
|
|
||||||
let path = input_paths[&edge_id.0].clone();
|
|
||||||
let _ = self.actor_plane.push_blob_chunk(path, bytes);
|
|
||||||
}
|
|
||||||
WireEvent::StreamEnded { edge_id, .. } if input_paths.contains_key(&edge_id.0) => {
|
|
||||||
let path = input_paths[&edge_id.0].clone();
|
|
||||||
let _ = self.actor_plane.finish_blob_source(path);
|
|
||||||
}
|
|
||||||
WireEvent::StreamFault {
|
|
||||||
edge_id: Some(edge_id),
|
|
||||||
reason,
|
|
||||||
..
|
|
||||||
} if input_paths.contains_key(&edge_id.0) => {
|
|
||||||
let path = input_paths[&edge_id.0].clone();
|
|
||||||
let _ = self
|
|
||||||
.actor_plane
|
|
||||||
.fail_blob_source(path, format!("{reason:?}"));
|
|
||||||
}
|
|
||||||
WireEvent::StreamArrived { edge_id, .. }
|
|
||||||
if input_paths.contains_key(&edge_id.0) => {}
|
|
||||||
event => remaining.push(event),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
events.extend(remaining);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobDataPlanePort for EmbeddedJobDataPlane {
|
impl JobDataPlanePort for EmbeddedJobDataPlane {
|
||||||
|
|
@ -208,24 +189,6 @@ impl JobDataPlanePort for EmbeddedJobDataPlane {
|
||||||
*self.result_sink.lock() = Some(sink);
|
*self.result_sink.lock() = Some(sink);
|
||||||
self.result_ready.notify_one();
|
self.result_ready.notify_one();
|
||||||
self.actor_plane.configure_run(job_id.to_string())?;
|
self.actor_plane.configure_run(job_id.to_string())?;
|
||||||
let mut input_paths = BTreeMap::new();
|
|
||||||
for input in assignment.input_blobs {
|
|
||||||
if input_paths
|
|
||||||
.insert(input.edge_id, input.path.clone())
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Err(format!(
|
|
||||||
"duplicate input blob edge id {} in job assignment",
|
|
||||||
input.edge_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.actor_plane.begin_blob_source(
|
|
||||||
input.path,
|
|
||||||
input.metadata.length,
|
|
||||||
input.metadata.digest,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
*self.input_paths.lock() = input_paths;
|
|
||||||
let mut env = self.actor_plane.handoff_env(&self.host_endpoint_json);
|
let mut env = self.actor_plane.handoff_env(&self.host_endpoint_json);
|
||||||
env.insert(
|
env.insert(
|
||||||
"SWACTOR_DATA_PLANE_OUTPUT".to_owned(),
|
"SWACTOR_DATA_PLANE_OUTPUT".to_owned(),
|
||||||
|
|
@ -235,7 +198,6 @@ impl JobDataPlanePort for EmbeddedJobDataPlane {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_ended(&self, _job_id: u64) {
|
fn session_ended(&self, _job_id: u64) {
|
||||||
self.input_paths.lock().clear();
|
|
||||||
self.result_sink.lock().take();
|
self.result_sink.lock().take();
|
||||||
self.actor_plane.close();
|
self.actor_plane.close();
|
||||||
}
|
}
|
||||||
|
|
@ -307,15 +269,15 @@ fn build_composition_with_relay(relay_mode: RelayMode) -> Result<JobComposition,
|
||||||
DistributedNodeConfig::default(),
|
DistributedNodeConfig::default(),
|
||||||
engine.handle(),
|
engine.handle(),
|
||||||
);
|
);
|
||||||
driver.enable_actor_bridge(
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
stack.runtime.clone(),
|
runtime: stack.runtime.clone(),
|
||||||
stack.codec.clone(),
|
codec: stack.codec.clone(),
|
||||||
stack.actor_bridge_routes(),
|
routes: stack.actor_bridge_routes(),
|
||||||
stack.actors.swim,
|
swim: stack.actors.swim,
|
||||||
stack.relay_mirror.clone(),
|
relay_mirror: stack.relay_mirror.clone(),
|
||||||
stack.route_view.clone(),
|
route_view: stack.route_view.clone(),
|
||||||
stack.outbox.clone(),
|
outbox: stack.outbox.clone(),
|
||||||
);
|
});
|
||||||
stack.spawn_protocol_ticker(POLL);
|
stack.spawn_protocol_ticker(POLL);
|
||||||
driver.install_actor_bridge_pump(POLL);
|
driver.install_actor_bridge_pump(POLL);
|
||||||
Ok((engine, driver, stack))
|
Ok((engine, driver, stack))
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
// Engine boundary enforcement: disallowed scheduling/time/core-driving methods
|
// Engine boundary enforcement: disallowed scheduling, time, and core-driving
|
||||||
// are hard errors in this crate (ENGINE_SPEC.md §2). The VastAI
|
// methods are hard errors in this crate (ENGINE_SPEC.md §2).
|
||||||
// provider adapter carries a module-level `#![allow]` pending its separate
|
|
||||||
// redesign; unit tests that drive a raw Runtime in isolation are exempted
|
|
||||||
// locally.
|
|
||||||
#![deny(clippy::disallowed_methods)]
|
#![deny(clippy::disallowed_methods)]
|
||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
|
|
@ -23,8 +20,9 @@ pub fn run_worker_node_from_env() -> std::process::ExitCode {
|
||||||
node::worker_node_runtime::run_from_env()
|
node::worker_node_runtime::run_from_env()
|
||||||
}
|
}
|
||||||
|
|
||||||
mod job_deploy;
|
mod data_namespace;
|
||||||
mod job_data_plane;
|
mod job_data_plane;
|
||||||
|
mod job_deploy;
|
||||||
|
|
||||||
/// `myelin-job-worker` — GPU-node side of the iroh job runner.
|
/// `myelin-job-worker` — GPU-node side of the iroh job runner.
|
||||||
pub fn run_job_worker_from_args<I>(args: I) -> std::process::ExitCode
|
pub fn run_job_worker_from_args<I>(args: I) -> std::process::ExitCode
|
||||||
|
|
@ -84,16 +82,17 @@ where
|
||||||
"--provider" => provider = it.next(),
|
"--provider" => provider = it.next(),
|
||||||
"--image" | "--node-image" => vastai.image = it.next(),
|
"--image" | "--node-image" => vastai.image = it.next(),
|
||||||
"--vastai-api-key" => vastai.api_key = it.next(),
|
"--vastai-api-key" => vastai.api_key = it.next(),
|
||||||
"--vastai-ssh-identity" => match it.next() {
|
"--vastai-ssh-identity" => {
|
||||||
Some(path) => match orchestration::app::expand_home_path(&path) {
|
if let Some(path) = it.next() {
|
||||||
|
match orchestration::app::expand_home_path(&path) {
|
||||||
Ok(path) => vastai.ssh_identity = Some(path),
|
Ok(path) => vastai.ssh_identity = Some(path),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("myelin-job: {error}");
|
eprintln!("myelin-job: {error}");
|
||||||
return std::process::ExitCode::from(2);
|
return std::process::ExitCode::from(2);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
None => {}
|
}
|
||||||
},
|
}
|
||||||
"--remote-worker-bin" => {
|
"--remote-worker-bin" => {
|
||||||
vastai.remote_worker_bin = it.next().unwrap_or_default();
|
vastai.remote_worker_bin = it.next().unwrap_or_default();
|
||||||
}
|
}
|
||||||
|
|
@ -227,12 +226,14 @@ where
|
||||||
|
|
||||||
fn vastai_job_options_from_env() -> Result<orchestration::job_reconciler::VastAiJobOptions, String>
|
fn vastai_job_options_from_env() -> Result<orchestration::job_reconciler::VastAiJobOptions, String>
|
||||||
{
|
{
|
||||||
let mut options = orchestration::job_reconciler::VastAiJobOptions::default();
|
let mut options = orchestration::job_reconciler::VastAiJobOptions {
|
||||||
options.api_key = first_env(["VAST_API_KEY", "MYELIN_VASTAI_API_KEY", "VASTAI_API_KEY"]);
|
api_key: first_env(["VAST_API_KEY", "MYELIN_VASTAI_API_KEY", "VASTAI_API_KEY"]),
|
||||||
options.image = env_optional("MYELIN_NODE_IMAGE");
|
image: env_optional("MYELIN_NODE_IMAGE"),
|
||||||
options.ssh_identity = env_optional("MYELIN_VASTAI_SSH_IDENTITY")
|
ssh_identity: env_optional("MYELIN_VASTAI_SSH_IDENTITY")
|
||||||
.map(|path| orchestration::app::expand_home_path(&path))
|
.map(|path| orchestration::app::expand_home_path(&path))
|
||||||
.transpose()?;
|
.transpose()?,
|
||||||
|
..orchestration::job_reconciler::VastAiJobOptions::default()
|
||||||
|
};
|
||||||
apply_env("MYELIN_JOB_REMOTE_WORKER_BIN", |value| {
|
apply_env("MYELIN_JOB_REMOTE_WORKER_BIN", |value| {
|
||||||
options.remote_worker_bin = value;
|
options.remote_worker_bin = value;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ impl StageProvisionWire {
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub(crate) enum NodeAgentMsg {
|
pub(crate) enum NodeAgentMsg {
|
||||||
ProvisionStage(StageProvisionWire),
|
ProvisionStage(Box<StageProvisionWire>),
|
||||||
MarkWorkerReady,
|
MarkWorkerReady,
|
||||||
RuntimeLoaded {
|
RuntimeLoaded {
|
||||||
run_id: u64,
|
run_id: u64,
|
||||||
|
|
@ -296,7 +296,7 @@ pub(crate) enum StageLifecycleWire {
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub(crate) enum NodeAgentReport {
|
pub(crate) enum NodeAgentReport {
|
||||||
Command(StageCommandWire),
|
Command(Box<StageCommandWire>),
|
||||||
Lifecycle(StageLifecycleWire),
|
Lifecycle(StageLifecycleWire),
|
||||||
PromptRequested {
|
PromptRequested {
|
||||||
request_id: u64,
|
request_id: u64,
|
||||||
|
|
@ -455,7 +455,7 @@ impl NodeAgentActor {
|
||||||
NodeAgentMsg::ProvisionStage(provision) => {
|
NodeAgentMsg::ProvisionStage(provision) => {
|
||||||
self.core.observe(stage::StageEvent::ProvisionStage {
|
self.core.observe(stage::StageEvent::ProvisionStage {
|
||||||
from: stage::NodeId(provision.authorized_orchestrator),
|
from: stage::NodeId(provision.authorized_orchestrator),
|
||||||
provision: provision.to_core(),
|
provision: Box::new(provision.to_core()),
|
||||||
});
|
});
|
||||||
self.inbound_edge = provision.inbound_edge;
|
self.inbound_edge = provision.inbound_edge;
|
||||||
self.outbound_edge = provision.outbound_edge;
|
self.outbound_edge = provision.outbound_edge;
|
||||||
|
|
@ -615,7 +615,10 @@ impl NodeAgentActor {
|
||||||
|
|
||||||
fn drain_outputs(&mut self, ctx: &Ctx) {
|
fn drain_outputs(&mut self, ctx: &Ctx) {
|
||||||
for command in &self.core.commands()[self.command_cursor..] {
|
for command in &self.core.commands()[self.command_cursor..] {
|
||||||
self.report(ctx, NodeAgentReport::Command(self.command_wire(command)));
|
self.report(
|
||||||
|
ctx,
|
||||||
|
NodeAgentReport::Command(Box::new(self.command_wire(command))),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
self.command_cursor = self.core.commands().len();
|
self.command_cursor = self.core.commands().len();
|
||||||
|
|
||||||
|
|
@ -741,7 +744,7 @@ impl From<&stage::StageCommand> for StageCommandWire {
|
||||||
tokenizer: source.tokenizer.clone(),
|
tokenizer: source.tokenizer.clone(),
|
||||||
layer_start: range.start,
|
layer_start: range.start,
|
||||||
layer_end_exclusive: range.end_exclusive,
|
layer_end_exclusive: range.end_exclusive,
|
||||||
stage_shard_plan: shard_plan.clone(),
|
stage_shard_plan: shard_plan.as_ref().clone(),
|
||||||
},
|
},
|
||||||
stage::StageCommand::ExecuteStep(step) => Self::ExecuteStep {
|
stage::StageCommand::ExecuteStep(step) => Self::ExecuteStep {
|
||||||
step_id: step.step_id.0,
|
step_id: step.step_id.0,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@
|
||||||
//! only through these closures.
|
//! only through these closures.
|
||||||
|
|
||||||
use iroh::EndpointAddr;
|
use iroh::EndpointAddr;
|
||||||
|
use iroh_driver::telemetry_transport::PullCollectorConfig;
|
||||||
use iroh_driver::{IrohDriver, PullCollectorHandle, TelemetryQuicHeader, spawn_pull_collector};
|
use iroh_driver::{IrohDriver, PullCollectorHandle, TelemetryQuicHeader, spawn_pull_collector};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
@ -82,12 +83,14 @@ impl FrameCollector {
|
||||||
flow_id[8..].copy_from_slice(&node_id.to_le_bytes());
|
flow_id[8..].copy_from_slice(&node_id.to_le_bytes());
|
||||||
let collector = spawn_pull_collector(
|
let collector = spawn_pull_collector(
|
||||||
engine,
|
engine,
|
||||||
|
PullCollectorConfig {
|
||||||
endpoint,
|
endpoint,
|
||||||
peer,
|
peer,
|
||||||
flow_id,
|
flow_id,
|
||||||
Vec::new(),
|
token: Vec::new(),
|
||||||
SubscriptionRequest::all(),
|
request: SubscriptionRequest::all(),
|
||||||
Arc::clone(&self.pull_fanout),
|
fanout: Arc::clone(&self.pull_fanout),
|
||||||
|
},
|
||||||
self.pull_header_tx.clone(),
|
self.pull_header_tx.clone(),
|
||||||
);
|
);
|
||||||
if let Some(previous) = self
|
if let Some(previous) = self
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@ pub(crate) struct OrchTelemetry {
|
||||||
descriptor: StreamDescriptor,
|
descriptor: StreamDescriptor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct BootstrapEmission<'a> {
|
||||||
|
pub(crate) dashboard: Option<&'a DashboardSupport>,
|
||||||
|
pub(crate) channel: &'a str,
|
||||||
|
pub(crate) run_id: u64,
|
||||||
|
pub(crate) node_id: u64,
|
||||||
|
pub(crate) phase: &'a str,
|
||||||
|
pub(crate) status: &'a str,
|
||||||
|
pub(crate) detail: Value,
|
||||||
|
}
|
||||||
impl OrchTelemetry {
|
impl OrchTelemetry {
|
||||||
pub(crate) fn new(run_id: u64, frame_log: Option<&Path>) -> Result<Self, String> {
|
pub(crate) fn new(run_id: u64, frame_log: Option<&Path>) -> Result<Self, String> {
|
||||||
let stream = StreamId::new(NodeId::new("myelin-orchestrator"), Lifetime(run_id));
|
let stream = StreamId::new(NodeId::new("myelin-orchestrator"), Lifetime(run_id));
|
||||||
|
|
@ -110,6 +119,10 @@ impl OrchTelemetry {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn producer(&self) -> TelemetryProducer {
|
||||||
|
self.producer.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn emit_event(
|
pub(crate) fn emit_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
dashboard: Option<&DashboardSupport>,
|
dashboard: Option<&DashboardSupport>,
|
||||||
|
|
@ -140,27 +153,27 @@ impl OrchTelemetry {
|
||||||
status: &str,
|
status: &str,
|
||||||
detail: Value,
|
detail: Value,
|
||||||
) {
|
) {
|
||||||
self.emit_bootstrap_to_channel(
|
self.emit_bootstrap_to_channel(BootstrapEmission {
|
||||||
dashboard,
|
dashboard,
|
||||||
MYELIN_ORCH_BOOTSTRAP,
|
channel: MYELIN_ORCH_BOOTSTRAP,
|
||||||
run_id,
|
run_id,
|
||||||
node_id,
|
node_id,
|
||||||
phase,
|
phase,
|
||||||
status,
|
status,
|
||||||
detail,
|
detail,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn emit_bootstrap_to_channel(
|
pub(crate) fn emit_bootstrap_to_channel(&mut self, emission: BootstrapEmission<'_>) {
|
||||||
&mut self,
|
let BootstrapEmission {
|
||||||
dashboard: Option<&DashboardSupport>,
|
dashboard,
|
||||||
channel: &str,
|
channel,
|
||||||
run_id: u64,
|
run_id,
|
||||||
node_id: u64,
|
node_id,
|
||||||
phase: &str,
|
phase,
|
||||||
status: &str,
|
status,
|
||||||
detail: Value,
|
detail,
|
||||||
) {
|
} = emission;
|
||||||
let benchmark = benchmark::stamp("myelin-orchestrator");
|
let benchmark = benchmark::stamp("myelin-orchestrator");
|
||||||
let payload = serde_json::to_vec(&json!({
|
let payload = serde_json::to_vec(&json!({
|
||||||
"schema_version": benchmark["schema_version"].clone(),
|
"schema_version": benchmark["schema_version"].clone(),
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use crate::provisioning::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn node_stream_id(run_id: u64, node_id: u64) -> StreamId {
|
pub(crate) fn node_stream_id(run_id: u64, node_id: u64) -> StreamId {
|
||||||
StreamId::new(NodeId::new(&node_id.to_string()), Lifetime(run_id))
|
StreamId::new(NodeId::new(node_id.to_string()), Lifetime(run_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
|
||||||
|
|
@ -139,10 +139,10 @@ pub(crate) enum RunCommandWire {
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub(crate) enum LifecycleEventWire {
|
pub(crate) enum LifecycleEventWire {
|
||||||
RunFaulted { run_id: u64 },
|
Faulted { run_id: u64 },
|
||||||
RunCompleted { run_id: u64 },
|
Completed { run_id: u64 },
|
||||||
RunOperatorStopped { run_id: u64 },
|
OperatorStopped { run_id: u64 },
|
||||||
RunTornDown { run_id: u64 },
|
TornDown { run_id: u64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -512,16 +512,12 @@ impl From<&core::RunCommand> for RunCommandWire {
|
||||||
impl From<&core::LifecycleEvent> for LifecycleEventWire {
|
impl From<&core::LifecycleEvent> for LifecycleEventWire {
|
||||||
fn from(event: &core::LifecycleEvent) -> Self {
|
fn from(event: &core::LifecycleEvent) -> Self {
|
||||||
match event {
|
match event {
|
||||||
core::LifecycleEvent::RunFaulted { run_id, .. } => {
|
core::LifecycleEvent::Faulted { run_id, .. } => Self::Faulted { run_id: run_id.0 },
|
||||||
Self::RunFaulted { run_id: run_id.0 }
|
core::LifecycleEvent::Completed { run_id } => Self::Completed { run_id: run_id.0 },
|
||||||
|
core::LifecycleEvent::OperatorStopped { run_id } => {
|
||||||
|
Self::OperatorStopped { run_id: run_id.0 }
|
||||||
}
|
}
|
||||||
core::LifecycleEvent::RunCompleted { run_id } => {
|
core::LifecycleEvent::TornDown { run_id } => Self::TornDown { run_id: run_id.0 },
|
||||||
Self::RunCompleted { run_id: run_id.0 }
|
|
||||||
}
|
|
||||||
core::LifecycleEvent::RunOperatorStopped { run_id } => {
|
|
||||||
Self::RunOperatorStopped { run_id: run_id.0 }
|
|
||||||
}
|
|
||||||
core::LifecycleEvent::RunTornDown { run_id } => Self::RunTornDown { run_id: run_id.0 },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,16 @@ use crate::DEFAULT_PIPELINE_CACHED_MODEL_FILE;
|
||||||
use crate::codecs::register_myelin_actor_codecs;
|
use crate::codecs::register_myelin_actor_codecs;
|
||||||
use crate::observability::frame_collector::FrameCollector;
|
use crate::observability::frame_collector::FrameCollector;
|
||||||
use crate::observability::orch_telemetry::{
|
use crate::observability::orch_telemetry::{
|
||||||
DashboardSupport, MYELIN_SWIM_MEMBERSHIP, OrchTelemetry,
|
BootstrapEmission, DashboardSupport, MYELIN_SWIM_MEMBERSHIP, OrchTelemetry,
|
||||||
};
|
};
|
||||||
use crate::orchestration::actor::{OrchestratorActor, OrchestratorMsg, OrchestratorReport};
|
use crate::orchestration::actor::{OrchestratorActor, OrchestratorMsg, OrchestratorReport};
|
||||||
use crate::orchestration::config::{DEFAULT_CONFIG_PATH, TomlConfigOverlay};
|
use crate::orchestration::config::{DEFAULT_CONFIG_PATH, TomlConfigOverlay};
|
||||||
use crate::orchestration::control;
|
use crate::orchestration::control;
|
||||||
use crate::orchestration::daemon;
|
use crate::orchestration::daemon;
|
||||||
use crate::orchestration::manual_control::{
|
use crate::orchestration::manual_control::{
|
||||||
CONTROL_REGISTRY_NAME, ConfigValidator, ManualActorControl, ManualControl, ManualControlMsg,
|
CONTROL_REGISTRY_NAME, ConfigValidator, ManualActorControl, ManualActorControlConfig,
|
||||||
ManualControlReply, NodePhase, OfferDto, OfferSearchRequest, OfferSearcher,
|
ManualControl, ManualControlMsg, ManualControlReply, NodePhase, OfferDto, OfferSearchRequest,
|
||||||
ProviderConfigurationRequest, ProviderFactory, ProviderReadiness, SpecBuilder,
|
OfferSearcher, ProviderConfigurationRequest, ProviderFactory, ProviderReadiness, SpecBuilder,
|
||||||
};
|
};
|
||||||
use swactor_job_runner::{JobDone, OrchestratorJobActor};
|
use swactor_job_runner::{JobDone, OrchestratorJobActor};
|
||||||
|
|
||||||
|
|
@ -69,6 +69,154 @@ const PUMP_INTERVAL: Duration = Duration::from_millis(10);
|
||||||
const SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(10);
|
const SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
const TELEMETRY_FRAME_LOG_ENV: &str = "MYELIN_TELEMETRY_FRAME_LOG";
|
const TELEMETRY_FRAME_LOG_ENV: &str = "MYELIN_TELEMETRY_FRAME_LOG";
|
||||||
|
|
||||||
|
fn sample_orchestrator_cpu(
|
||||||
|
mut sampler: telemetry::hardware::cpu::CpuSampler,
|
||||||
|
seq: u64,
|
||||||
|
) -> (
|
||||||
|
telemetry::hardware::cpu::CpuSampler,
|
||||||
|
telemetry::hardware::cpu::HostCpuSample,
|
||||||
|
) {
|
||||||
|
let sample = sampler.sample(seq);
|
||||||
|
(sampler, sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_orchestrator_gpu((): (), seq: u64) -> ((), telemetry::hardware::gpu::HostGpuSample) {
|
||||||
|
((), telemetry::hardware::gpu::sample(seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_orchestrator_memory(
|
||||||
|
(): (),
|
||||||
|
seq: u64,
|
||||||
|
) -> ((), telemetry::hardware::memory::HostMemorySample) {
|
||||||
|
((), telemetry::hardware::memory::sample(seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_orchestrator_net((): (), seq: u64) -> ((), telemetry::hardware::net::HostNetSample) {
|
||||||
|
((), telemetry::hardware::net::sample(seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_orchestrator_storage(
|
||||||
|
(): (),
|
||||||
|
seq: u64,
|
||||||
|
) -> ((), telemetry::hardware::storage::HostStorageSample) {
|
||||||
|
((), telemetry::hardware::storage::sample(seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_orchestrator_hardware_samplers(engine: &EngineHandle, telemetry: &mut OrchTelemetry) {
|
||||||
|
let cpu_channel = telemetry.record_channel::<telemetry::hardware::cpu::HostCpuSample>();
|
||||||
|
let cpu_producer = telemetry.producer();
|
||||||
|
telemetry::hardware::spawn_blocking_sampler(
|
||||||
|
engine.clone(),
|
||||||
|
telemetry::hardware::cpu::CPU_SAMPLE_INTERVAL,
|
||||||
|
telemetry::hardware::cpu::CpuSampler::new([std::process::id()]),
|
||||||
|
sample_orchestrator_cpu,
|
||||||
|
|| {},
|
||||||
|
move |_, sample| {
|
||||||
|
cpu_producer.submit_record(cpu_channel, &sample);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let gpu_channel = telemetry.record_channel::<telemetry::hardware::gpu::HostGpuSample>();
|
||||||
|
let gpu_producer = telemetry.producer();
|
||||||
|
telemetry::hardware::spawn_blocking_sampler(
|
||||||
|
engine.clone(),
|
||||||
|
telemetry::hardware::gpu::GPU_SAMPLE_INTERVAL,
|
||||||
|
(),
|
||||||
|
sample_orchestrator_gpu,
|
||||||
|
|| {},
|
||||||
|
move |_, sample| {
|
||||||
|
gpu_producer.submit_record(gpu_channel, &sample);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let memory_channel =
|
||||||
|
telemetry.record_channel::<telemetry::hardware::memory::HostMemorySample>();
|
||||||
|
let memory_producer = telemetry.producer();
|
||||||
|
telemetry::hardware::spawn_blocking_sampler(
|
||||||
|
engine.clone(),
|
||||||
|
telemetry::hardware::memory::MEMORY_SAMPLE_INTERVAL,
|
||||||
|
(),
|
||||||
|
sample_orchestrator_memory,
|
||||||
|
|| {},
|
||||||
|
move |_, sample| {
|
||||||
|
memory_producer.submit_record(memory_channel, &sample);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let net_channel = telemetry.record_channel::<telemetry::hardware::net::HostNetSample>();
|
||||||
|
let net_producer = telemetry.producer();
|
||||||
|
telemetry::hardware::spawn_blocking_sampler(
|
||||||
|
engine.clone(),
|
||||||
|
telemetry::hardware::net::HOST_NET_SAMPLE_INTERVAL,
|
||||||
|
(),
|
||||||
|
sample_orchestrator_net,
|
||||||
|
|| {},
|
||||||
|
move |_, sample| {
|
||||||
|
net_producer.submit_record(net_channel, &sample);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let storage_channel =
|
||||||
|
telemetry.record_channel::<telemetry::hardware::storage::HostStorageSample>();
|
||||||
|
let storage_producer = telemetry.producer();
|
||||||
|
telemetry::hardware::spawn_blocking_sampler(
|
||||||
|
engine.clone(),
|
||||||
|
telemetry::hardware::storage::STORAGE_SAMPLE_INTERVAL,
|
||||||
|
(),
|
||||||
|
sample_orchestrator_storage,
|
||||||
|
|| {},
|
||||||
|
move |_, sample| {
|
||||||
|
storage_producer.submit_record(storage_channel, &sample);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod hardware_telemetry_tests {
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::RuntimeParts;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orchestrator_emits_all_host_hardware_channels() {
|
||||||
|
let frame_log = tempfile::NamedTempFile::new().expect("frame log");
|
||||||
|
let mut telemetry =
|
||||||
|
OrchTelemetry::new(1, Some(frame_log.path())).expect("orchestrator telemetry");
|
||||||
|
let engine = Engine::new(
|
||||||
|
RuntimeParts::new(RuntimeConfig::default()),
|
||||||
|
TokioBackend::new(TokioConfig::default()).expect("Tokio backend"),
|
||||||
|
)
|
||||||
|
.expect("engine");
|
||||||
|
spawn_orchestrator_hardware_samplers(&engine.handle(), &mut telemetry);
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
telemetry.flush(None, "test");
|
||||||
|
let frames = std::fs::read_to_string(frame_log.path()).expect("read frame log");
|
||||||
|
if [
|
||||||
|
"host.cpu",
|
||||||
|
"host.gpu",
|
||||||
|
"host.memory",
|
||||||
|
"host.net",
|
||||||
|
"host.storage",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.all(|channel| frames.contains(&format!(r#""channel":"{channel}""#)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"orchestrator hardware channels were incomplete: {frames}"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn run_with_options<I>(
|
pub(crate) fn run_with_options<I>(
|
||||||
args: I,
|
args: I,
|
||||||
capture_stdio: bool,
|
capture_stdio: bool,
|
||||||
|
|
@ -86,6 +234,7 @@ where
|
||||||
.overlay_cli(args)?
|
.overlay_cli(args)?
|
||||||
.finalize()?;
|
.finalize()?;
|
||||||
let state_dir = daemon::StateDir::new(config.state_dir.clone());
|
let state_dir = daemon::StateDir::new(config.state_dir.clone());
|
||||||
|
let data_namespace_path = config.state_dir.join("data-namespace.json");
|
||||||
if config.reset_state {
|
if config.reset_state {
|
||||||
state_dir.reset()?;
|
state_dir.reset()?;
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +373,7 @@ where
|
||||||
return Err(format!("create engine: {error}"));
|
return Err(format!("create engine: {error}"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
spawn_orchestrator_hardware_samplers(&engine.handle(), &mut orch_telemetry);
|
||||||
let mut driver = match IrohDriver::with_engine(
|
let mut driver = match IrohDriver::with_engine(
|
||||||
engine.handle(),
|
engine.handle(),
|
||||||
IrohDriverConfig {
|
IrohDriverConfig {
|
||||||
|
|
@ -295,15 +445,28 @@ where
|
||||||
"ready",
|
"ready",
|
||||||
json!({"registered":["node_agent","orchestrator","provisioner","prompt_rpc","telemetry"]}),
|
json!({"registered":["node_agent","orchestrator","provisioner","prompt_rpc","telemetry"]}),
|
||||||
);
|
);
|
||||||
driver.enable_actor_bridge(
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
stack.runtime.clone(),
|
runtime: stack.runtime.clone(),
|
||||||
stack.codec.clone(),
|
codec: stack.codec.clone(),
|
||||||
stack.actor_bridge_routes(),
|
routes: stack.actor_bridge_routes(),
|
||||||
stack.actors.swim,
|
swim: stack.actors.swim,
|
||||||
stack.relay_mirror.clone(),
|
relay_mirror: stack.relay_mirror.clone(),
|
||||||
stack.route_view.clone(),
|
route_view: stack.route_view.clone(),
|
||||||
stack.outbox.clone(),
|
outbox: stack.outbox.clone(),
|
||||||
);
|
});
|
||||||
|
let data_namespace =
|
||||||
|
crate::data_namespace::DataNamespaceAuthority::start(&stack, &driver, data_namespace_path)?;
|
||||||
|
let tiny_linear_weights = std::env::var_os("MYELIN_TINY_LINEAR_WEIGHTS")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("apps/myelin/jobs/tiny_linear.weights"));
|
||||||
|
futures_lite::future::block_on(
|
||||||
|
data_namespace.control().ensure(
|
||||||
|
data_plane::path::DataPath::parse("/models/tiny-linear/weights")
|
||||||
|
.expect("static tiny-linear data path"),
|
||||||
|
data_plane::blob::file(tiny_linear_weights),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("register tiny-linear weights: {error}"))?;
|
||||||
let recovery_nodes = snapshot
|
let recovery_nodes = snapshot
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -508,13 +671,15 @@ where
|
||||||
ManualControl::new(snapshot, readiness),
|
ManualControl::new(snapshot, readiness),
|
||||||
stack.runtime.clone(),
|
stack.runtime.clone(),
|
||||||
stack.engine.blocking_work_sender(),
|
stack.engine.blocking_work_sender(),
|
||||||
|
ManualActorControlConfig {
|
||||||
state_dir,
|
state_dir,
|
||||||
sink,
|
sink,
|
||||||
provider_factory,
|
provider_factory,
|
||||||
spec_builder,
|
spec_builder,
|
||||||
Some(config_validator),
|
config_validator: Some(config_validator),
|
||||||
offer_searcher,
|
offer_searcher,
|
||||||
daemon::unix_ms_now(),
|
control_generation: daemon::unix_ms_now(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let orchestrator_actor = match stack.runtime.spawn(
|
let orchestrator_actor = match stack.runtime.spawn(
|
||||||
OrchestratorActor::new(
|
OrchestratorActor::new(
|
||||||
|
|
@ -1476,11 +1641,12 @@ impl ConfigBuilder {
|
||||||
});
|
});
|
||||||
let provider_name = provider.as_str();
|
let provider_name = provider.as_str();
|
||||||
let mut image = self.image.clone();
|
let mut image = self.image.clone();
|
||||||
if provider_name == "vastai" && !self.image_overridden_after_toml {
|
if provider_name == "vastai"
|
||||||
if let Some(vastai_image) = &self.toml_vastai_image {
|
&& !self.image_overridden_after_toml
|
||||||
|
&& let Some(vastai_image) = &self.toml_vastai_image
|
||||||
|
{
|
||||||
image = vastai_image.clone();
|
image = vastai_image.clone();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if self.pipeline_stages == 0 {
|
if self.pipeline_stages == 0 {
|
||||||
return Err("--pipeline-stages must be greater than 0".to_owned());
|
return Err("--pipeline-stages must be greater than 0".to_owned());
|
||||||
}
|
}
|
||||||
|
|
@ -2367,6 +2533,8 @@ impl ServeClusterActor {
|
||||||
}
|
}
|
||||||
telemetry.archive_frame("node", stream, channel, frame);
|
telemetry.archive_frame("node", stream, channel, frame);
|
||||||
});
|
});
|
||||||
|
self.orch_telemetry
|
||||||
|
.flush(self.dashboard.as_ref(), "orchestrator");
|
||||||
self.drain_observations();
|
self.drain_observations();
|
||||||
while let Some(report) = self.orchestrator_reports.try_recv() {
|
while let Some(report) = self.orchestrator_reports.try_recv() {
|
||||||
self.observe_report(report);
|
self.observe_report(report);
|
||||||
|
|
@ -2587,14 +2755,14 @@ fn emit_swim_transitions(
|
||||||
let last_ack_age_ms = transition.last_ack_age.map(duration_ms_u64);
|
let last_ack_age_ms = transition.last_ack_age.map(duration_ms_u64);
|
||||||
let consecutive_timeouts = transition.consecutive_timeouts;
|
let consecutive_timeouts = transition.consecutive_timeouts;
|
||||||
let recent_probe_targets = stack.swim_recent_probe_targets();
|
let recent_probe_targets = stack.swim_recent_probe_targets();
|
||||||
orch_telemetry.emit_bootstrap_to_channel(
|
orch_telemetry.emit_bootstrap_to_channel(BootstrapEmission {
|
||||||
dashboard,
|
dashboard,
|
||||||
MYELIN_SWIM_MEMBERSHIP,
|
channel: MYELIN_SWIM_MEMBERSHIP,
|
||||||
run_id,
|
run_id,
|
||||||
node_id,
|
node_id,
|
||||||
"membership_transition",
|
phase: "membership_transition",
|
||||||
"observed",
|
status: "observed",
|
||||||
json!({
|
detail: json!({
|
||||||
"peer":peer.clone(),
|
"peer":peer.clone(),
|
||||||
"from":from.clone(),
|
"from":from.clone(),
|
||||||
"to":to.clone(),
|
"to":to.clone(),
|
||||||
|
|
@ -2604,7 +2772,7 @@ fn emit_swim_transitions(
|
||||||
"recent_probe_targets":recent_probe_targets.clone(),
|
"recent_probe_targets":recent_probe_targets.clone(),
|
||||||
"member_state":member_state.clone(),
|
"member_state":member_state.clone(),
|
||||||
}),
|
}),
|
||||||
);
|
});
|
||||||
orch_telemetry.emit_record(dashboard, &stack.membership_transition(transition));
|
orch_telemetry.emit_record(dashboard, &stack.membership_transition(transition));
|
||||||
}
|
}
|
||||||
transitions
|
transitions
|
||||||
|
|
@ -2656,7 +2824,7 @@ pub(crate) fn expand_home_path(value: &str) -> Result<PathBuf, String> {
|
||||||
|
|
||||||
pub(crate) fn derive_ssh_public_key(identity: &Path) -> Result<String, String> {
|
pub(crate) fn derive_ssh_public_key(identity: &Path) -> Result<String, String> {
|
||||||
let output = swactor_process::command_output(
|
let output = swactor_process::command_output(
|
||||||
&mut Command::new("ssh-keygen").arg("-y").arg("-f").arg(identity),
|
Command::new("ssh-keygen").arg("-y").arg("-f").arg(identity),
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -2685,9 +2853,8 @@ pub(crate) fn ssh_public_key_fingerprint(public_key: &str) -> String {
|
||||||
if std::fs::write(&path, format!("{public_key}\n")).is_err() {
|
if std::fs::write(&path, format!("{public_key}\n")).is_err() {
|
||||||
return UNAVAILABLE.to_owned();
|
return UNAVAILABLE.to_owned();
|
||||||
}
|
}
|
||||||
let output = swactor_process::command_output(
|
let output =
|
||||||
&mut Command::new("ssh-keygen").arg("-l").arg("-f").arg(&path),
|
swactor_process::command_output(Command::new("ssh-keygen").arg("-l").arg("-f").arg(&path))
|
||||||
)
|
|
||||||
.ok();
|
.ok();
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let Some(output) = output.filter(|output| output.status.success()) else {
|
let Some(output) = output.filter(|output| output.status.success()) else {
|
||||||
|
|
@ -2704,7 +2871,7 @@ pub(crate) fn ssh_public_key_fingerprint(public_key: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vastai_account_has_ssh_key(api_key: &str, public_key: &str) -> Result<bool, String> {
|
fn vastai_account_has_ssh_key(api_key: &str, public_key: &str) -> Result<bool, String> {
|
||||||
let output = swactor_process::command_output(&mut Command::new("vastai").args([
|
let output = swactor_process::command_output(Command::new("vastai").args([
|
||||||
"show",
|
"show",
|
||||||
"ssh-keys",
|
"ssh-keys",
|
||||||
"--raw",
|
"--raw",
|
||||||
|
|
@ -2730,7 +2897,7 @@ pub(crate) fn ensure_vastai_account_ssh_key(api_key: &str, public_key: &str) ->
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = swactor_process::command_output(
|
let output = swactor_process::command_output(
|
||||||
&mut Command::new("vastai")
|
Command::new("vastai")
|
||||||
.args(["create", "ssh-key"])
|
.args(["create", "ssh-key"])
|
||||||
.arg(public_key)
|
.arg(public_key)
|
||||||
.args(["-y", "--api-key", api_key]),
|
.args(["-y", "--api-key", api_key]),
|
||||||
|
|
@ -3347,9 +3514,10 @@ mod serve_cluster_properties {
|
||||||
fn serve_cluster_production_transitions_converge_once_without_growth(
|
fn serve_cluster_production_transitions_converge_once_without_growth(
|
||||||
actions in lifecycle_actions()
|
actions in lifecycle_actions()
|
||||||
) {
|
) {
|
||||||
let mut config = RuntimeConfig::default();
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
config.worker_count = 1;
|
worker_count: 1,
|
||||||
let parts = RuntimeParts::new(config);
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
let runtime = parts.runtime().clone();
|
let runtime = parts.runtime().clone();
|
||||||
let backend = SteppingBackend::new();
|
let backend = SteppingBackend::new();
|
||||||
let engine = Engine::new(parts, backend.clone()).expect("one-worker stepping engine");
|
let engine = Engine::new(parts, backend.clone()).expect("one-worker stepping engine");
|
||||||
|
|
|
||||||
|
|
@ -257,10 +257,12 @@ impl MyelinEffectBackend {
|
||||||
let mut effects = lock_node(&effects);
|
let mut effects = lock_node(&effects);
|
||||||
if let Some(live) = &effects.live {
|
if let Some(live) = &effects.live {
|
||||||
if live.attempt == effect.operation.attempt {
|
if live.attempt == effect.operation.attempt {
|
||||||
return Ok(OperationOutcome::LeaseCreated(CreateLeaseResult {
|
return Ok(OperationOutcome::LeaseCreated(Box::new(
|
||||||
|
CreateLeaseResult {
|
||||||
lease: live.lease.clone(),
|
lease: live.lease.clone(),
|
||||||
endpoint: Some(live.endpoint.clone()),
|
endpoint: Some(live.endpoint.clone()),
|
||||||
}));
|
},
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
return Err(EffectError::ambiguous(format!(
|
return Err(EffectError::ambiguous(format!(
|
||||||
"node {} still owns attempt {} while creating attempt {}",
|
"node {} still owns attempt {} while creating attempt {}",
|
||||||
|
|
@ -335,10 +337,12 @@ impl MyelinEffectBackend {
|
||||||
bootstrap_started: false,
|
bootstrap_started: false,
|
||||||
});
|
});
|
||||||
failure_sink.arm();
|
failure_sink.arm();
|
||||||
Ok(OperationOutcome::LeaseCreated(CreateLeaseResult {
|
Ok(OperationOutcome::LeaseCreated(Box::new(
|
||||||
|
CreateLeaseResult {
|
||||||
lease,
|
lease,
|
||||||
endpoint: Some(endpoint),
|
endpoint: Some(endpoint),
|
||||||
}))
|
},
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -538,7 +538,7 @@ async fn submit_node_job(
|
||||||
ManualControlMsg::Query { reply_to }
|
ManualControlMsg::Query { reply_to }
|
||||||
}) {
|
}) {
|
||||||
Ok(response_rx) => response_rx,
|
Ok(response_rx) => response_rx,
|
||||||
Err(response) => return response,
|
Err(response) => return *response,
|
||||||
};
|
};
|
||||||
let model = match response_rx.await {
|
let model = match response_rx.await {
|
||||||
Ok(ManualControlReply::Status(model)) => model,
|
Ok(ManualControlReply::Status(model)) => model,
|
||||||
|
|
@ -689,7 +689,7 @@ async fn request_reply(
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let response_rx = match begin_request_reply(state, timeout, build) {
|
let response_rx = match begin_request_reply(state, timeout, build) {
|
||||||
Ok(response_rx) => response_rx,
|
Ok(response_rx) => response_rx,
|
||||||
Err(response) => return response,
|
Err(response) => return *response,
|
||||||
};
|
};
|
||||||
|
|
||||||
match response_rx.await {
|
match response_rx.await {
|
||||||
|
|
@ -718,7 +718,7 @@ fn begin_request_reply(
|
||||||
state: &ControlHttpState,
|
state: &ControlHttpState,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
build: impl FnOnce(ActorAddress) -> ManualControlMsg,
|
build: impl FnOnce(ActorAddress) -> ManualControlMsg,
|
||||||
) -> Result<tokio::sync::oneshot::Receiver<ManualControlReply>, Response> {
|
) -> Result<tokio::sync::oneshot::Receiver<ManualControlReply>, Box<Response>> {
|
||||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||||
let response_tx = Arc::new(Mutex::new(Some(response_tx)));
|
let response_tx = Arc::new(Mutex::new(Some(response_tx)));
|
||||||
let reply_to = state
|
let reply_to = state
|
||||||
|
|
@ -730,26 +730,30 @@ fn begin_request_reply(
|
||||||
timeout,
|
timeout,
|
||||||
})
|
})
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
|
Box::new(
|
||||||
(
|
(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: format!("create control reply observer: {error}"),
|
error: format!("create control reply observer: {error}"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
if let Err(error) = state
|
if let Err(error) = state
|
||||||
.runtime
|
.runtime
|
||||||
.send_to(state.orchestrator, OrchestratorMsg::Manual(build(reply_to)))
|
.send_to(state.orchestrator, OrchestratorMsg::Manual(build(reply_to)))
|
||||||
{
|
{
|
||||||
let _ = state.runtime.stop_actor(reply_to);
|
let _ = state.runtime.stop_actor(reply_to);
|
||||||
return Err((
|
return Err(Box::new(
|
||||||
|
(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: format!("orchestrator control actor unavailable: {error}"),
|
error: format!("orchestrator control actor unavailable: {error}"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response());
|
.into_response(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(response_rx)
|
Ok(response_rx)
|
||||||
}
|
}
|
||||||
|
|
@ -998,7 +1002,7 @@ mod properties {
|
||||||
status,
|
status,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let reply = |result: Result<_, Response>| match result {
|
let reply = |result: Result<_, Box<Response>>| match result {
|
||||||
Ok(receiver) => PendingHttpObservation::Reply {
|
Ok(receiver) => PendingHttpObservation::Reply {
|
||||||
index,
|
index,
|
||||||
action: action.clone(),
|
action: action.clone(),
|
||||||
|
|
@ -1191,9 +1195,10 @@ mod properties {
|
||||||
HttpAction::from_raw(kind, command_slot, value)
|
HttpAction::from_raw(kind, command_slot, value)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut config = RuntimeConfig::default();
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
config.worker_count = 1;
|
worker_count: 1,
|
||||||
let parts = RuntimeParts::new(config);
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
let runtime = parts.runtime().clone();
|
let runtime = parts.runtime().clone();
|
||||||
let backend = SteppingBackend::new();
|
let backend = SteppingBackend::new();
|
||||||
let engine =
|
let engine =
|
||||||
|
|
@ -1294,9 +1299,10 @@ mod properties {
|
||||||
fn generated_duplicate_control_replies_deliver_first_once_and_remove_observer(
|
fn generated_duplicate_control_replies_deliver_first_once_and_remove_observer(
|
||||||
replies in prop::collection::vec(any::<u8>(), 0..=16)
|
replies in prop::collection::vec(any::<u8>(), 0..=16)
|
||||||
) {
|
) {
|
||||||
let mut config = RuntimeConfig::default();
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
config.worker_count = 1;
|
worker_count: 1,
|
||||||
let parts = RuntimeParts::new(config);
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
let runtime = parts.runtime().clone();
|
let runtime = parts.runtime().clone();
|
||||||
let backend = SteppingBackend::new();
|
let backend = SteppingBackend::new();
|
||||||
let engine =
|
let engine =
|
||||||
|
|
@ -1380,9 +1386,10 @@ mod properties {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reply_observer_disappearance_returns_a_bounded_terminal_http_response() {
|
fn reply_observer_disappearance_returns_a_bounded_terminal_http_response() {
|
||||||
let mut config = RuntimeConfig::default();
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
config.worker_count = 1;
|
worker_count: 1,
|
||||||
let parts = RuntimeParts::new(config);
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
let runtime = parts.runtime().clone();
|
let runtime = parts.runtime().clone();
|
||||||
let backend = SteppingBackend::new();
|
let backend = SteppingBackend::new();
|
||||||
let engine =
|
let engine =
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,50 @@
|
||||||
(() => {
|
(() => {
|
||||||
const CONTROL_ID = 'myelin-fleet-control';
|
const CONTROL_ID = 'myelin-fleet-control';
|
||||||
|
const CONFIRM_ID = 'myelin-confirm-dialog';
|
||||||
|
const CONFIRM_STYLE_ID = 'myelin-confirm-dialog-style';
|
||||||
const selectedJobs = new Map();
|
const selectedJobs = new Map();
|
||||||
|
|
||||||
async function syncControl() {
|
function confirmKill(logicalNodeId) {
|
||||||
const nodeView = document.querySelector('.node-view[data-node]');
|
let dialog = document.getElementById(CONFIRM_ID);
|
||||||
if (!nodeView) return;
|
if (!dialog) {
|
||||||
const rawNodeId = nodeView.getAttribute('data-node') || '';
|
const style = document.createElement('style');
|
||||||
if (!/^\d+$/.test(rawNodeId)) return;
|
style.id = CONFIRM_STYLE_ID;
|
||||||
const logicalNodeId = Number(rawNodeId);
|
style.textContent = `
|
||||||
|
.myelin-confirm { width:min(440px,calc(100vw - 32px));padding:0;color:var(--text);background:var(--panel);border:1px solid var(--bad);border-radius:var(--r);box-shadow:0 18px 60px rgba(0,0,0,.55) }
|
||||||
|
.myelin-confirm::backdrop { background:rgba(0,6,12,.78) }
|
||||||
|
.myelin-confirm form { display:grid;gap:14px;padding:18px }
|
||||||
|
.myelin-confirm h2,.myelin-confirm p { margin:0 }
|
||||||
|
.myelin-confirm h2 { color:var(--bad) }
|
||||||
|
.myelin-confirm-actions { display:flex;justify-content:flex-end;gap:8px }
|
||||||
|
.myelin-confirm button { padding:6px 12px;background:transparent;color:var(--text);border:1px solid var(--border);border-radius:var(--r);cursor:pointer;font:600 13px var(--mono) }
|
||||||
|
.myelin-confirm button[value="confirm"] { color:var(--danger-ink);background:var(--danger-fill);border-color:var(--danger-border) }
|
||||||
|
`;
|
||||||
|
document.head.append(style);
|
||||||
|
dialog = document.createElement('dialog');
|
||||||
|
dialog.id = CONFIRM_ID;
|
||||||
|
dialog.className = 'myelin-confirm';
|
||||||
|
dialog.setAttribute('aria-labelledby', 'myelin-confirm-title');
|
||||||
|
dialog.setAttribute('aria-describedby', 'myelin-confirm-message');
|
||||||
|
dialog.innerHTML = `<form method="dialog">
|
||||||
|
<h2 id="myelin-confirm-title">Terminate managed node?</h2>
|
||||||
|
<p id="myelin-confirm-message"></p>
|
||||||
|
<div class="myelin-confirm-actions">
|
||||||
|
<button value="cancel" autofocus>Cancel</button>
|
||||||
|
<button value="confirm">Terminate node</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
document.body.append(dialog);
|
||||||
|
}
|
||||||
|
dialog.querySelector('#myelin-confirm-message').textContent =
|
||||||
|
`Terminate managed node ${logicalNodeId}? Vast.ai contracts are destroyed and billing stops.`;
|
||||||
|
dialog.returnValue = 'cancel';
|
||||||
|
return new Promise(resolve => {
|
||||||
|
dialog.addEventListener('close', () => resolve(dialog.returnValue === 'confirm'), { once: true });
|
||||||
|
dialog.showModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncControl() {
|
||||||
let model;
|
let model;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/control/status', { cache: 'no-store' });
|
const response = await fetch('/api/control/status', { cache: 'no-store' });
|
||||||
|
|
@ -18,6 +54,17 @@
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('dashboard-hardware-source', {
|
||||||
|
detail: {
|
||||||
|
source: model?.provider?.provisioning_mode === 'mock' ? 'orchestrator' : 'node',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nodeView = document.querySelector('.node-view[data-node]');
|
||||||
|
if (!nodeView) return;
|
||||||
|
const rawNodeId = nodeView.getAttribute('data-node') || '';
|
||||||
|
if (!/^\d+$/.test(rawNodeId)) return;
|
||||||
|
const logicalNodeId = Number(rawNodeId);
|
||||||
const node = model?.nodes?.find(candidate => candidate.logical_node_id === logicalNodeId);
|
const node = model?.nodes?.find(candidate => candidate.logical_node_id === logicalNodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
|
|
@ -130,7 +177,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
killButton.onclick = async () => {
|
killButton.onclick = async () => {
|
||||||
if (!window.confirm(`Kill managed node ${logicalNodeId}? Vast.ai contracts are destroyed and billing stops.`)) return;
|
if (!await confirmKill(logicalNodeId)) return;
|
||||||
killButton.disabled = true;
|
killButton.disabled = true;
|
||||||
message.textContent = 'Submitting kill…';
|
message.textContent = 'Submitting kill…';
|
||||||
const commandId = `fleet-kill-${globalThis.crypto?.randomUUID?.() || Date.now()}`;
|
const commandId = `fleet-kill-${globalThis.crypto?.randomUUID?.() || Date.now()}`;
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ enum ReconciledJobPhase {
|
||||||
deadline: Instant,
|
deadline: Instant,
|
||||||
worker: NodeIdentity,
|
worker: NodeIdentity,
|
||||||
},
|
},
|
||||||
Running(JobRunStateMachine),
|
Running(Box<JobRunStateMachine>),
|
||||||
Stopping {
|
Stopping {
|
||||||
result: Result<JobDone, String>,
|
result: Result<JobDone, String>,
|
||||||
},
|
},
|
||||||
|
|
@ -291,9 +291,9 @@ impl ActorInterface for ReconciledJobActor {
|
||||||
Err(error) => self.begin_stop(Err(error)),
|
Err(error) => self.begin_stop(Err(error)),
|
||||||
}
|
}
|
||||||
} else if Instant::now() >= deadline {
|
} else if Instant::now() >= deadline {
|
||||||
self.begin_stop(Err(format!(
|
self.begin_stop(Err(
|
||||||
"timed out waiting for reconciled job worker identity"
|
"timed out waiting for reconciled job worker identity".to_string()
|
||||||
)));
|
));
|
||||||
} else {
|
} else {
|
||||||
self.phase = ReconciledJobPhase::Provisioning { deadline };
|
self.phase = ReconciledJobPhase::Provisioning { deadline };
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +313,7 @@ impl ActorInterface for ReconciledJobActor {
|
||||||
match machine {
|
match machine {
|
||||||
Ok(mut machine) => {
|
Ok(mut machine) => {
|
||||||
machine.start(Instant::now());
|
machine.start(Instant::now());
|
||||||
self.phase = ReconciledJobPhase::Running(machine);
|
self.phase = ReconciledJobPhase::Running(Box::new(machine));
|
||||||
}
|
}
|
||||||
Err(error) => self.begin_stop(Err(error)),
|
Err(error) => self.begin_stop(Err(error)),
|
||||||
}
|
}
|
||||||
|
|
@ -382,14 +382,16 @@ fn build_vastai_provisioner(
|
||||||
ssh_public_key_fingerprint(&public_key)
|
ssh_public_key_fingerprint(&public_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut config = VastAiProvisioningConfig::default();
|
let mut config = VastAiProvisioningConfig {
|
||||||
config.label_prefix = options.label_prefix.clone();
|
label_prefix: options.label_prefix.clone(),
|
||||||
config.disk_gb = options.disk_gb;
|
disk_gb: options.disk_gb,
|
||||||
config.ssh_user = options.ssh_user.clone();
|
ssh_user: options.ssh_user.clone(),
|
||||||
config.confirm_lease = options.confirm_lease;
|
confirm_lease: options.confirm_lease,
|
||||||
config.onstart = options.onstart.clone();
|
onstart: options.onstart.clone(),
|
||||||
config.ssh_public_key = Some(public_key);
|
ssh_public_key: Some(public_key),
|
||||||
config.selection = selection_policy(options);
|
selection: selection_policy(options),
|
||||||
|
..VastAiProvisioningConfig::default()
|
||||||
|
};
|
||||||
if let Some(poll_interval) = options.poll_interval {
|
if let Some(poll_interval) = options.poll_interval {
|
||||||
config.lifecycle.poll_interval = poll_interval;
|
config.lifecycle.poll_interval = poll_interval;
|
||||||
}
|
}
|
||||||
|
|
@ -502,8 +504,10 @@ fn build_cluster(
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let mut retry = RetryPolicy::default();
|
let retry = RetryPolicy {
|
||||||
retry.operation_timeout = options.provision_timeout;
|
operation_timeout: options.provision_timeout,
|
||||||
|
..RetryPolicy::default()
|
||||||
|
};
|
||||||
ProvisionedClusterGuard::new(
|
ProvisionedClusterGuard::new(
|
||||||
desired,
|
desired,
|
||||||
vec![ReconcilerNodeBinding {
|
vec![ReconcilerNodeBinding {
|
||||||
|
|
|
||||||
|
|
@ -1458,20 +1458,32 @@ pub(crate) struct ManualActorControl {
|
||||||
control_generation: u64,
|
control_generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ManualActorControlConfig {
|
||||||
|
pub(crate) state_dir: StateDir,
|
||||||
|
pub(crate) sink: PluginSink,
|
||||||
|
pub(crate) provider_factory: ProviderFactory,
|
||||||
|
pub(crate) spec_builder: SpecBuilder,
|
||||||
|
pub(crate) config_validator: Option<ConfigValidator>,
|
||||||
|
pub(crate) offer_searcher: Option<OfferSearcher>,
|
||||||
|
pub(crate) control_generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
impl ManualActorControl {
|
impl ManualActorControl {
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
core: ManualControl,
|
core: ManualControl,
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
blocking_work: BlockingWorkSender,
|
blocking_work: BlockingWorkSender,
|
||||||
state_dir: StateDir,
|
config: ManualActorControlConfig,
|
||||||
sink: PluginSink,
|
|
||||||
provider_factory: ProviderFactory,
|
|
||||||
spec_builder: SpecBuilder,
|
|
||||||
config_validator: Option<ConfigValidator>,
|
|
||||||
offer_searcher: Option<OfferSearcher>,
|
|
||||||
control_generation: u64,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let ManualActorControlConfig {
|
||||||
|
state_dir,
|
||||||
|
sink,
|
||||||
|
provider_factory,
|
||||||
|
spec_builder,
|
||||||
|
config_validator,
|
||||||
|
offer_searcher,
|
||||||
|
control_generation,
|
||||||
|
} = config;
|
||||||
let sender = runtime.create_sender();
|
let sender = runtime.create_sender();
|
||||||
let work_actor = runtime
|
let work_actor = runtime
|
||||||
.spawn(ManualWorkActor { blocking_work })
|
.spawn(ManualWorkActor { blocking_work })
|
||||||
|
|
@ -3038,7 +3050,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_bootstrap(&mut self, handle: &PluginNodeHandle) -> Result<(), String> {
|
fn start_bootstrap(&mut self, handle: &PluginNodeHandle) -> Result<(), String> {
|
||||||
if handle.id % 11 == 0 {
|
if handle.id.is_multiple_of(11) {
|
||||||
return Err("scripted bootstrap failure".to_owned());
|
return Err("scripted bootstrap failure".to_owned());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -3062,7 +3074,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provider_ref_for(&self, spec: &NodeProvisionSpec) -> String {
|
fn provider_ref_for(&self, spec: &NodeProvisionSpec) -> String {
|
||||||
if spec.node_id % 5 == 0 {
|
if spec.node_id.is_multiple_of(5) {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!("scripted-resource-{}", spec.node_id)
|
format!("scripted-resource-{}", spec.node_id)
|
||||||
|
|
@ -3484,13 +3496,15 @@ mod tests {
|
||||||
ready_core(),
|
ready_core(),
|
||||||
runtime.clone(),
|
runtime.clone(),
|
||||||
engine.handle().blocking_work_sender(),
|
engine.handle().blocking_work_sender(),
|
||||||
StateDir::new(temp.path()),
|
ManualActorControlConfig {
|
||||||
PluginSink::new(Arc::new(DiscardObservations)),
|
state_dir: StateDir::new(temp.path()),
|
||||||
|
sink: PluginSink::new(Arc::new(DiscardObservations)),
|
||||||
provider_factory,
|
provider_factory,
|
||||||
spec_builder,
|
spec_builder,
|
||||||
Some(validator),
|
config_validator: Some(validator),
|
||||||
Some(searcher),
|
offer_searcher: Some(searcher),
|
||||||
1,
|
control_generation: 1,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
prop_assert_eq!(
|
prop_assert_eq!(
|
||||||
runtime.stats().actors.len(),
|
runtime.stats().actors.len(),
|
||||||
|
|
|
||||||
|
|
@ -225,8 +225,7 @@ struct VastAiProviderMonitorActor {
|
||||||
stopped: bool,
|
stopped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VastAiProviderMonitorActor {
|
struct VastAiProviderMonitorConfig {
|
||||||
fn new(
|
|
||||||
client: ToolsVastAiLeaseClient,
|
client: ToolsVastAiLeaseClient,
|
||||||
contract_id: u64,
|
contract_id: u64,
|
||||||
label: String,
|
label: String,
|
||||||
|
|
@ -235,7 +234,20 @@ impl VastAiProviderMonitorActor {
|
||||||
sink: PluginSink,
|
sink: PluginSink,
|
||||||
sender: ExternalSender,
|
sender: ExternalSender,
|
||||||
engine: EngineHandle,
|
engine: EngineHandle,
|
||||||
) -> Self {
|
}
|
||||||
|
|
||||||
|
impl VastAiProviderMonitorActor {
|
||||||
|
fn new(config: VastAiProviderMonitorConfig) -> Self {
|
||||||
|
let VastAiProviderMonitorConfig {
|
||||||
|
client,
|
||||||
|
contract_id,
|
||||||
|
label,
|
||||||
|
lifecycle,
|
||||||
|
spec,
|
||||||
|
sink,
|
||||||
|
sender,
|
||||||
|
engine,
|
||||||
|
} = config;
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
contract_id,
|
contract_id,
|
||||||
|
|
@ -592,7 +604,8 @@ impl VastAiLeaseClient for ToolsVastAiLeaseClient {
|
||||||
let sender = runtime.create_sender();
|
let sender = runtime.create_sender();
|
||||||
let actor = runtime
|
let actor = runtime
|
||||||
.spawn(VastAiProviderMonitorActor::new(
|
.spawn(VastAiProviderMonitorActor::new(
|
||||||
self.clone(),
|
VastAiProviderMonitorConfig {
|
||||||
|
client: self.clone(),
|
||||||
contract_id,
|
contract_id,
|
||||||
label,
|
label,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
|
@ -600,6 +613,7 @@ impl VastAiLeaseClient for ToolsVastAiLeaseClient {
|
||||||
sink,
|
sink,
|
||||||
sender,
|
sender,
|
||||||
engine,
|
engine,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
.ok()?;
|
.ok()?;
|
||||||
Some(VastAiProviderMonitor::new(runtime, actor))
|
Some(VastAiProviderMonitor::new(runtime, actor))
|
||||||
|
|
@ -2355,21 +2369,23 @@ mod tests {
|
||||||
let recording = Arc::new(RecordingSink::default());
|
let recording = Arc::new(RecordingSink::default());
|
||||||
let actor = runtime
|
let actor = runtime
|
||||||
.spawn(VastAiProviderMonitorActor::new(
|
.spawn(VastAiProviderMonitorActor::new(
|
||||||
|
VastAiProviderMonitorConfig {
|
||||||
client,
|
client,
|
||||||
contract_id,
|
contract_id,
|
||||||
format!(
|
label: format!(
|
||||||
"run-{}-node-{}-attempt-{}",
|
"run-{}-node-{}-attempt-{}",
|
||||||
spec.run_id, spec.node_id, spec.attempt_id
|
spec.run_id, spec.node_id, spec.attempt_id
|
||||||
),
|
),
|
||||||
LifecyclePolicy {
|
lifecycle: LifecyclePolicy {
|
||||||
lease_pace: Duration::ZERO,
|
lease_pace: Duration::ZERO,
|
||||||
poll_interval: Duration::from_millis(1),
|
poll_interval: Duration::from_millis(1),
|
||||||
state_timeout: Duration::from_millis(10),
|
state_timeout: Duration::from_millis(10),
|
||||||
},
|
},
|
||||||
spec,
|
spec,
|
||||||
PluginSink::new(recording.clone()),
|
sink: PluginSink::new(recording.clone()),
|
||||||
runtime.create_sender(),
|
sender: runtime.create_sender(),
|
||||||
engine.handle(),
|
engine: engine.handle(),
|
||||||
|
},
|
||||||
))
|
))
|
||||||
.expect("spawn VastAI monitor");
|
.expect("spawn VastAI monitor");
|
||||||
MonitorHarness {
|
MonitorHarness {
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,19 @@
|
||||||
.sort-button[data-direction="asc"]::before { top: 3px; border-bottom: 5px solid currentColor; }
|
.sort-button[data-direction="asc"]::before { top: 3px; border-bottom: 5px solid currentColor; }
|
||||||
.sort-button[data-direction="desc"]::before { bottom: 3px; border-top: 5px solid currentColor; }
|
.sort-button[data-direction="desc"]::before { bottom: 3px; border-top: 5px solid currentColor; }
|
||||||
.sort-button:hover:not(:disabled), .sort-button[aria-pressed="true"] { color: var(--cyan); border-color: var(--cyan); background: var(--panel-hover); }
|
.sort-button:hover:not(:disabled), .sort-button[aria-pressed="true"] { color: var(--cyan); border-color: var(--cyan); background: var(--panel-hover); }
|
||||||
|
.offers-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.offers-heading h2 { margin: 0; }
|
||||||
|
.offer-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.offer-selection { margin: 0; font: 12px var(--mono); }
|
||||||
|
dialog.confirm-dialog {
|
||||||
|
width: min(440px, calc(100vw - 32px)); padding: 0; color: var(--text);
|
||||||
|
background: var(--panel); border: 1px solid var(--amber); border-radius: var(--r);
|
||||||
|
box-shadow: 0 18px 60px rgba(0, 0, 0, .55);
|
||||||
|
}
|
||||||
|
dialog.confirm-dialog::backdrop { background: rgba(0, 6, 12, .78); }
|
||||||
|
.confirm-form { display: grid; gap: 14px; padding: 18px; }
|
||||||
|
.confirm-form h2, .confirm-form p { margin: 0; }
|
||||||
|
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
@media (max-width: 680px) { #search-form { grid-template-columns: 1fr; } }
|
@media (max-width: 680px) { #search-form { grid-template-columns: 1fr; } }
|
||||||
@media (prefers-reduced-motion: reduce) { * { transition-duration: .01ms !important; } }
|
@media (prefers-reduced-motion: reduce) { * { transition-duration: .01ms !important; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -144,12 +157,27 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<section id="vast-offers-card" class="card" style="margin-top:1rem">
|
<section id="vast-offers-card" class="card" style="margin-top:1rem">
|
||||||
<h2>Offers</h2><p id="offer-status" class="muted"></p><p id="offer-error" class="error"></p>
|
<div class="offers-heading">
|
||||||
<div class="offers-scroll"><table id="offers-table"><thead><tr><th>Select</th><th>Offer<span class="sort-controls"><button type="button" class="sort-button" data-sort="offer_id" data-direction="asc" title="Sort offer ascending" aria-label="Sort offer ascending">↑</button><button type="button" class="sort-button" data-sort="offer_id" data-direction="desc" title="Sort offer descending" aria-label="Sort offer descending">↓</button></span></th><th>Host<span class="sort-controls"><button type="button" class="sort-button" data-sort="host_id" data-direction="asc" title="Sort host ascending" aria-label="Sort host ascending">↑</button><button type="button" class="sort-button" data-sort="host_id" data-direction="desc" title="Sort host descending" aria-label="Sort host descending">↓</button></span></th><th>GPU<span class="sort-controls"><button type="button" class="sort-button" data-sort="gpu_model" data-direction="asc" title="Sort GPU ascending" aria-label="Sort GPU ascending">↑</button><button type="button" class="sort-button" data-sort="gpu_model" data-direction="desc" title="Sort GPU descending" aria-label="Sort GPU descending">↓</button></span></th><th>RAM<span class="sort-controls"><button type="button" class="sort-button" data-sort="gpu_ram_mb" data-direction="asc" title="Sort RAM ascending" aria-label="Sort RAM ascending">↑</button><button type="button" class="sort-button" data-sort="gpu_ram_mb" data-direction="desc" title="Sort RAM descending" aria-label="Sort RAM descending">↓</button></span></th><th>CC<span class="sort-controls"><button type="button" class="sort-button" data-sort="compute_cap" data-direction="asc" title="Sort compute capability ascending" aria-label="Sort compute capability ascending">↑</button><button type="button" class="sort-button" data-sort="compute_cap" data-direction="desc" title="Sort compute capability descending" aria-label="Sort compute capability descending">↓</button></span></th><th>Verified<span class="sort-controls"><button type="button" class="sort-button" data-sort="verification" data-direction="asc" title="Sort verification ascending" aria-label="Sort verification ascending">↑</button><button type="button" class="sort-button" data-sort="verification" data-direction="desc" title="Sort verification descending" aria-label="Sort verification descending">↓</button></span></th><th>Reliability<span class="sort-controls"><button type="button" class="sort-button" data-sort="reliability" data-direction="asc" title="Sort reliability ascending" aria-label="Sort reliability ascending">↑</button><button type="button" class="sort-button" data-sort="reliability" data-direction="desc" title="Sort reliability descending" aria-label="Sort reliability descending">↓</button></span></th><th>Down<span class="sort-controls"><button type="button" class="sort-button" data-sort="download_mbps" data-direction="asc" title="Sort download speed ascending" aria-label="Sort download speed ascending">↑</button><button type="button" class="sort-button" data-sort="download_mbps" data-direction="desc" title="Sort download speed descending" aria-label="Sort download speed descending">↓</button></span></th><th>Up<span class="sort-controls"><button type="button" class="sort-button" data-sort="upload_mbps" data-direction="asc" title="Sort upload speed ascending" aria-label="Sort upload speed ascending">↑</button><button type="button" class="sort-button" data-sort="upload_mbps" data-direction="desc" title="Sort upload speed descending" aria-label="Sort upload speed descending">↓</button></span></th><th>Location<span class="sort-controls"><button type="button" class="sort-button" data-sort="location" data-direction="asc" title="Sort location ascending" aria-label="Sort location ascending">↑</button><button type="button" class="sort-button" data-sort="location" data-direction="desc" title="Sort location descending" aria-label="Sort location descending">↓</button></span></th><th>$/hr<span class="sort-controls"><button type="button" class="sort-button" data-sort="hourly_price" data-direction="asc" title="Sort price ascending" aria-label="Sort price ascending">↑</button><button type="button" class="sort-button" data-sort="hourly_price" data-direction="desc" title="Sort price descending" aria-label="Sort price descending">↓</button></span></th></tr></thead><tbody id="offers"></tbody></table></div>
|
<h2>Offers</h2>
|
||||||
<p>Selected exact offer IDs: <code id="selection">none (0/8)</code></p>
|
<div class="offer-actions">
|
||||||
|
<p class="offer-selection">Selected: <code id="selection">none (0/8)</code></p>
|
||||||
<button id="provision-button" class="primary" disabled>Provision selected offers</button>
|
<button id="provision-button" class="primary" disabled>Provision selected offers</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="offer-status" class="muted"></p><p id="offer-error" class="error"></p>
|
||||||
|
<div class="offers-scroll"><table id="offers-table"><thead><tr><th>Select</th><th>Offer<span class="sort-controls"><button type="button" class="sort-button" data-sort="offer_id" data-direction="asc" title="Sort offer ascending" aria-label="Sort offer ascending">↑</button><button type="button" class="sort-button" data-sort="offer_id" data-direction="desc" title="Sort offer descending" aria-label="Sort offer descending">↓</button></span></th><th>Host<span class="sort-controls"><button type="button" class="sort-button" data-sort="host_id" data-direction="asc" title="Sort host ascending" aria-label="Sort host ascending">↑</button><button type="button" class="sort-button" data-sort="host_id" data-direction="desc" title="Sort host descending" aria-label="Sort host descending">↓</button></span></th><th>GPU<span class="sort-controls"><button type="button" class="sort-button" data-sort="gpu_model" data-direction="asc" title="Sort GPU ascending" aria-label="Sort GPU ascending">↑</button><button type="button" class="sort-button" data-sort="gpu_model" data-direction="desc" title="Sort GPU descending" aria-label="Sort GPU descending">↓</button></span></th><th>RAM<span class="sort-controls"><button type="button" class="sort-button" data-sort="gpu_ram_mb" data-direction="asc" title="Sort RAM ascending" aria-label="Sort RAM ascending">↑</button><button type="button" class="sort-button" data-sort="gpu_ram_mb" data-direction="desc" title="Sort RAM descending" aria-label="Sort RAM descending">↓</button></span></th><th>CC<span class="sort-controls"><button type="button" class="sort-button" data-sort="compute_cap" data-direction="asc" title="Sort compute capability ascending" aria-label="Sort compute capability ascending">↑</button><button type="button" class="sort-button" data-sort="compute_cap" data-direction="desc" title="Sort compute capability descending" aria-label="Sort compute capability descending">↓</button></span></th><th>Verified<span class="sort-controls"><button type="button" class="sort-button" data-sort="verification" data-direction="asc" title="Sort verification ascending" aria-label="Sort verification ascending">↑</button><button type="button" class="sort-button" data-sort="verification" data-direction="desc" title="Sort verification descending" aria-label="Sort verification descending">↓</button></span></th><th>Reliability<span class="sort-controls"><button type="button" class="sort-button" data-sort="reliability" data-direction="asc" title="Sort reliability ascending" aria-label="Sort reliability ascending">↑</button><button type="button" class="sort-button" data-sort="reliability" data-direction="desc" title="Sort reliability descending" aria-label="Sort reliability descending">↓</button></span></th><th>Down<span class="sort-controls"><button type="button" class="sort-button" data-sort="download_mbps" data-direction="asc" title="Sort download speed ascending" aria-label="Sort download speed ascending">↑</button><button type="button" class="sort-button" data-sort="download_mbps" data-direction="desc" title="Sort download speed descending" aria-label="Sort download speed descending">↓</button></span></th><th>Up<span class="sort-controls"><button type="button" class="sort-button" data-sort="upload_mbps" data-direction="asc" title="Sort upload speed ascending" aria-label="Sort upload speed ascending">↑</button><button type="button" class="sort-button" data-sort="upload_mbps" data-direction="desc" title="Sort upload speed descending" aria-label="Sort upload speed descending">↓</button></span></th><th>Location<span class="sort-controls"><button type="button" class="sort-button" data-sort="location" data-direction="asc" title="Sort location ascending" aria-label="Sort location ascending">↑</button><button type="button" class="sort-button" data-sort="location" data-direction="desc" title="Sort location descending" aria-label="Sort location descending">↓</button></span></th><th>$/hr<span class="sort-controls"><button type="button" class="sort-button" data-sort="hourly_price" data-direction="asc" title="Sort price ascending" aria-label="Sort price ascending">↑</button><button type="button" class="sort-button" data-sort="hourly_price" data-direction="desc" title="Sort price descending" aria-label="Sort price descending">↓</button></span></th></tr></thead><tbody id="offers"></tbody></table></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-message">
|
||||||
|
<form method="dialog" class="confirm-form">
|
||||||
|
<h2 id="confirm-title">Confirm action</h2>
|
||||||
|
<p id="confirm-message"></p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button value="cancel" autofocus>Cancel</button>
|
||||||
|
<button id="confirm-submit" class="primary" value="confirm">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<script>
|
<script>
|
||||||
'use strict';
|
'use strict';
|
||||||
const $=id=>document.getElementById(id); const MAX_SELECTED_OFFERS=8; const PAGE_STATE_KEY='myelin.provision.page.v1'; const SEARCH_FIELDS=['gpu_model','min_gpu_ram_mb','min_compute_cap','min_reliability','min_download_mbps','min_upload_mbps','max_hourly_price','blacklist_hosts','require_verified']; let selected=new Set(); let provisioningMode='real'; let currentOffers=[]; let offerSorts=[]; let sortDoubleClickStartedActive=null;
|
const $=id=>document.getElementById(id); const MAX_SELECTED_OFFERS=8; const PAGE_STATE_KEY='myelin.provision.page.v1'; const SEARCH_FIELDS=['gpu_model','min_gpu_ram_mb','min_compute_cap','min_reliability','min_download_mbps','min_upload_mbps','max_hourly_price','blacklist_hosts','require_verified']; let selected=new Set(); let provisioningMode='real'; let currentOffers=[]; let offerSorts=[]; let sortDoubleClickStartedActive=null;
|
||||||
|
|
@ -158,6 +186,7 @@ const number=(form,name)=>{const raw=value(form,name);return raw===null?null:Num
|
||||||
const escapeHtml=raw=>String(raw??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
const escapeHtml=raw=>String(raw??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
||||||
async function jsonFetch(url,options={}){const response=await fetch(url,{...options,headers:{'content-type':'application/json',...(options.headers||{})}});const text=await response.text();let body=null;try{body=text?JSON.parse(text):null}catch{body={error:text}}if(!response.ok)throw new Error(body?.error||`${response.status} ${response.statusText}`);return body}
|
async function jsonFetch(url,options={}){const response=await fetch(url,{...options,headers:{'content-type':'application/json',...(options.headers||{})}});const text=await response.text();let body=null;try{body=text?JSON.parse(text):null}catch{body={error:text}}if(!response.ok)throw new Error(body?.error||`${response.status} ${response.statusText}`);return body}
|
||||||
function commandId(prefix){return `${prefix}-${crypto.randomUUID()}`}
|
function commandId(prefix){return `${prefix}-${crypto.randomUUID()}`}
|
||||||
|
function confirmAction(title,message,confirmLabel){const dialog=$('confirm-dialog');$('confirm-title').textContent=title;$('confirm-message').textContent=message;$('confirm-submit').textContent=confirmLabel;dialog.returnValue='cancel';return new Promise(resolve=>{dialog.addEventListener('close',()=>resolve(dialog.returnValue==='confirm'),{once:true});dialog.showModal()})}
|
||||||
function formSnapshot(form,names){return Object.fromEntries(names.map(name=>{const field=form.elements[name];return [name,field.type==='checkbox'?field.checked:field.value]}))}
|
function formSnapshot(form,names){return Object.fromEntries(names.map(name=>{const field=form.elements[name];return [name,field.type==='checkbox'?field.checked:field.value]}))}
|
||||||
function restoreForm(form,state){if(!state)return;Object.entries(state).forEach(([name,saved])=>{const field=form.elements[name];if(!field)return;if(field.type==='checkbox')field.checked=Boolean(saved);else field.value=String(saved??'')})}
|
function restoreForm(form,state){if(!state)return;Object.entries(state).forEach(([name,saved])=>{const field=form.elements[name];if(!field)return;if(field.type==='checkbox')field.checked=Boolean(saved);else field.value=String(saved??'')})}
|
||||||
function persistPageState(){try{sessionStorage.setItem(PAGE_STATE_KEY,JSON.stringify({config:formSnapshot($('config-form'),['ssh_identity','bootstrap_command']),search:formSnapshot($('search-form'),SEARCH_FIELDS),local:formSnapshot($('local-provision-form'),['count']),offers:currentOffers,selected:[...selected],sorts:offerSorts}))}catch{}}
|
function persistPageState(){try{sessionStorage.setItem(PAGE_STATE_KEY,JSON.stringify({config:formSnapshot($('config-form'),['ssh_identity','bootstrap_command']),search:formSnapshot($('search-form'),SEARCH_FIELDS),local:formSnapshot($('local-provision-form'),['count']),offers:currentOffers,selected:[...selected],sorts:offerSorts}))}catch{}}
|
||||||
|
|
@ -165,13 +194,13 @@ function restorePageState(){try{const saved=JSON.parse(sessionStorage.getItem(PA
|
||||||
function updateSelectionUi(){const count=selected.size;$('selection').textContent=count?`${[...selected].join(', ')} (${count}/${MAX_SELECTED_OFFERS})`:`none (0/${MAX_SELECTED_OFFERS})`;$('provision-button').disabled=count===0;$('provision-button').textContent=count===0?(provisioningMode==='mock'?'Simulate selected offers':'Provision selected offers'):`${provisioningMode==='mock'?'Simulate':'Provision'} ${count} selected offer${count===1?'':'s'}`}
|
function updateSelectionUi(){const count=selected.size;$('selection').textContent=count?`${[...selected].join(', ')} (${count}/${MAX_SELECTED_OFFERS})`:`none (0/${MAX_SELECTED_OFFERS})`;$('provision-button').disabled=count===0;$('provision-button').textContent=count===0?(provisioningMode==='mock'?'Simulate selected offers':'Provision selected offers'):`${provisioningMode==='mock'?'Simulate':'Provision'} ${count} selected offer${count===1?'':'s'}`}
|
||||||
async function refresh(){try{const reply=await jsonFetch('/api/control/status');const model=reply.Status;const provider=model.provider.name;provisioningMode=model.provider.provisioning_mode||'real';const vast=provider==='vastai';const simulated=vast&&provisioningMode==='mock';const local=provider==='process'||provider==='docker';$('provider-config-card').hidden=!vast;$('vast-search-card').hidden=!vast;$('vast-offers-card').hidden=!vast;$('local-provision-card').hidden=!local;$('simulation-banner').hidden=!simulated;document.querySelectorAll('[data-real-vastai]').forEach(element=>element.hidden=simulated);$('local-provider-name').textContent=provider;$('readiness').textContent=`${provider} · ${provisioningMode} · ${model.provider.kind}`;$('readiness').className=model.provider.kind==='ready'?'status ok':'status';$('readiness-error').textContent=model.provider.error||'';$('search-button').disabled=!vast||model.provider.kind!=='ready';$('local-provision-button').disabled=!local||model.provider.kind!=='ready';updateSelectionUi()}catch(error){$('readiness-error').textContent=error.message}}
|
async function refresh(){try{const reply=await jsonFetch('/api/control/status');const model=reply.Status;const provider=model.provider.name;provisioningMode=model.provider.provisioning_mode||'real';const vast=provider==='vastai';const simulated=vast&&provisioningMode==='mock';const local=provider==='process'||provider==='docker';$('provider-config-card').hidden=!vast;$('vast-search-card').hidden=!vast;$('vast-offers-card').hidden=!vast;$('local-provision-card').hidden=!local;$('simulation-banner').hidden=!simulated;document.querySelectorAll('[data-real-vastai]').forEach(element=>element.hidden=simulated);$('local-provider-name').textContent=provider;$('readiness').textContent=`${provider} · ${provisioningMode} · ${model.provider.kind}`;$('readiness').className=model.provider.kind==='ready'?'status ok':'status';$('readiness-error').textContent=model.provider.error||'';$('search-button').disabled=!vast||model.provider.kind!=='ready';$('local-provision-button').disabled=!local||model.provider.kind!=='ready';updateSelectionUi()}catch(error){$('readiness-error').textContent=error.message}}
|
||||||
$('config-form').addEventListener('submit',async event=>{event.preventDefault();const form=event.currentTarget;try{await jsonFetch('/api/control/provider',{method:'POST',body:JSON.stringify({api_key:value(form,'api_key'),ssh_identity:value(form,'ssh_identity'),bootstrap_command:value(form,'bootstrap_command')})});form.elements.api_key.value='';persistPageState();await refresh()}catch(error){$('readiness-error').textContent=error.message}});
|
$('config-form').addEventListener('submit',async event=>{event.preventDefault();const form=event.currentTarget;try{await jsonFetch('/api/control/provider',{method:'POST',body:JSON.stringify({api_key:value(form,'api_key'),ssh_identity:value(form,'ssh_identity'),bootstrap_command:value(form,'bootstrap_command')})});form.elements.api_key.value='';persistPageState();await refresh()}catch(error){$('readiness-error').textContent=error.message}});
|
||||||
$('local-provision-form').addEventListener('submit',async event=>{event.preventDefault();const form=event.currentTarget;const count=number(form,'count')||1;const provider=$('local-provider-name').textContent;if(!confirm(`Provision ${count} ${provider} node${count===1?'':'s'}?`))return;try{await jsonFetch('/api/control/provision',{method:'POST',body:JSON.stringify({command_id:commandId('provision'),count,selected_offer_ids:[],image:$('runtime-image').value.trim()})});$('local-provision-error').textContent='';await refresh()}catch(error){$('local-provision-error').textContent=error.message}});
|
$('local-provision-form').addEventListener('submit',async event=>{event.preventDefault();const form=event.currentTarget;const count=number(form,'count')||1;const provider=$('local-provider-name').textContent;if(!await confirmAction('Provision nodes?',`Provision ${count} ${provider} node${count===1?'':'s'}?`,'Provision'))return;try{await jsonFetch('/api/control/provision',{method:'POST',body:JSON.stringify({command_id:commandId('provision'),count,selected_offer_ids:[],image:$('runtime-image').value.trim()})});$('local-provision-error').textContent='';await refresh()}catch(error){$('local-provision-error').textContent=error.message}});
|
||||||
$('search-form').addEventListener('submit',async event=>{event.preventDefault();const form=event.currentTarget;const blacklist=(value(form,'blacklist_hosts')||'').split(',').map(v=>Number(v.trim())).filter(Number.isFinite);$('offer-status').textContent='Searching…';$('offer-error').textContent='';try{const reply=await jsonFetch('/api/control/offers',{method:'POST',body:JSON.stringify({gpu_model:value(form,'gpu_model'),min_gpu_ram_mb:number(form,'min_gpu_ram_mb'),min_compute_cap:number(form,'min_compute_cap'),min_reliability:number(form,'min_reliability'),require_verified:form.elements.require_verified.checked,min_download_mbps:number(form,'min_download_mbps'),min_upload_mbps:number(form,'min_upload_mbps'),max_hourly_price:number(form,'max_hourly_price'),blacklist_hosts:blacklist,count:null})});currentOffers=reply.Offers;selected.clear();renderOffers();updateSelectionUi();$('offer-status').textContent=currentOffers.length===0?'No offers match this search.':`${currentOffers.length} matching offer${currentOffers.length===1?'':'s'}`;persistPageState()}catch(error){$('offer-status').textContent='';$('offer-error').textContent=error.message}});
|
$('search-form').addEventListener('submit',async event=>{event.preventDefault();const form=event.currentTarget;const blacklist=(value(form,'blacklist_hosts')||'').split(',').map(v=>Number(v.trim())).filter(Number.isFinite);$('offer-status').textContent='Searching…';$('offer-error').textContent='';try{const reply=await jsonFetch('/api/control/offers',{method:'POST',body:JSON.stringify({gpu_model:value(form,'gpu_model'),min_gpu_ram_mb:number(form,'min_gpu_ram_mb'),min_compute_cap:number(form,'min_compute_cap'),min_reliability:number(form,'min_reliability'),require_verified:form.elements.require_verified.checked,min_download_mbps:number(form,'min_download_mbps'),min_upload_mbps:number(form,'min_upload_mbps'),max_hourly_price:number(form,'max_hourly_price'),blacklist_hosts:blacklist,count:null})});currentOffers=reply.Offers;selected.clear();renderOffers();updateSelectionUi();$('offer-status').textContent=currentOffers.length===0?'No offers match this search.':`${currentOffers.length} matching offer${currentOffers.length===1?'':'s'}`;persistPageState()}catch(error){$('offer-status').textContent='';$('offer-error').textContent=error.message}});
|
||||||
function compareOfferValues(left,right){const leftMissing=left===null||left===undefined||left==='';const rightMissing=right===null||right===undefined||right==='';if(leftMissing||rightMissing)return leftMissing===rightMissing?0:leftMissing?1:-1;if(typeof left==='number'&&typeof right==='number')return left-right;return String(left).localeCompare(String(right),undefined,{numeric:true,sensitivity:'base'})}
|
function compareOfferValues(left,right){const leftMissing=left===null||left===undefined||left==='';const rightMissing=right===null||right===undefined||right==='';if(leftMissing||rightMissing)return leftMissing===rightMissing?0:leftMissing?1:-1;if(typeof left==='number'&&typeof right==='number')return left-right;return String(left).localeCompare(String(right),undefined,{numeric:true,sensitivity:'base'})}
|
||||||
function renderOffers(){const offers=[...currentOffers];if(offerSorts.length)offers.sort((left,right)=>{for(const sort of offerSorts){const compared=compareOfferValues(left[sort.key],right[sort.key]);if(compared!==0)return compared*(sort.direction==='asc'?1:-1)}return Number(left.offer_id)-Number(right.offer_id)});$('offers').replaceChildren(...offers.map(offer=>{const tr=document.createElement('tr');tr.innerHTML=`<td><input type="checkbox" data-offer="${Number(offer.offer_id)}" style="width:auto"></td><td>${escapeHtml(offer.offer_id)}</td><td>${escapeHtml(offer.host_id)}</td><td>${escapeHtml(offer.gpu_model)}</td><td>${escapeHtml(offer.gpu_ram_mb)}</td><td>${escapeHtml(offer.compute_cap)}</td><td>${escapeHtml(offer.verification)}</td><td>${escapeHtml(offer.reliability)}</td><td>${escapeHtml(offer.download_mbps)}</td><td>${escapeHtml(offer.upload_mbps)}</td><td>${escapeHtml(offer.location)}</td><td>${escapeHtml(Number(offer.hourly_price).toFixed(3))}</td>`;tr.querySelector('[data-offer]').checked=selected.has(Number(offer.offer_id));return tr}));document.querySelectorAll('.sort-button').forEach(button=>button.setAttribute('aria-pressed',String(offerSorts.some(sort=>sort.key===button.dataset.sort&&sort.direction===button.dataset.direction))))}
|
function renderOffers(){const offers=[...currentOffers];if(offerSorts.length)offers.sort((left,right)=>{for(const sort of offerSorts){const compared=compareOfferValues(left[sort.key],right[sort.key]);if(compared!==0)return compared*(sort.direction==='asc'?1:-1)}return Number(left.offer_id)-Number(right.offer_id)});$('offers').replaceChildren(...offers.map(offer=>{const tr=document.createElement('tr');tr.innerHTML=`<td><input type="checkbox" data-offer="${Number(offer.offer_id)}" style="width:auto"></td><td>${escapeHtml(offer.offer_id)}</td><td>${escapeHtml(offer.host_id)}</td><td>${escapeHtml(offer.gpu_model)}</td><td>${escapeHtml(offer.gpu_ram_mb)}</td><td>${escapeHtml(offer.compute_cap)}</td><td>${escapeHtml(offer.verification)}</td><td>${escapeHtml(offer.reliability)}</td><td>${escapeHtml(offer.download_mbps)}</td><td>${escapeHtml(offer.upload_mbps)}</td><td>${escapeHtml(offer.location)}</td><td>${escapeHtml(Number(offer.hourly_price).toFixed(3))}</td>`;tr.querySelector('[data-offer]').checked=selected.has(Number(offer.offer_id));return tr}));document.querySelectorAll('.sort-button').forEach(button=>button.setAttribute('aria-pressed',String(offerSorts.some(sort=>sort.key===button.dataset.sort&&sort.direction===button.dataset.direction))))}
|
||||||
$('offers-table').addEventListener('click',event=>{const button=event.target.closest('.sort-button');if(!button)return;const id=`${button.dataset.sort}:${button.dataset.direction}`;const activeIndex=offerSorts.findIndex(sort=>sort.key===button.dataset.sort&&sort.direction===button.dataset.direction);if(event.detail===1)sortDoubleClickStartedActive=activeIndex>=0?id:null;if(event.detail===2&&sortDoubleClickStartedActive===id){offerSorts.splice(activeIndex,1);sortDoubleClickStartedActive=null}else if(activeIndex<0){const columnIndex=offerSorts.findIndex(sort=>sort.key===button.dataset.sort);const next={key:button.dataset.sort,direction:button.dataset.direction};if(columnIndex>=0)offerSorts.splice(columnIndex,1,next);else offerSorts.push(next)}renderOffers();persistPageState()});
|
$('offers-table').addEventListener('click',event=>{const button=event.target.closest('.sort-button');if(!button)return;const id=`${button.dataset.sort}:${button.dataset.direction}`;const activeIndex=offerSorts.findIndex(sort=>sort.key===button.dataset.sort&&sort.direction===button.dataset.direction);if(event.detail===1)sortDoubleClickStartedActive=activeIndex>=0?id:null;if(event.detail===2&&sortDoubleClickStartedActive===id){offerSorts.splice(activeIndex,1);sortDoubleClickStartedActive=null}else if(activeIndex<0){const columnIndex=offerSorts.findIndex(sort=>sort.key===button.dataset.sort);const next={key:button.dataset.sort,direction:button.dataset.direction};if(columnIndex>=0)offerSorts.splice(columnIndex,1,next);else offerSorts.push(next)}renderOffers();persistPageState()});
|
||||||
$('offers').addEventListener('change',event=>{const id=Number(event.target.dataset.offer);if(!Number.isFinite(id))return;if(event.target.checked){if(selected.size>=MAX_SELECTED_OFFERS){event.target.checked=false;$('offer-error').textContent=`Select at most ${MAX_SELECTED_OFFERS} offers.`;return}selected.add(id)}else selected.delete(id);$('offer-error').textContent='';updateSelectionUi();persistPageState()});
|
$('offers').addEventListener('change',event=>{const id=Number(event.target.dataset.offer);if(!Number.isFinite(id))return;if(event.target.checked){if(selected.size>=MAX_SELECTED_OFFERS){event.target.checked=false;$('offer-error').textContent=`Select at most ${MAX_SELECTED_OFFERS} offers.`;return}selected.add(id)}else selected.delete(id);$('offer-error').textContent='';updateSelectionUi();persistPageState()});
|
||||||
$('provision-button').addEventListener('click',async()=>{try{const ids=[...selected];if(ids.length===0)return;const question=provisioningMode==='mock'?`Simulate provisioning exact Vast.ai offer IDs ${ids.join(', ')}? No rental will be created.`:`Provision exact Vast.ai offer IDs ${ids.join(', ')}? This can incur charges.`;if(!confirm(question))return;await jsonFetch('/api/control/provision',{method:'POST',body:JSON.stringify({command_id:commandId('provision'),count:ids.length,selected_offer_ids:ids,image:$('runtime-image').value.trim()})});const provisioned=new Set(ids);currentOffers=currentOffers.filter(offer=>!provisioned.has(Number(offer.offer_id)));selected.clear();renderOffers();updateSelectionUi();persistPageState();await refresh()}catch(error){$('offer-error').textContent=error.message}});
|
$('provision-button').addEventListener('click',async()=>{try{const ids=[...selected];if(ids.length===0)return;const simulated=provisioningMode==='mock';const question=simulated?`Simulate provisioning exact Vast.ai offer IDs ${ids.join(', ')}? No rental will be created.`:`Provision exact Vast.ai offer IDs ${ids.join(', ')}? This can incur charges.`;if(!await confirmAction(simulated?'Simulate provisioning?':'Provision selected offers?',question,simulated?'Simulate':'Provision'))return;await jsonFetch('/api/control/provision',{method:'POST',body:JSON.stringify({command_id:commandId('provision'),count:ids.length,selected_offer_ids:ids,image:$('runtime-image').value.trim()})});const provisioned=new Set(ids);currentOffers=currentOffers.filter(offer=>!provisioned.has(Number(offer.offer_id)));selected.clear();renderOffers();updateSelectionUi();persistPageState();await refresh()}catch(error){$('offer-error').textContent=error.message}});
|
||||||
$('config-form').addEventListener('input',persistPageState);$('search-form').addEventListener('input',persistPageState);$('local-provision-form').addEventListener('input',persistPageState);restorePageState();refresh();setInterval(refresh,1000);
|
$('config-form').addEventListener('input',persistPageState);$('search-form').addEventListener('input',persistPageState);$('local-provision-form').addEventListener('input',persistPageState);restorePageState();refresh();setInterval(refresh,1000);
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,6 @@ pub(crate) struct MockVastAiPlugin {
|
||||||
inner: Box<dyn ProvisionPlugin>,
|
inner: Box<dyn ProvisionPlugin>,
|
||||||
contracts: BTreeMap<u64, MockVastAiContract>,
|
contracts: BTreeMap<u64, MockVastAiContract>,
|
||||||
selected_offers: BTreeMap<u64, u64>,
|
selected_offers: BTreeMap<u64, u64>,
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
lifecycle_observer_path: Option<PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -280,9 +278,6 @@ impl MockVastAiPlugin {
|
||||||
inner,
|
inner,
|
||||||
contracts: BTreeMap::new(),
|
contracts: BTreeMap::new(),
|
||||||
selected_offers: BTreeMap::new(),
|
selected_offers: BTreeMap::new(),
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
lifecycle_observer_path: std::env::var_os("MYELIN_MOCK_VASTAI_LEDGER_PATH")
|
|
||||||
.map(PathBuf::from),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,113 +328,6 @@ fn observe_mock_vastai_contract(
|
||||||
.to_string(),
|
.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
fn observe_mock_vastai_lifecycle(
|
|
||||||
path: Option<&Path>,
|
|
||||||
event: &str,
|
|
||||||
spec: &NodeProvisionSpec,
|
|
||||||
provider_ref: &str,
|
|
||||||
selected_offer_id: Option<u64>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let Some(path) = path else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(path)
|
|
||||||
.map_err(|error| {
|
|
||||||
format!(
|
|
||||||
"open mock Vast.ai lifecycle observer {}: {error}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let mut observation = serde_json::to_string(&serde_json::json!({
|
|
||||||
"event": event,
|
|
||||||
"run_id": spec.run_id,
|
|
||||||
"node_id": spec.node_id,
|
|
||||||
"attempt_id": spec.attempt_id,
|
|
||||||
"provider_ref": provider_ref,
|
|
||||||
"selected_offer_id": selected_offer_id,
|
|
||||||
}))
|
|
||||||
.map_err(|error| {
|
|
||||||
format!(
|
|
||||||
"encode mock Vast.ai lifecycle observation {}: {error}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
observation.push('\n');
|
|
||||||
file.write_all(observation.as_bytes()).map_err(|error| {
|
|
||||||
format!(
|
|
||||||
"append mock Vast.ai lifecycle observation {}: {error}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
fn mock_vastai_lifecycle_path(plugin: &MockVastAiPlugin) -> Option<&Path> {
|
|
||||||
plugin.lifecycle_observer_path.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
fn mock_vastai_resource_is_live(
|
|
||||||
path: Option<&Path>,
|
|
||||||
spec: &NodeProvisionSpec,
|
|
||||||
provider_ref: &str,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let Some(path) = path else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let contents = match fs::read_to_string(path) {
|
|
||||||
Ok(contents) => contents,
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
|
||||||
Err(error) => {
|
|
||||||
return Err(format!(
|
|
||||||
"read mock Vast.ai provider state {}: {error}",
|
|
||||||
path.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut live = false;
|
|
||||||
for (index, line) in contents.lines().enumerate() {
|
|
||||||
let event: serde_json::Value = serde_json::from_str(line).map_err(|error| {
|
|
||||||
format!(
|
|
||||||
"parse mock Vast.ai provider state {} line {}: {error}",
|
|
||||||
path.display(),
|
|
||||||
index + 1
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if event
|
|
||||||
.get("provider_ref")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
!= Some(provider_ref)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let event_run_id = event.get("run_id").and_then(serde_json::Value::as_u64);
|
|
||||||
let event_node_id = event.get("node_id").and_then(serde_json::Value::as_u64);
|
|
||||||
let event_attempt_id = event.get("attempt_id").and_then(serde_json::Value::as_u64);
|
|
||||||
if (event_run_id, event_node_id, event_attempt_id)
|
|
||||||
!= (Some(spec.run_id), Some(spec.node_id), Some(spec.attempt_id))
|
|
||||||
{
|
|
||||||
return Err(format!(
|
|
||||||
"mock Vast.ai provider reference {provider_ref} belongs to another node: {event}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
match event.get("event").and_then(serde_json::Value::as_str) {
|
|
||||||
Some("created") => live = true,
|
|
||||||
Some("destroyed") => live = false,
|
|
||||||
Some("adopted" | "recreated") => {}
|
|
||||||
other => {
|
|
||||||
return Err(format!(
|
|
||||||
"unknown mock Vast.ai provider lifecycle event {other:?}: {event}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(live)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn local_process_provider_ref(prefix: &str, spec: &NodeProvisionSpec) -> String {
|
fn local_process_provider_ref(prefix: &str, spec: &NodeProvisionSpec) -> String {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -720,8 +608,7 @@ fn docker_inspect_error_is_absent(stderr: &str) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn docker_container_is_absent(name: &str) -> Result<bool, String> {
|
fn docker_container_is_absent(name: &str) -> Result<bool, String> {
|
||||||
let output =
|
let output = swactor_process::command_output(Command::new("docker").arg("inspect").arg(name))
|
||||||
swactor_process::command_output(&mut Command::new("docker").arg("inspect").arg(name))
|
|
||||||
.map_err(|error| format!("inspect Docker container {name}: {error}"))?;
|
.map_err(|error| format!("inspect Docker container {name}: {error}"))?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
|
|
@ -740,7 +627,7 @@ fn docker_container_is_absent(name: &str) -> Result<bool, String> {
|
||||||
|
|
||||||
fn docker_container_is_running(name: &str) -> Result<bool, String> {
|
fn docker_container_is_running(name: &str) -> Result<bool, String> {
|
||||||
let output = swactor_process::command_output(
|
let output = swactor_process::command_output(
|
||||||
&mut Command::new("docker")
|
Command::new("docker")
|
||||||
.args(["inspect", "-f", "{{.State.Running}}"])
|
.args(["inspect", "-f", "{{.State.Running}}"])
|
||||||
.arg(name),
|
.arg(name),
|
||||||
)
|
)
|
||||||
|
|
@ -757,7 +644,7 @@ fn docker_container_is_running(name: &str) -> Result<bool, String> {
|
||||||
|
|
||||||
/// Lists container names carrying this daemon's label, running or not.
|
/// Lists container names carrying this daemon's label, running or not.
|
||||||
fn docker_labeled_containers(prefix: &str) -> Result<Vec<String>, String> {
|
fn docker_labeled_containers(prefix: &str) -> Result<Vec<String>, String> {
|
||||||
let output = swactor_process::command_output(&mut Command::new("docker").args([
|
let output = swactor_process::command_output(Command::new("docker").args([
|
||||||
"ps",
|
"ps",
|
||||||
"-a",
|
"-a",
|
||||||
"--filter",
|
"--filter",
|
||||||
|
|
@ -786,7 +673,7 @@ fn docker_containers_for_spec(
|
||||||
spec: &NodeProvisionSpec,
|
spec: &NodeProvisionSpec,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
let output = swactor_process::command_output(
|
let output = swactor_process::command_output(
|
||||||
&mut Command::new("docker")
|
Command::new("docker")
|
||||||
.args(["ps", "-a"])
|
.args(["ps", "-a"])
|
||||||
.arg("--filter")
|
.arg("--filter")
|
||||||
.arg(format!("label=myelin.daemon={prefix}"))
|
.arg(format!("label=myelin.daemon={prefix}"))
|
||||||
|
|
@ -886,7 +773,7 @@ fn prepare_docker_file_volume(
|
||||||
)?;
|
)?;
|
||||||
let loader_name = format!("myelin-cache-load-{}-{volume}", std::process::id());
|
let loader_name = format!("myelin-cache-load-{}-{volume}", std::process::id());
|
||||||
let _ = swactor_process::command_status(
|
let _ = swactor_process::command_status(
|
||||||
&mut Command::new("docker")
|
Command::new("docker")
|
||||||
.args(["rm", "-f", &loader_name])
|
.args(["rm", "-f", &loader_name])
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null()),
|
.stderr(Stdio::null()),
|
||||||
|
|
@ -915,7 +802,7 @@ fn prepare_docker_file_volume(
|
||||||
"copy cached model into docker volume",
|
"copy cached model into docker volume",
|
||||||
);
|
);
|
||||||
let _ = swactor_process::command_status(
|
let _ = swactor_process::command_status(
|
||||||
&mut Command::new("docker")
|
Command::new("docker")
|
||||||
.args(["rm", &loader_name])
|
.args(["rm", &loader_name])
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null()),
|
.stderr(Stdio::null()),
|
||||||
|
|
@ -966,7 +853,7 @@ fn safe_docker_volume_component(value: &str) -> String {
|
||||||
|
|
||||||
fn docker_status(args: &[&str], label: &str) -> Result<(), String> {
|
fn docker_status(args: &[&str], label: &str) -> Result<(), String> {
|
||||||
let status = swactor_process::command_status(
|
let status = swactor_process::command_status(
|
||||||
&mut Command::new("docker")
|
Command::new("docker")
|
||||||
.args(args)
|
.args(args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
|
|
@ -982,7 +869,7 @@ fn docker_status(args: &[&str], label: &str) -> Result<(), String> {
|
||||||
|
|
||||||
fn docker_status_vec(args: Vec<String>, label: &str) -> Result<(), String> {
|
fn docker_status_vec(args: Vec<String>, label: &str) -> Result<(), String> {
|
||||||
let status = swactor_process::command_status(
|
let status = swactor_process::command_status(
|
||||||
&mut Command::new("docker")
|
Command::new("docker")
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
|
|
@ -1012,11 +899,11 @@ fn stop_owned_process(runtime: &mut LocalProcessRuntime) -> Result<Option<i32>,
|
||||||
fn stop_adopted_process(record: &LocalProcessRecord) -> Result<(), String> {
|
fn stop_adopted_process(record: &LocalProcessRecord) -> Result<(), String> {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
return swactor_process::terminate_process_group(
|
swactor_process::terminate_process_group(
|
||||||
&record.identity(),
|
&record.identity(),
|
||||||
Duration::from_secs(2),
|
Duration::from_secs(2),
|
||||||
Duration::from_millis(50),
|
Duration::from_millis(50),
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -1414,14 +1301,6 @@ impl ProvisionPlugin for MockVastAiPlugin {
|
||||||
&provider_ref,
|
&provider_ref,
|
||||||
Some(offer_id),
|
Some(offer_id),
|
||||||
);
|
);
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
observe_mock_vastai_lifecycle(
|
|
||||||
mock_vastai_lifecycle_path(self),
|
|
||||||
"created",
|
|
||||||
&spec,
|
|
||||||
&provider_ref,
|
|
||||||
Some(offer_id),
|
|
||||||
)?;
|
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1450,14 +1329,6 @@ impl ProvisionPlugin for MockVastAiPlugin {
|
||||||
&contract.provider_ref,
|
&contract.provider_ref,
|
||||||
contract.selected_offer_id,
|
contract.selected_offer_id,
|
||||||
);
|
);
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
observe_mock_vastai_lifecycle(
|
|
||||||
mock_vastai_lifecycle_path(self),
|
|
||||||
"destroyed",
|
|
||||||
&contract.spec,
|
|
||||||
&contract.provider_ref,
|
|
||||||
contract.selected_offer_id,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -1468,21 +1339,6 @@ impl ProvisionPlugin for MockVastAiPlugin {
|
||||||
sink: PluginSink,
|
sink: PluginSink,
|
||||||
) -> Result<Option<AdoptedNode>, String> {
|
) -> Result<Option<AdoptedNode>, String> {
|
||||||
let adopted = self.inner.adopt_by_spec(spec, sink.clone())?;
|
let adopted = self.inner.adopt_by_spec(spec, sink.clone())?;
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
let adopted = {
|
|
||||||
let provider_ref = self.inner.provider_ref_for(spec);
|
|
||||||
if adopted.is_none()
|
|
||||||
&& mock_vastai_resource_is_live(
|
|
||||||
mock_vastai_lifecycle_path(self),
|
|
||||||
spec,
|
|
||||||
&provider_ref,
|
|
||||||
)?
|
|
||||||
{
|
|
||||||
self.inner.prepare_missing_bootstrap(spec, sink.clone())?
|
|
||||||
} else {
|
|
||||||
adopted
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(adopted) = &adopted {
|
if let Some(adopted) = &adopted {
|
||||||
let offer_id = selected_offer_from_spec(spec);
|
let offer_id = selected_offer_from_spec(spec);
|
||||||
if let Some(offer_id) = offer_id {
|
if let Some(offer_id) = offer_id {
|
||||||
|
|
@ -1504,14 +1360,6 @@ impl ProvisionPlugin for MockVastAiPlugin {
|
||||||
&adopted.provider_ref,
|
&adopted.provider_ref,
|
||||||
offer_id,
|
offer_id,
|
||||||
);
|
);
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
observe_mock_vastai_lifecycle(
|
|
||||||
mock_vastai_lifecycle_path(self),
|
|
||||||
"adopted",
|
|
||||||
spec,
|
|
||||||
&adopted.provider_ref,
|
|
||||||
offer_id,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
Ok(adopted)
|
Ok(adopted)
|
||||||
}
|
}
|
||||||
|
|
@ -1543,14 +1391,6 @@ impl ProvisionPlugin for MockVastAiPlugin {
|
||||||
&prepared.provider_ref,
|
&prepared.provider_ref,
|
||||||
offer_id,
|
offer_id,
|
||||||
);
|
);
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
observe_mock_vastai_lifecycle(
|
|
||||||
mock_vastai_lifecycle_path(self),
|
|
||||||
"recreated",
|
|
||||||
spec,
|
|
||||||
&prepared.provider_ref,
|
|
||||||
offer_id,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
Ok(prepared)
|
Ok(prepared)
|
||||||
}
|
}
|
||||||
|
|
@ -1588,14 +1428,6 @@ impl ProvisionPlugin for MockVastAiPlugin {
|
||||||
&provider_ref,
|
&provider_ref,
|
||||||
selected_offer_from_spec(spec),
|
selected_offer_from_spec(spec),
|
||||||
);
|
);
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
observe_mock_vastai_lifecycle(
|
|
||||||
mock_vastai_lifecycle_path(self),
|
|
||||||
"destroyed",
|
|
||||||
spec,
|
|
||||||
&provider_ref,
|
|
||||||
selected_offer_from_spec(spec),
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
Ok(stopped)
|
Ok(stopped)
|
||||||
}
|
}
|
||||||
|
|
@ -1879,7 +1711,7 @@ impl ProvisionPlugin for LocalDockerPlugin {
|
||||||
|
|
||||||
fn remove_docker_container(container_name: &str) -> Result<(), String> {
|
fn remove_docker_container(container_name: &str) -> Result<(), String> {
|
||||||
let status = swactor_process::command_status(
|
let status = swactor_process::command_status(
|
||||||
&mut Command::new("docker")
|
Command::new("docker")
|
||||||
.arg("rm")
|
.arg("rm")
|
||||||
.arg("-f")
|
.arg("-f")
|
||||||
.arg(container_name)
|
.arg(container_name)
|
||||||
|
|
@ -2274,78 +2106,6 @@ mod tests {
|
||||||
assert!(!process_record_matches(&record));
|
assert!(!process_record_matches(&record));
|
||||||
let _ = fs::remove_file(registry_path);
|
let _ = fs::remove_file(registry_path);
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[test]
|
|
||||||
fn mixed_mock_processes_survive_kill_provision_and_restart() {
|
|
||||||
let (_engine, runtime) = test_runtime();
|
|
||||||
let registry_path = std::env::temp_dir().join(format!(
|
|
||||||
"myelin-mixed-mock-test-{}-{}.json",
|
|
||||||
std::process::id(),
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
let (tx, _) = mpsc::channel();
|
|
||||||
let sink = PluginSink::new(Arc::new(ChannelSink(tx)));
|
|
||||||
let make_spec = |node_id: u64| {
|
|
||||||
let mut spec = test_spec();
|
|
||||||
spec.node_id = node_id;
|
|
||||||
spec.env
|
|
||||||
.push(("MYELIN_RUN_ID".to_owned(), spec.run_id.to_string()));
|
|
||||||
spec.env.push((
|
|
||||||
"MYELIN_LOGICAL_NODE_ID".to_owned(),
|
|
||||||
spec.node_id.to_string(),
|
|
||||||
));
|
|
||||||
spec.args = vec![
|
|
||||||
"-c".to_owned(),
|
|
||||||
"trap 'exit 0' TERM; while :; do sleep 1; done".to_owned(),
|
|
||||||
];
|
|
||||||
spec
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut plugin =
|
|
||||||
MockVastAiPlugin::with_registry("/bin/sh", ®istry_path, runtime.clone());
|
|
||||||
for node_id in 1..=3 {
|
|
||||||
let handle = plugin
|
|
||||||
.create_node_selected(make_spec(node_id), sink.clone(), Some(node_id))
|
|
||||||
.unwrap();
|
|
||||||
plugin.start_bootstrap(&handle).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut restarted =
|
|
||||||
MockVastAiPlugin::with_registry("/bin/sh", ®istry_path, runtime.clone());
|
|
||||||
let killed = restarted
|
|
||||||
.adopt_by_spec(&make_spec(2), sink.clone())
|
|
||||||
.unwrap()
|
|
||||||
.expect("node 2");
|
|
||||||
restarted.stop_node(&killed.handle).unwrap();
|
|
||||||
let new_node = restarted
|
|
||||||
.create_node_selected(make_spec(4), sink.clone(), Some(4))
|
|
||||||
.unwrap();
|
|
||||||
restarted.start_bootstrap(&new_node).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut final_restart = MockVastAiPlugin::with_registry("/bin/sh", ®istry_path, runtime);
|
|
||||||
assert_eq!(
|
|
||||||
final_restart.list_managed_refs().unwrap(),
|
|
||||||
[
|
|
||||||
"mock-vastai-5-1-attempt-11",
|
|
||||||
"mock-vastai-5-3-attempt-11",
|
|
||||||
"mock-vastai-5-4-attempt-11",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
for node_id in [1, 3, 4] {
|
|
||||||
let adopted = final_restart
|
|
||||||
.adopt_by_spec(&make_spec(node_id), sink.clone())
|
|
||||||
.unwrap()
|
|
||||||
.expect("surviving mock process");
|
|
||||||
final_restart.stop_node(&adopted.handle).unwrap();
|
|
||||||
}
|
|
||||||
let _ = fs::remove_file(registry_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_local_bootstrap_recreates_only_the_provider_handle() {
|
fn missing_local_bootstrap_recreates_only_the_provider_handle() {
|
||||||
|
|
|
||||||
|
|
@ -114,17 +114,17 @@ pub(crate) enum RunFaultReason {
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub(crate) enum LifecycleEvent {
|
pub(crate) enum LifecycleEvent {
|
||||||
RunFaulted {
|
Faulted {
|
||||||
run_id: RunId,
|
run_id: RunId,
|
||||||
reason: RunFaultReason,
|
reason: RunFaultReason,
|
||||||
},
|
},
|
||||||
RunCompleted {
|
Completed {
|
||||||
run_id: RunId,
|
run_id: RunId,
|
||||||
},
|
},
|
||||||
RunOperatorStopped {
|
OperatorStopped {
|
||||||
run_id: RunId,
|
run_id: RunId,
|
||||||
},
|
},
|
||||||
RunTornDown {
|
TornDown {
|
||||||
run_id: RunId,
|
run_id: RunId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -373,7 +373,7 @@ impl OrchestratorRun {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.terminal = true;
|
self.terminal = true;
|
||||||
self.events.push(LifecycleEvent::RunCompleted {
|
self.events.push(LifecycleEvent::Completed {
|
||||||
run_id: self.config.run_id,
|
run_id: self.config.run_id,
|
||||||
});
|
});
|
||||||
self.start_teardown();
|
self.start_teardown();
|
||||||
|
|
@ -383,7 +383,7 @@ impl OrchestratorRun {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.terminal = true;
|
self.terminal = true;
|
||||||
self.events.push(LifecycleEvent::RunOperatorStopped {
|
self.events.push(LifecycleEvent::OperatorStopped {
|
||||||
run_id: self.config.run_id,
|
run_id: self.config.run_id,
|
||||||
});
|
});
|
||||||
self.start_teardown();
|
self.start_teardown();
|
||||||
|
|
@ -394,7 +394,7 @@ impl OrchestratorRun {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.terminal = true;
|
self.terminal = true;
|
||||||
self.events.push(LifecycleEvent::RunFaulted {
|
self.events.push(LifecycleEvent::Faulted {
|
||||||
run_id: self.config.run_id,
|
run_id: self.config.run_id,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
|
|
@ -437,9 +437,9 @@ impl OrchestratorRun {
|
||||||
if !self
|
if !self
|
||||||
.events
|
.events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| matches!(event, LifecycleEvent::RunTornDown { .. }))
|
.any(|event| matches!(event, LifecycleEvent::TornDown { .. }))
|
||||||
{
|
{
|
||||||
self.events.push(LifecycleEvent::RunTornDown {
|
self.events.push(LifecycleEvent::TornDown {
|
||||||
run_id: self.config.run_id,
|
run_id: self.config.run_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ pub(crate) struct ProvisionStage {
|
||||||
pub(crate) enum StageEvent {
|
pub(crate) enum StageEvent {
|
||||||
ProvisionStage {
|
ProvisionStage {
|
||||||
from: NodeId,
|
from: NodeId,
|
||||||
provision: ProvisionStage,
|
provision: Box<ProvisionStage>,
|
||||||
},
|
},
|
||||||
WorkerReady,
|
WorkerReady,
|
||||||
WeightsReady,
|
WeightsReady,
|
||||||
|
|
@ -211,7 +211,7 @@ pub(crate) enum StageCommand {
|
||||||
LoadWeights {
|
LoadWeights {
|
||||||
source: WeightSource,
|
source: WeightSource,
|
||||||
range: LayerRange,
|
range: LayerRange,
|
||||||
shard_plan: Option<StageShardPlan>,
|
shard_plan: Box<Option<StageShardPlan>>,
|
||||||
},
|
},
|
||||||
ExecuteStep(ExecuteStep),
|
ExecuteStep(ExecuteStep),
|
||||||
ReleaseInputHandle {
|
ReleaseInputHandle {
|
||||||
|
|
@ -277,7 +277,7 @@ impl StageController {
|
||||||
|
|
||||||
pub(crate) fn observe(&mut self, event: StageEvent) {
|
pub(crate) fn observe(&mut self, event: StageEvent) {
|
||||||
match event {
|
match event {
|
||||||
StageEvent::ProvisionStage { from, provision } => self.provision(from, provision),
|
StageEvent::ProvisionStage { from, provision } => self.provision(from, *provision),
|
||||||
StageEvent::WorkerReady => self.worker_ready = true,
|
StageEvent::WorkerReady => self.worker_ready = true,
|
||||||
StageEvent::WeightsReady => self.weights_ready = true,
|
StageEvent::WeightsReady => self.weights_ready = true,
|
||||||
StageEvent::InboundEdgeReady { edge_id } => {
|
StageEvent::InboundEdgeReady { edge_id } => {
|
||||||
|
|
@ -353,7 +353,7 @@ impl StageController {
|
||||||
self.commands.push(StageCommand::LoadWeights {
|
self.commands.push(StageCommand::LoadWeights {
|
||||||
source: provision.weight_source.clone(),
|
source: provision.weight_source.clone(),
|
||||||
range: provision.layer_range,
|
range: provision.layer_range,
|
||||||
shard_plan: provision.shard_plan.clone(),
|
shard_plan: Box::new(provision.shard_plan.clone()),
|
||||||
});
|
});
|
||||||
self.provision = Some(provision);
|
self.provision = Some(provision);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,7 @@ where
|
||||||
"bytes_total":bytes_total,
|
"bytes_total":bytes_total,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let metadata_prefix = fetch_http_range(&url, 0, plan.metadata_end)?;
|
let metadata_prefix = fetch_http_range(url, 0, plan.metadata_end)?;
|
||||||
bytes_done = bytes_done.saturating_add(plan.metadata_end);
|
bytes_done = bytes_done.saturating_add(plan.metadata_end);
|
||||||
if plan.metadata_end > 0 {
|
if plan.metadata_end > 0 {
|
||||||
emit(serde_json::json!({
|
emit(serde_json::json!({
|
||||||
|
|
@ -348,7 +348,7 @@ where
|
||||||
"bytes_done":bytes_done,
|
"bytes_done":bytes_done,
|
||||||
"bytes_total":bytes_total,
|
"bytes_total":bytes_total,
|
||||||
}));
|
}));
|
||||||
let range_bytes = fetch_http_range(&url, range.start, range.len)?;
|
let range_bytes = fetch_http_range(url, range.start, range.len)?;
|
||||||
bytes_done = bytes_done.saturating_add(range.len);
|
bytes_done = bytes_done.saturating_add(range.len);
|
||||||
emit(serde_json::json!({
|
emit(serde_json::json!({
|
||||||
"type":"StageShardRangeFetchReady",
|
"type":"StageShardRangeFetchReady",
|
||||||
|
|
|
||||||
246
apps/myelin/src/tests/data_namespace_guarantees.rs
Normal file
246
apps/myelin/src/tests/data_namespace_guarantees.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId as ArenaNodeId};
|
||||||
|
use data_plane::blob;
|
||||||
|
use data_plane::blob_transfer::{BlobTransferReceiver, BlobTransferSender};
|
||||||
|
use data_plane::bootstrap::{self, BootstrapSpec};
|
||||||
|
use data_plane::data_plane::DataPlaneBootstrap;
|
||||||
|
use data_plane::host::{HostDataPlaneConfig, HostDataPlaneSessionActor};
|
||||||
|
use data_plane::namespace::{DirectoryClient, NamespaceError};
|
||||||
|
use data_plane::path::{DataPath, JobContext};
|
||||||
|
use data_plane::protocol::JobCapability;
|
||||||
|
use iroh_driver::{IrohBlobTransferReceiver, IrohBlobTransferSender, IrohDriver};
|
||||||
|
use swactor_engine::Engine;
|
||||||
|
|
||||||
|
use crate::data_namespace::{
|
||||||
|
DATA_DIRECTORY_SERVICE, DataNamespaceAuthority, InstalledNamespaceClient,
|
||||||
|
install_namespace_client,
|
||||||
|
};
|
||||||
|
use crate::orchestration::distribution_stack::DistributionRuntimeStack;
|
||||||
|
use crate::tests::harness::build_iroh_composition;
|
||||||
|
|
||||||
|
const POLL: Duration = Duration::from_millis(25);
|
||||||
|
const DEADLINE: Duration = Duration::from_secs(30);
|
||||||
|
const CAPABILITY: JobCapability = JobCapability::new([0x77; 32]);
|
||||||
|
|
||||||
|
struct DataNode {
|
||||||
|
namespace: InstalledNamespaceClient,
|
||||||
|
receiver: Arc<dyn BlobTransferReceiver>,
|
||||||
|
sender: Arc<dyn BlobTransferSender>,
|
||||||
|
stack: DistributionRuntimeStack,
|
||||||
|
driver: IrohDriver,
|
||||||
|
_engine: Engine,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataNode {
|
||||||
|
fn start() -> Self {
|
||||||
|
let (engine, driver, stack) = build_iroh_composition(POLL);
|
||||||
|
let namespace = install_namespace_client(&stack, &driver).expect("namespace client");
|
||||||
|
let receiver = Arc::new(IrohBlobTransferReceiver::new(
|
||||||
|
driver.endpoint_addr(),
|
||||||
|
driver.edge_events_handle(),
|
||||||
|
));
|
||||||
|
receiver.install_pump(&engine.handle(), stack.runtime.clone(), POLL);
|
||||||
|
let receiver: Arc<dyn BlobTransferReceiver> = receiver;
|
||||||
|
let sender: Arc<dyn BlobTransferSender> = Arc::new(IrohBlobTransferSender::new(
|
||||||
|
driver.edge_connector(),
|
||||||
|
&engine.handle(),
|
||||||
|
));
|
||||||
|
Self {
|
||||||
|
namespace,
|
||||||
|
receiver,
|
||||||
|
sender,
|
||||||
|
stack,
|
||||||
|
driver,
|
||||||
|
_engine: engine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(
|
||||||
|
&self,
|
||||||
|
generation: u64,
|
||||||
|
read_prefixes: Vec<DataPath>,
|
||||||
|
write_prefixes: Vec<DataPath>,
|
||||||
|
) -> DataPlaneBootstrap {
|
||||||
|
let mut arena = ArenaManager::boot(ArenaConfig {
|
||||||
|
node_id: ArenaNodeId(generation),
|
||||||
|
reservation_ceiling: 64 * 1024,
|
||||||
|
base_alignment: 64,
|
||||||
|
})
|
||||||
|
.expect("session arena");
|
||||||
|
let handoff = bootstrap::write_bootstrap(
|
||||||
|
&mut arena,
|
||||||
|
BootstrapSpec {
|
||||||
|
arena_generation: generation,
|
||||||
|
alignment: 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("session bootstrap");
|
||||||
|
let host = self
|
||||||
|
.stack
|
||||||
|
.runtime
|
||||||
|
.spawn(
|
||||||
|
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
||||||
|
runtime: self.stack.runtime.clone(),
|
||||||
|
arena,
|
||||||
|
arena_generation: generation,
|
||||||
|
session_generation: generation,
|
||||||
|
capability: CAPABILITY,
|
||||||
|
job_context: JobContext {
|
||||||
|
run_id: format!("run-{generation}"),
|
||||||
|
read_prefixes,
|
||||||
|
write_prefixes,
|
||||||
|
},
|
||||||
|
namespace: Some(self.namespace.client.clone()),
|
||||||
|
transfer_receiver: Some(Arc::clone(&self.receiver)),
|
||||||
|
source_sender: Some(Arc::clone(&self.sender)),
|
||||||
|
source_publisher: Some(Arc::clone(&self.namespace.source_publisher)),
|
||||||
|
route_registrar: None,
|
||||||
|
})
|
||||||
|
.expect("host session"),
|
||||||
|
)
|
||||||
|
.expect("spawn host session");
|
||||||
|
futures_lite::future::block_on(DataPlaneBootstrap::attach(
|
||||||
|
handoff.arena_fd,
|
||||||
|
self.stack.runtime.clone(),
|
||||||
|
host,
|
||||||
|
CAPABILITY,
|
||||||
|
))
|
||||||
|
.expect("attach child session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_and_session_publications_cross_real_iroh_and_outlive_the_producer() {
|
||||||
|
let state = tempfile::tempdir().expect("namespace state");
|
||||||
|
let file = state.path().join("registered.bin");
|
||||||
|
std::fs::write(&file, b"control-file-over-iroh").expect("registered file");
|
||||||
|
|
||||||
|
let node_a = DataNode::start();
|
||||||
|
let node_b = DataNode::start();
|
||||||
|
let authority = DataNamespaceAuthority::start(
|
||||||
|
&node_a.stack,
|
||||||
|
&node_a.driver,
|
||||||
|
state.path().join("namespace.json"),
|
||||||
|
)
|
||||||
|
.expect("namespace authority");
|
||||||
|
|
||||||
|
node_a
|
||||||
|
.driver
|
||||||
|
.join(std::slice::from_ref(&node_b.driver.endpoint_addr()));
|
||||||
|
assert!(wait_until(DEADLINE, || {
|
||||||
|
namespace_visible(&node_a.stack, authority.directory())
|
||||||
|
&& namespace_visible(&node_b.stack, authority.directory())
|
||||||
|
}));
|
||||||
|
|
||||||
|
let file_path = DataPath::parse("/shared/registered").unwrap();
|
||||||
|
futures_lite::future::block_on(
|
||||||
|
authority
|
||||||
|
.control()
|
||||||
|
.register(file_path.clone(), blob::file(&file)),
|
||||||
|
)
|
||||||
|
.expect("public file registration");
|
||||||
|
let directory = DirectoryClient::new(node_a.stack.runtime.clone(), authority.directory());
|
||||||
|
let file_source = futures_lite::future::block_on(directory.resolve(file_path.clone()))
|
||||||
|
.expect("registered binding")
|
||||||
|
.source;
|
||||||
|
assert!(wait_until(DEADLINE, || {
|
||||||
|
node_b.stack.route_owner(file_source).is_some()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let reader_b = node_b.session(1, vec![DataPath::parse("/shared").unwrap()], vec![]);
|
||||||
|
let registered = futures_lite::future::block_on(reader_b.data_plane.read_blob(&file_path))
|
||||||
|
.expect("remote registered-file read");
|
||||||
|
assert_eq!(
|
||||||
|
registered.map().unwrap().as_ref(),
|
||||||
|
b"control-file-over-iroh"
|
||||||
|
);
|
||||||
|
drop(registered);
|
||||||
|
|
||||||
|
let published_path = DataPath::parse("/shared/published").unwrap();
|
||||||
|
let producer = node_b.session(
|
||||||
|
2,
|
||||||
|
vec![DataPath::parse("/shared").unwrap()],
|
||||||
|
vec![DataPath::parse("/shared").unwrap()],
|
||||||
|
);
|
||||||
|
let mut writer = futures_lite::future::block_on(
|
||||||
|
producer
|
||||||
|
.data_plane
|
||||||
|
.write_blob(&published_path, b"session-over-iroh".len() as u64),
|
||||||
|
)
|
||||||
|
.expect("open publication");
|
||||||
|
writer
|
||||||
|
.map()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.copy_from_slice(b"session-over-iroh");
|
||||||
|
futures_lite::future::block_on(writer.seal()).expect("commit publication");
|
||||||
|
|
||||||
|
let published_source =
|
||||||
|
futures_lite::future::block_on(directory.resolve(published_path.clone()))
|
||||||
|
.expect("published binding")
|
||||||
|
.source;
|
||||||
|
assert!(wait_until(DEADLINE, || {
|
||||||
|
node_a.stack.route_owner(published_source).is_some()
|
||||||
|
}));
|
||||||
|
producer.data_plane.close().expect("close producer");
|
||||||
|
|
||||||
|
let reader_a = node_a.session(3, vec![DataPath::parse("/shared").unwrap()], vec![]);
|
||||||
|
let published = futures_lite::future::block_on(reader_a.data_plane.read_blob(&published_path))
|
||||||
|
.expect("read after producer close");
|
||||||
|
assert_eq!(published.map().unwrap().as_ref(), b"session-over-iroh");
|
||||||
|
drop(published);
|
||||||
|
|
||||||
|
futures_lite::future::block_on(authority.control().unregister(published_path.clone()))
|
||||||
|
.expect("unregister publication");
|
||||||
|
assert!(matches!(
|
||||||
|
futures_lite::future::block_on(directory.resolve(published_path)),
|
||||||
|
Err(NamespaceError::PathNotFound(_))
|
||||||
|
));
|
||||||
|
assert!(wait_until(DEADLINE, || {
|
||||||
|
node_b
|
||||||
|
.stack
|
||||||
|
.runtime
|
||||||
|
.stats()
|
||||||
|
.actors
|
||||||
|
.iter()
|
||||||
|
.all(|(actor, _)| *actor != published_source)
|
||||||
|
}));
|
||||||
|
|
||||||
|
reader_a.data_plane.close().expect("close reader A");
|
||||||
|
reader_b.data_plane.close().expect("close reader B");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn namespace_visible(
|
||||||
|
stack: &DistributionRuntimeStack,
|
||||||
|
directory: swactor::actor::ActorAddress,
|
||||||
|
) -> bool {
|
||||||
|
stack.route_owner(directory).is_some()
|
||||||
|
&& stack
|
||||||
|
.registry_view
|
||||||
|
.read()
|
||||||
|
.expect("registry view")
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| {
|
||||||
|
entry.name == DATA_DIRECTORY_SERVICE
|
||||||
|
&& entry.actor_addr == directory
|
||||||
|
&& !entry.tombstone
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until(deadline: Duration, mut check: impl FnMut() -> bool) -> bool {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
if check() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if started.elapsed() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,15 +84,15 @@ fn build_composition() -> (Engine, IrohDriver, DistributionRuntimeStack) {
|
||||||
engine.handle(),
|
engine.handle(),
|
||||||
);
|
);
|
||||||
|
|
||||||
driver.enable_actor_bridge(
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
stack.runtime.clone(),
|
runtime: stack.runtime.clone(),
|
||||||
stack.codec.clone(),
|
codec: stack.codec.clone(),
|
||||||
stack.actor_bridge_routes(),
|
routes: stack.actor_bridge_routes(),
|
||||||
stack.actors.swim,
|
swim: stack.actors.swim,
|
||||||
stack.relay_mirror.clone(),
|
relay_mirror: stack.relay_mirror.clone(),
|
||||||
stack.route_view.clone(),
|
route_view: stack.route_view.clone(),
|
||||||
stack.outbox.clone(),
|
outbox: stack.outbox.clone(),
|
||||||
);
|
});
|
||||||
// Engine-hosted protocol tick injection + adapter progression — no manual
|
// Engine-hosted protocol tick injection + adapter progression — no manual
|
||||||
// pump is wired anywhere.
|
// pump is wired anywhere.
|
||||||
stack.spawn_protocol_ticker(PROBE_TICK);
|
stack.spawn_protocol_ticker(PROBE_TICK);
|
||||||
|
|
@ -156,8 +156,10 @@ fn dashboard_server_is_scheduled_through_the_engine() {
|
||||||
.port();
|
.port();
|
||||||
|
|
||||||
let (engine, _driver, _stack) = build_composition();
|
let (engine, _driver, _stack) = build_composition();
|
||||||
let mut config = dashboard::DashboardConfig::default();
|
let config = dashboard::DashboardConfig {
|
||||||
config.port = free_port;
|
port: free_port,
|
||||||
|
..dashboard::DashboardConfig::default()
|
||||||
|
};
|
||||||
let handle = dashboard::DashboardHandle::new(config);
|
let handle = dashboard::DashboardHandle::new(config);
|
||||||
handle.spawn(&engine.handle());
|
handle.spawn(&engine.handle());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@
|
||||||
//! such as `fsm::RunPlan::test_linear(..)` and
|
//! such as `fsm::RunPlan::test_linear(..)` and
|
||||||
//! `stage::WeightSource::embedded_gguf(..)` work unchanged.
|
//! `stage::WeightSource::embedded_gguf(..)` work unchanged.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use distribution::node::DistributedNodeConfig;
|
||||||
|
use iroh::RelayMode;
|
||||||
|
use iroh_driver::{EDGE_ALPN, IrohDriver, IrohDriverConfig};
|
||||||
|
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
||||||
|
|
||||||
|
use crate::orchestration::distribution_stack::DistributionRuntimeStack;
|
||||||
use crate::run_fsm::{NodeId as FsmNodeId, OrchestratorRun, RunId as FsmRunId, RunPlan, StageRef};
|
use crate::run_fsm::{NodeId as FsmNodeId, OrchestratorRun, RunId as FsmRunId, RunPlan, StageRef};
|
||||||
use crate::run_plan::{GgufSource, TokenizerSource};
|
use crate::run_plan::{GgufSource, TokenizerSource};
|
||||||
use crate::staging::{DeviceHandle, StageController, WeightSource};
|
use crate::staging::{DeviceHandle, StageController, WeightSource};
|
||||||
|
|
@ -46,5 +54,48 @@ impl WeightSource {
|
||||||
/// Test alias for the orchestrator run core, retained for readable test prose.
|
/// Test alias for the orchestrator run core, retained for readable test prose.
|
||||||
pub(crate) type OrchestratorHarness = OrchestratorRun;
|
pub(crate) type OrchestratorHarness = OrchestratorRun;
|
||||||
|
|
||||||
|
pub(crate) fn build_iroh_composition(
|
||||||
|
poll: Duration,
|
||||||
|
) -> (Engine, IrohDriver, DistributionRuntimeStack) {
|
||||||
|
let (parts, runtime, codec, transport_router) =
|
||||||
|
DistributionRuntimeStack::build_runtime(crate::codecs::register_myelin_actor_codecs, None);
|
||||||
|
let engine = Engine::new(
|
||||||
|
parts,
|
||||||
|
TokioBackend::new(TokioConfig::default()).expect("tokio backend"),
|
||||||
|
)
|
||||||
|
.expect("engine");
|
||||||
|
let mut driver = IrohDriver::with_engine(
|
||||||
|
engine.handle(),
|
||||||
|
IrohDriverConfig {
|
||||||
|
secret_key: None,
|
||||||
|
relay_mode: RelayMode::Disabled,
|
||||||
|
node: DistributedNodeConfig::default(),
|
||||||
|
peer_auth: None,
|
||||||
|
additional_alpns: vec![EDGE_ALPN.to_vec()],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("iroh driver");
|
||||||
|
let stack = DistributionRuntimeStack::new_from_runtime(
|
||||||
|
runtime,
|
||||||
|
codec,
|
||||||
|
transport_router,
|
||||||
|
driver.node_id(),
|
||||||
|
DistributedNodeConfig::default(),
|
||||||
|
engine.handle(),
|
||||||
|
);
|
||||||
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
|
runtime: stack.runtime.clone(),
|
||||||
|
codec: stack.codec.clone(),
|
||||||
|
routes: stack.actor_bridge_routes(),
|
||||||
|
swim: stack.actors.swim,
|
||||||
|
relay_mirror: stack.relay_mirror.clone(),
|
||||||
|
route_view: stack.route_view.clone(),
|
||||||
|
outbox: stack.outbox.clone(),
|
||||||
|
});
|
||||||
|
stack.spawn_protocol_ticker(poll);
|
||||||
|
driver.install_actor_bridge_pump(poll);
|
||||||
|
(engine, driver, stack)
|
||||||
|
}
|
||||||
|
|
||||||
/// Test alias for the stage controller core, retained for readable test prose.
|
/// Test alias for the stage controller core, retained for readable test prose.
|
||||||
pub(crate) type StageControllerHarness = StageController;
|
pub(crate) type StageControllerHarness = StageController;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
#![cfg(target_os = "linux")]
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use data_plane::blob::ContentDigest;
|
|
||||||
use data_plane::bootstrap::{
|
use data_plane::bootstrap::{
|
||||||
ENV_ARENA_FD, ENV_DATA_PLANE_ACTOR, ENV_DATA_PLANE_ENDPOINT, ENV_JOB_CAPABILITY,
|
ENV_ARENA_FD, ENV_DATA_PLANE_ACTOR, ENV_DATA_PLANE_ENDPOINT, ENV_JOB_CAPABILITY,
|
||||||
};
|
};
|
||||||
use data_plane::host::BlobSource;
|
|
||||||
use data_plane::path::{DataPath, JobContext};
|
use data_plane::path::{DataPath, JobContext};
|
||||||
use data_plane::protocol::JobCapability;
|
use data_plane::protocol::JobCapability;
|
||||||
use swactor::config::RuntimeConfig;
|
use swactor::config::RuntimeConfig;
|
||||||
|
|
@ -24,24 +22,24 @@ fn path(value: &str) -> DataPath {
|
||||||
fn plane() -> ActorJobDataPlane {
|
fn plane() -> ActorJobDataPlane {
|
||||||
let parts = RuntimeParts::new(RuntimeConfig::default());
|
let parts = RuntimeParts::new(RuntimeConfig::default());
|
||||||
let runtime = parts.runtime().clone();
|
let runtime = parts.runtime().clone();
|
||||||
let mut blobs = BTreeMap::new();
|
|
||||||
blobs.insert(
|
|
||||||
path("/models/fixture"),
|
|
||||||
BlobSource::with_sha256(b"opaque fixture".as_slice()),
|
|
||||||
);
|
|
||||||
ActorJobDataPlane::new(
|
ActorJobDataPlane::new(
|
||||||
&runtime,
|
&runtime,
|
||||||
ARENA_BYTES,
|
crate::job_data_plane::ActorJobDataPlaneConfig {
|
||||||
11,
|
arena_bytes: ARENA_BYTES,
|
||||||
13,
|
arena_generation: 11,
|
||||||
CAPABILITY,
|
session_generation: 13,
|
||||||
JobContext {
|
capability: CAPABILITY,
|
||||||
|
job_context: JobContext {
|
||||||
run_id: "run-1".to_owned(),
|
run_id: "run-1".to_owned(),
|
||||||
read_prefixes: vec![path("/models")],
|
read_prefixes: vec![path("/models")],
|
||||||
write_prefixes: vec![path("/runs/run-1/results")],
|
write_prefixes: vec![path("/runs/run-1/results")],
|
||||||
},
|
},
|
||||||
blobs,
|
namespace: None,
|
||||||
None,
|
transfer_receiver: None,
|
||||||
|
source_sender: None,
|
||||||
|
source_publisher: None,
|
||||||
|
route_registrar: None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.expect("actor data-plane")
|
.expect("actor data-plane")
|
||||||
}
|
}
|
||||||
|
|
@ -71,19 +69,3 @@ fn handoff_contains_one_descriptor_and_private_actor_metadata() {
|
||||||
assert!(flags >= 0);
|
assert!(flags >= 0);
|
||||||
assert_eq!(flags & libc::FD_CLOEXEC, 0);
|
assert_eq!(flags & libc::FD_CLOEXEC, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fixture_source_metadata_is_application_opaque() {
|
|
||||||
let source = BlobSource::with_sha256(b"opaque fixture".as_slice());
|
|
||||||
assert_eq!(source.length(), 14);
|
|
||||||
assert_eq!(
|
|
||||||
source.digest(),
|
|
||||||
Some(ContentDigest::sha256(b"opaque fixture"))
|
|
||||||
);
|
|
||||||
|
|
||||||
let plane = plane();
|
|
||||||
assert_ne!(
|
|
||||||
plane.host_session(),
|
|
||||||
swactor::actor::ActorAddress::default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ use swactor::actor::Message;
|
||||||
use swactor::runtime::Inbox;
|
use swactor::runtime::Inbox;
|
||||||
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
||||||
use swactor_job_runner::{
|
use swactor_job_runner::{
|
||||||
register_job_codecs, Job, JobDone, JobState, NodeJobActor, OrchestratorJobActor,
|
Job, JobDone, JobState, NodeJobActor, OrchestratorJobActor, OrchestratorJobMsg, Workspace,
|
||||||
OrchestratorJobMsg, Workspace,
|
register_job_codecs,
|
||||||
};
|
};
|
||||||
|
|
||||||
use distribution::node::DistributedNodeConfig;
|
use distribution::node::DistributedNodeConfig;
|
||||||
|
|
@ -41,8 +41,11 @@ fn recv_within<T: Message>(inbox: &Inbox<T>, deadline: Duration) -> Option<T> {
|
||||||
|
|
||||||
fn build_composition() -> (Engine, IrohDriver, DistributionRuntimeStack) {
|
fn build_composition() -> (Engine, IrohDriver, DistributionRuntimeStack) {
|
||||||
let (parts, runtime, codec, transport_router) =
|
let (parts, runtime, codec, transport_router) =
|
||||||
DistributionRuntimeStack::build_runtime(|c| register_job_codecs(c), None);
|
DistributionRuntimeStack::build_runtime(register_job_codecs, None);
|
||||||
let engine = Engine::new(parts, TokioBackend::new(TokioConfig::default()).expect("tokio backend"))
|
let engine = Engine::new(
|
||||||
|
parts,
|
||||||
|
TokioBackend::new(TokioConfig::default()).expect("tokio backend"),
|
||||||
|
)
|
||||||
.expect("engine");
|
.expect("engine");
|
||||||
let mut driver = IrohDriver::with_engine(
|
let mut driver = IrohDriver::with_engine(
|
||||||
engine.handle(),
|
engine.handle(),
|
||||||
|
|
@ -63,15 +66,15 @@ fn build_composition() -> (Engine, IrohDriver, DistributionRuntimeStack) {
|
||||||
DistributedNodeConfig::default(),
|
DistributedNodeConfig::default(),
|
||||||
engine.handle(),
|
engine.handle(),
|
||||||
);
|
);
|
||||||
driver.enable_actor_bridge(
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
stack.runtime.clone(),
|
runtime: stack.runtime.clone(),
|
||||||
stack.codec.clone(),
|
codec: stack.codec.clone(),
|
||||||
stack.actor_bridge_routes(),
|
routes: stack.actor_bridge_routes(),
|
||||||
stack.actors.swim,
|
swim: stack.actors.swim,
|
||||||
stack.relay_mirror.clone(),
|
relay_mirror: stack.relay_mirror.clone(),
|
||||||
stack.route_view.clone(),
|
route_view: stack.route_view.clone(),
|
||||||
stack.outbox.clone(),
|
outbox: stack.outbox.clone(),
|
||||||
);
|
});
|
||||||
stack.spawn_protocol_ticker(POLL);
|
stack.spawn_protocol_ticker(POLL);
|
||||||
driver.install_actor_bridge_pump(POLL);
|
driver.install_actor_bridge_pump(POLL);
|
||||||
(engine, driver, stack)
|
(engine, driver, stack)
|
||||||
|
|
@ -90,17 +93,28 @@ fn job_runs_through_swactor_inside_myelin_composition() {
|
||||||
|
|
||||||
let done = runtime.new_inbox::<JobDone>().expect("done inbox");
|
let done = runtime.new_inbox::<JobDone>().expect("done inbox");
|
||||||
let orch = runtime
|
let orch = runtime
|
||||||
.spawn(OrchestratorJobActor::new(*done.addr(), landing.path().to_path_buf()))
|
.spawn(OrchestratorJobActor::new(
|
||||||
|
*done.addr(),
|
||||||
|
landing.path().to_path_buf(),
|
||||||
|
))
|
||||||
.expect("spawn orchestrator");
|
.expect("spawn orchestrator");
|
||||||
let node = runtime
|
let node = runtime
|
||||||
.spawn(NodeJobActor::new(orch, node_workdir.path().to_path_buf(), sender, 0))
|
.spawn(NodeJobActor::new(
|
||||||
|
orch,
|
||||||
|
node_workdir.path().to_path_buf(),
|
||||||
|
sender,
|
||||||
|
0,
|
||||||
|
))
|
||||||
.expect("spawn node");
|
.expect("spawn node");
|
||||||
|
|
||||||
let job = Job {
|
let job = Job {
|
||||||
name: "myelin-probe".to_owned(),
|
name: "myelin-probe".to_owned(),
|
||||||
setup: Some("echo setup-ok > setup_done.txt".to_owned()),
|
setup: Some("echo setup-ok > setup_done.txt".to_owned()),
|
||||||
run: "echo hello-from-myelin-swactor > greeting.txt".to_owned(),
|
run: "echo hello-from-myelin-swactor > greeting.txt".to_owned(),
|
||||||
workspace: Some(Workspace { workdir: ws.path().to_path_buf(), exclude: vec![] }),
|
workspace: Some(Workspace {
|
||||||
|
workdir: ws.path().to_path_buf(),
|
||||||
|
exclude: vec![],
|
||||||
|
}),
|
||||||
outputs: vec![
|
outputs: vec![
|
||||||
"greeting.txt".to_owned(),
|
"greeting.txt".to_owned(),
|
||||||
"setup_done.txt".to_owned(),
|
"setup_done.txt".to_owned(),
|
||||||
|
|
@ -109,18 +123,36 @@ fn job_runs_through_swactor_inside_myelin_composition() {
|
||||||
env: BTreeMap::new(),
|
env: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
runtime
|
runtime
|
||||||
.send_to(orch, OrchestratorJobMsg::Submit { job, node_actor: node })
|
.send_to(
|
||||||
|
orch,
|
||||||
|
OrchestratorJobMsg::Submit {
|
||||||
|
job,
|
||||||
|
node_actor: node,
|
||||||
|
},
|
||||||
|
)
|
||||||
.expect("submit");
|
.expect("submit");
|
||||||
|
|
||||||
let result = recv_within(&done, DEADLINE);
|
let result = recv_within(&done, DEADLINE);
|
||||||
drop(engine);
|
drop(engine);
|
||||||
let done = result.expect("job did not reach a terminal state");
|
let done = result.expect("job did not reach a terminal state");
|
||||||
assert_eq!(done.state, JobState::Completed, "expected COMPLETED, got {:?}", done);
|
assert_eq!(
|
||||||
|
done.state,
|
||||||
|
JobState::Completed,
|
||||||
|
"expected COMPLETED, got {:?}",
|
||||||
|
done
|
||||||
|
);
|
||||||
assert_eq!(done.exit_code, Some(0));
|
assert_eq!(done.exit_code, Some(0));
|
||||||
|
|
||||||
let greeting = std::fs::read_to_string(landing.path().join("greeting.txt"))
|
let greeting = std::fs::read_to_string(landing.path().join("greeting.txt"))
|
||||||
.expect("collected greeting.txt");
|
.expect("collected greeting.txt");
|
||||||
assert!(greeting.contains("hello-from-myelin-swactor"), "greeting: {greeting}");
|
assert!(
|
||||||
let seed = std::fs::read_to_string(landing.path().join("seed.txt")).expect("collected seed.txt");
|
greeting.contains("hello-from-myelin-swactor"),
|
||||||
assert_eq!(seed, "seed-value", "workspace materialized + collected through swactor");
|
"greeting: {greeting}"
|
||||||
|
);
|
||||||
|
let seed =
|
||||||
|
std::fs::read_to_string(landing.path().join("seed.txt")).expect("collected seed.txt");
|
||||||
|
assert_eq!(
|
||||||
|
seed, "seed-value",
|
||||||
|
"workspace materialized + collected through swactor"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,63 +9,20 @@
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
|
||||||
use swactor_job_runner::{
|
use swactor_job_runner::{
|
||||||
Job, JobDone, JobState, NodeJobActor, OrchestratorJobActor, OrchestratorJobMsg, Workspace,
|
Job, JobDone, JobState, NodeJobActor, OrchestratorJobActor, OrchestratorJobMsg, Workspace,
|
||||||
register_job_codecs,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use distribution::node::DistributedNodeConfig;
|
use iroh_driver::IrohDriver;
|
||||||
use iroh::RelayMode;
|
|
||||||
use iroh_driver::{IrohDriver, IrohDriverConfig};
|
|
||||||
|
|
||||||
use crate::orchestration::distribution_stack::DistributionRuntimeStack;
|
use crate::orchestration::distribution_stack::DistributionRuntimeStack;
|
||||||
|
|
||||||
|
use crate::tests::harness::build_iroh_composition;
|
||||||
|
|
||||||
const POLL: Duration = Duration::from_millis(25);
|
const POLL: Duration = Duration::from_millis(25);
|
||||||
const CONVERGE_DEADLINE: Duration = Duration::from_secs(30);
|
const CONVERGE_DEADLINE: Duration = Duration::from_secs(30);
|
||||||
const JOB_DEADLINE: Duration = Duration::from_secs(30);
|
const JOB_DEADLINE: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
fn build_composition() -> (Engine, IrohDriver, DistributionRuntimeStack) {
|
|
||||||
let (parts, runtime, codec, transport_router) =
|
|
||||||
DistributionRuntimeStack::build_runtime(|c| register_job_codecs(c), None);
|
|
||||||
let engine = Engine::new(
|
|
||||||
parts,
|
|
||||||
TokioBackend::new(TokioConfig::default()).expect("tokio backend"),
|
|
||||||
)
|
|
||||||
.expect("engine");
|
|
||||||
let mut driver = IrohDriver::with_engine(
|
|
||||||
engine.handle(),
|
|
||||||
IrohDriverConfig {
|
|
||||||
secret_key: None,
|
|
||||||
relay_mode: RelayMode::Disabled,
|
|
||||||
node: DistributedNodeConfig::default(),
|
|
||||||
peer_auth: None,
|
|
||||||
additional_alpns: vec![],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("iroh driver");
|
|
||||||
let stack = DistributionRuntimeStack::new_from_runtime(
|
|
||||||
runtime.clone(),
|
|
||||||
codec,
|
|
||||||
transport_router,
|
|
||||||
driver.node_id(),
|
|
||||||
DistributedNodeConfig::default(),
|
|
||||||
engine.handle(),
|
|
||||||
);
|
|
||||||
driver.enable_actor_bridge(
|
|
||||||
stack.runtime.clone(),
|
|
||||||
stack.codec.clone(),
|
|
||||||
stack.actor_bridge_routes(),
|
|
||||||
stack.actors.swim,
|
|
||||||
stack.relay_mirror.clone(),
|
|
||||||
stack.route_view.clone(),
|
|
||||||
stack.outbox.clone(),
|
|
||||||
);
|
|
||||||
stack.spawn_protocol_ticker(POLL);
|
|
||||||
driver.install_actor_bridge_pump(POLL);
|
|
||||||
(engine, driver, stack)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn job_runs_across_two_nodes_over_real_iroh() {
|
fn job_runs_across_two_nodes_over_real_iroh() {
|
||||||
let node_workdir = tempfile::tempdir().expect("node workdir");
|
let node_workdir = tempfile::tempdir().expect("node workdir");
|
||||||
|
|
@ -75,13 +32,13 @@ fn job_runs_across_two_nodes_over_real_iroh() {
|
||||||
|
|
||||||
// Worker composition (B): spawn + register the NodeJobActor so peers can
|
// Worker composition (B): spawn + register the NodeJobActor so peers can
|
||||||
// route to it through the converged directory.
|
// route to it through the converged directory.
|
||||||
let (engine_b, driver_b, stack_b) = build_composition();
|
let (engine_b, driver_b, stack_b) = build_iroh_composition(POLL);
|
||||||
let sender_b = stack_b.runtime.create_sender();
|
let sender_b = stack_b.runtime.create_sender();
|
||||||
// Placeholder orchestrator address: the real one is on A; the node only
|
// Placeholder orchestrator address: the real one is on A; the node only
|
||||||
// needs it once the orchestrator submits. We point the node at A's
|
// needs it once the orchestrator submits. We point the node at A's
|
||||||
// orchestrator after it exists (address is fixed below), but the node actor
|
// orchestrator after it exists (address is fixed below), but the node actor
|
||||||
// captures the address at construction — so spawn it after A's orchestrator.
|
// captures the address at construction — so spawn it after A's orchestrator.
|
||||||
let (_engine_a, _driver_a, stack_a) = build_composition();
|
let (_engine_a, _driver_a, stack_a) = build_iroh_composition(POLL);
|
||||||
|
|
||||||
let done = stack_a
|
let done = stack_a
|
||||||
.runtime
|
.runtime
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
mod data_namespace_guarantees;
|
||||||
mod engine_composition;
|
mod engine_composition;
|
||||||
pub(crate) mod fuzz_support;
|
pub(crate) mod fuzz_support;
|
||||||
mod harness;
|
mod harness;
|
||||||
mod job_data_plane_guarantees;
|
mod job_data_plane_guarantees;
|
||||||
|
mod job_runner_integration;
|
||||||
mod job_runner_iroh;
|
mod job_runner_iroh;
|
||||||
mod node_guarantees;
|
mod node_guarantees;
|
||||||
mod observability_guarantees;
|
mod observability_guarantees;
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ mod run_fsm {
|
||||||
invalid
|
invalid
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| { matches!(event, fsm::LifecycleEvent::RunFaulted { .. }) })
|
.any(|event| { matches!(event, fsm::LifecycleEvent::Faulted { .. }) })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +249,7 @@ mod run_fsm {
|
||||||
fsm::RunCommand::InjectTokenObject { object, .. } => Some(object),
|
fsm::RunCommand::InjectTokenObject { object, .. } => Some(object),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.last()
|
.next_back()
|
||||||
.expect("decode injection must be recorded");
|
.expect("decode injection must be recorded");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
*decode_object,
|
*decode_object,
|
||||||
|
|
@ -315,7 +315,7 @@ mod run_fsm {
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|event| match event {
|
.filter_map(|event| match event {
|
||||||
fsm::LifecycleEvent::RunFaulted { reason, .. } => Some(reason),
|
fsm::LifecycleEvent::Faulted { reason, .. } => Some(reason),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
@ -344,7 +344,7 @@ mod run_fsm {
|
||||||
assert!(membership_lost.events().iter().any(|event| {
|
assert!(membership_lost.events().iter().any(|event| {
|
||||||
matches!(
|
matches!(
|
||||||
event,
|
event,
|
||||||
fsm::LifecycleEvent::RunFaulted {
|
fsm::LifecycleEvent::Faulted {
|
||||||
reason: fsm::RunFaultReason::MembershipLost {
|
reason: fsm::RunFaultReason::MembershipLost {
|
||||||
node_id: fsm::NodeId(12)
|
node_id: fsm::NodeId(12)
|
||||||
},
|
},
|
||||||
|
|
@ -380,12 +380,12 @@ mod run_fsm {
|
||||||
let completed = harness
|
let completed = harness
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| matches!(event, fsm::LifecycleEvent::RunCompleted { .. }))
|
.filter(|event| matches!(event, fsm::LifecycleEvent::Completed { .. }))
|
||||||
.count();
|
.count();
|
||||||
let faulted = harness
|
let faulted = harness
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| matches!(event, fsm::LifecycleEvent::RunFaulted { .. }))
|
.filter(|event| matches!(event, fsm::LifecycleEvent::Faulted { .. }))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(completed, 1);
|
assert_eq!(completed, 1);
|
||||||
assert_eq!(faulted, 0);
|
assert_eq!(faulted, 0);
|
||||||
|
|
@ -432,12 +432,12 @@ mod run_fsm {
|
||||||
let stopped_count = stopped
|
let stopped_count = stopped
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| matches!(event, fsm::LifecycleEvent::RunOperatorStopped { .. }))
|
.filter(|event| matches!(event, fsm::LifecycleEvent::OperatorStopped { .. }))
|
||||||
.count();
|
.count();
|
||||||
let stopped_faults = stopped
|
let stopped_faults = stopped
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| matches!(event, fsm::LifecycleEvent::RunFaulted { .. }))
|
.filter(|event| matches!(event, fsm::LifecycleEvent::Faulted { .. }))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(stopped_count, 1);
|
assert_eq!(stopped_count, 1);
|
||||||
assert_eq!(stopped_faults, 0);
|
assert_eq!(stopped_faults, 0);
|
||||||
|
|
@ -475,7 +475,7 @@ mod run_fsm {
|
||||||
!harness
|
!harness
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| { matches!(event, fsm::LifecycleEvent::RunTornDown { .. }) })
|
.any(|event| { matches!(event, fsm::LifecycleEvent::TornDown { .. }) })
|
||||||
);
|
);
|
||||||
|
|
||||||
// StageStopped for every stage still is not enough until local endpoints stop.
|
// StageStopped for every stage still is not enough until local endpoints stop.
|
||||||
|
|
@ -491,7 +491,7 @@ mod run_fsm {
|
||||||
!harness
|
!harness
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| { matches!(event, fsm::LifecycleEvent::RunTornDown { .. }) })
|
.any(|event| { matches!(event, fsm::LifecycleEvent::TornDown { .. }) })
|
||||||
);
|
);
|
||||||
harness.observe(fsm::RunEvent::TokenEndpointsStopped);
|
harness.observe(fsm::RunEvent::TokenEndpointsStopped);
|
||||||
|
|
||||||
|
|
@ -499,14 +499,14 @@ mod run_fsm {
|
||||||
let torn_down_count = harness
|
let torn_down_count = harness
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| matches!(event, fsm::LifecycleEvent::RunTornDown { .. }))
|
.filter(|event| matches!(event, fsm::LifecycleEvent::TornDown { .. }))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(torn_down_count, 1);
|
assert_eq!(torn_down_count, 1);
|
||||||
|
|
||||||
// Ordering is proven over the lifecycle transcript.
|
// Ordering is proven over the lifecycle transcript.
|
||||||
let fault_pos = position_of(
|
let fault_pos = position_of(
|
||||||
harness.events(),
|
harness.events(),
|
||||||
&fsm::LifecycleEvent::RunFaulted {
|
&fsm::LifecycleEvent::Faulted {
|
||||||
run_id: fsm::RunId(7),
|
run_id: fsm::RunId(7),
|
||||||
reason: fsm::RunFaultReason::StageFault {
|
reason: fsm::RunFaultReason::StageFault {
|
||||||
stage_index: 0,
|
stage_index: 0,
|
||||||
|
|
@ -516,7 +516,7 @@ mod run_fsm {
|
||||||
);
|
);
|
||||||
let torn_down_pos = position_of(
|
let torn_down_pos = position_of(
|
||||||
harness.events(),
|
harness.events(),
|
||||||
&fsm::LifecycleEvent::RunTornDown {
|
&fsm::LifecycleEvent::TornDown {
|
||||||
run_id: fsm::RunId(7),
|
run_id: fsm::RunId(7),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ mod stage_controller {
|
||||||
let mut harness = new_controller();
|
let mut harness = new_controller();
|
||||||
harness.observe(stage::StageEvent::ProvisionStage {
|
harness.observe(stage::StageEvent::ProvisionStage {
|
||||||
from: stage::NodeId(99),
|
from: stage::NodeId(99),
|
||||||
provision: valid_provision(),
|
provision: Box::new(valid_provision()),
|
||||||
});
|
});
|
||||||
for event in preparation_ready_events() {
|
for event in preparation_ready_events() {
|
||||||
harness.observe(event);
|
harness.observe(event);
|
||||||
|
|
@ -81,7 +81,7 @@ mod stage_controller {
|
||||||
let mut harness = new_controller();
|
let mut harness = new_controller();
|
||||||
harness.observe(stage::StageEvent::ProvisionStage {
|
harness.observe(stage::StageEvent::ProvisionStage {
|
||||||
from: stage::NodeId(99),
|
from: stage::NodeId(99),
|
||||||
provision: valid_provision(),
|
provision: Box::new(valid_provision()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup commands should be derived from the provided assignment.
|
// Setup commands should be derived from the provided assignment.
|
||||||
|
|
@ -108,7 +108,7 @@ mod stage_controller {
|
||||||
let mut unauthorized = new_controller();
|
let mut unauthorized = new_controller();
|
||||||
unauthorized.observe(stage::StageEvent::ProvisionStage {
|
unauthorized.observe(stage::StageEvent::ProvisionStage {
|
||||||
from: stage::NodeId(123),
|
from: stage::NodeId(123),
|
||||||
provision: valid_provision(),
|
provision: Box::new(valid_provision()),
|
||||||
});
|
});
|
||||||
assert!(unauthorized.events().iter().any(|event| {
|
assert!(unauthorized.events().iter().any(|event| {
|
||||||
matches!(
|
matches!(
|
||||||
|
|
@ -134,7 +134,7 @@ mod stage_controller {
|
||||||
let mut harness = new_controller();
|
let mut harness = new_controller();
|
||||||
harness.observe(stage::StageEvent::ProvisionStage {
|
harness.observe(stage::StageEvent::ProvisionStage {
|
||||||
from: stage::NodeId(99),
|
from: stage::NodeId(99),
|
||||||
provision: valid_provision(),
|
provision: Box::new(valid_provision()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Feed every readiness event except the final one and prove no prefix is
|
// Feed every readiness event except the final one and prove no prefix is
|
||||||
|
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Tinygrad-backed device bridge probe for Rust Myelin bridge tests.
|
|
||||||
|
|
||||||
Line-delimited JSON control only. Payload bytes live in the arena file whose
|
|
||||||
path Rust passes during initialize.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
try:
|
|
||||||
from tinygrad import Tensor # type: ignore
|
|
||||||
except Exception as exc: # pragma: no cover - exercised from Rust process tests
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "worker_fatal",
|
|
||||||
"reason": "tinygrad_unavailable",
|
|
||||||
"message": str(exc),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
raise SystemExit(2)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeviceObject:
|
|
||||||
dtype: str
|
|
||||||
shape: str
|
|
||||||
extent: int
|
|
||||||
values: list[int]
|
|
||||||
tensor: Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PendingCopy:
|
|
||||||
kind: str
|
|
||||||
copy_id: int
|
|
||||||
handle_id: int
|
|
||||||
host_offset: int
|
|
||||||
device_offset: int
|
|
||||||
length: int
|
|
||||||
|
|
||||||
|
|
||||||
arena_path: str | None = None
|
|
||||||
arena_bytes = 0
|
|
||||||
generation = 0
|
|
||||||
objects: dict[int, DeviceObject] = {}
|
|
||||||
pending_copies: dict[int, PendingCopy] = {}
|
|
||||||
fail_next: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def emit(obj: dict[str, Any]) -> None:
|
|
||||||
print(json.dumps(obj, separators=(",", ":")), flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def backend_error(reason: str) -> None:
|
|
||||||
emit({"type": "backend_error", "reason": reason})
|
|
||||||
|
|
||||||
|
|
||||||
def fatal(reason: str, message: str) -> None:
|
|
||||||
emit({"type": "worker_fatal", "reason": reason, "message": message})
|
|
||||||
|
|
||||||
|
|
||||||
def require_arena() -> str:
|
|
||||||
if arena_path is None:
|
|
||||||
raise RuntimeError("arena is not initialized")
|
|
||||||
return arena_path
|
|
||||||
|
|
||||||
|
|
||||||
def consume_failure(reason: str) -> bool:
|
|
||||||
global fail_next
|
|
||||||
if fail_next == reason:
|
|
||||||
fail_next = None
|
|
||||||
backend_error(reason)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_u32_range(offset: int, length: int) -> None:
|
|
||||||
if offset < 0 or length < 0 or offset % 4 != 0 or length % 4 != 0:
|
|
||||||
raise ValueError("u32 ranges must be non-negative and 4-byte aligned")
|
|
||||||
|
|
||||||
|
|
||||||
def tensor_from_values(values: list[int]) -> Any:
|
|
||||||
try:
|
|
||||||
return Tensor(values, dtype="uint32").realize()
|
|
||||||
except Exception:
|
|
||||||
return Tensor(values, dtype="int32").realize()
|
|
||||||
|
|
||||||
|
|
||||||
def allocate_tensor(dtype: str, extent: int) -> tuple[list[int], Any]:
|
|
||||||
if dtype == "u32":
|
|
||||||
if extent % 4 != 0:
|
|
||||||
raise ValueError("u32 extent must be 4-byte aligned")
|
|
||||||
values = [0] * (extent // 4)
|
|
||||||
return values, tensor_from_values(values)
|
|
||||||
if dtype == "f16":
|
|
||||||
values = [0] * (extent // 2)
|
|
||||||
return values, Tensor(values, dtype="float16").realize()
|
|
||||||
raise ValueError(f"unsupported dtype {dtype!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def update_tensor(obj: DeviceObject) -> None:
|
|
||||||
if obj.dtype == "u32":
|
|
||||||
obj.tensor = tensor_from_values(obj.values)
|
|
||||||
elif obj.dtype == "f16":
|
|
||||||
obj.tensor = Tensor(obj.values, dtype="float16").realize()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"unsupported dtype {obj.dtype!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def materialized_values(obj: DeviceObject) -> list[int]:
|
|
||||||
return [int(v) for v in obj.tensor.tolist()]
|
|
||||||
|
|
||||||
|
|
||||||
def read_arena(offset: int, length: int) -> bytes:
|
|
||||||
path = require_arena()
|
|
||||||
with open(path, "rb", buffering=0) as f:
|
|
||||||
f.seek(offset)
|
|
||||||
data = f.read(length)
|
|
||||||
if len(data) != length:
|
|
||||||
raise EOFError("short arena read")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def write_arena(offset: int, data: bytes) -> None:
|
|
||||||
path = require_arena()
|
|
||||||
with open(path, "r+b", buffering=0) as f:
|
|
||||||
f.seek(offset)
|
|
||||||
f.write(data)
|
|
||||||
f.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def perform_host_to_device(copy: PendingCopy) -> None:
|
|
||||||
obj = objects[copy.handle_id]
|
|
||||||
if obj.dtype != "u32":
|
|
||||||
raise ValueError("payload copy is implemented for u32 test objects only")
|
|
||||||
check_u32_range(copy.device_offset, copy.length)
|
|
||||||
payload = read_arena(copy.host_offset, copy.length)
|
|
||||||
words = list(struct.unpack("<" + "I" * (copy.length // 4), payload))
|
|
||||||
start = copy.device_offset // 4
|
|
||||||
end = start + len(words)
|
|
||||||
if end > len(obj.values):
|
|
||||||
raise ValueError("device range out of bounds")
|
|
||||||
obj.values[start:end] = words
|
|
||||||
update_tensor(obj)
|
|
||||||
|
|
||||||
|
|
||||||
def perform_device_to_host(copy: PendingCopy) -> None:
|
|
||||||
obj = objects[copy.handle_id]
|
|
||||||
if obj.dtype != "u32":
|
|
||||||
raise ValueError("payload copy is implemented for u32 test objects only")
|
|
||||||
check_u32_range(copy.device_offset, copy.length)
|
|
||||||
values = materialized_values(obj)
|
|
||||||
start = copy.device_offset // 4
|
|
||||||
end = start + (copy.length // 4)
|
|
||||||
if end > len(values):
|
|
||||||
raise ValueError("device range out of bounds")
|
|
||||||
payload = struct.pack("<" + "I" * (end - start), *values[start:end])
|
|
||||||
write_arena(copy.host_offset, payload)
|
|
||||||
|
|
||||||
|
|
||||||
def perform_copy(copy: PendingCopy) -> None:
|
|
||||||
if copy.kind == "host_to_device":
|
|
||||||
perform_host_to_device(copy)
|
|
||||||
elif copy.kind == "device_to_host":
|
|
||||||
perform_device_to_host(copy)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"unknown copy kind {copy.kind!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def handle(req: dict[str, Any]) -> bool:
|
|
||||||
global arena_path, arena_bytes, generation, fail_next
|
|
||||||
|
|
||||||
typ = req.get("type")
|
|
||||||
if typ == "initialize":
|
|
||||||
arena_path = str(req["arena_path"])
|
|
||||||
arena_bytes = int(req["arena_bytes"])
|
|
||||||
generation = int(req["generation"])
|
|
||||||
with open(arena_path, "r+b", buffering=0) as f:
|
|
||||||
f.truncate(arena_bytes)
|
|
||||||
emit({"type": "worker_ready", "generation": generation})
|
|
||||||
return True
|
|
||||||
|
|
||||||
if typ == "alloc":
|
|
||||||
if consume_failure("allocation_failed"):
|
|
||||||
return True
|
|
||||||
handle_id = int(req["handle_id"])
|
|
||||||
dtype = str(req["dtype"])
|
|
||||||
shape = str(req["shape"])
|
|
||||||
extent = int(req["extent"])
|
|
||||||
values, tensor = allocate_tensor(dtype, extent)
|
|
||||||
objects[handle_id] = DeviceObject(dtype=dtype, shape=shape, extent=extent, values=values, tensor=tensor)
|
|
||||||
emit({"type": "allocated", "handle_id": handle_id})
|
|
||||||
return True
|
|
||||||
|
|
||||||
if typ in ("host_to_device", "device_to_host"):
|
|
||||||
if consume_failure("copy_failed"):
|
|
||||||
return True
|
|
||||||
copy = PendingCopy(
|
|
||||||
kind=typ,
|
|
||||||
copy_id=int(req["copy_id"]),
|
|
||||||
handle_id=int(req["handle_id"]),
|
|
||||||
host_offset=int(req["host_offset"]),
|
|
||||||
device_offset=int(req["device_offset"]),
|
|
||||||
length=int(req["len"]),
|
|
||||||
)
|
|
||||||
if copy.handle_id not in objects:
|
|
||||||
backend_error("copy_failed")
|
|
||||||
return True
|
|
||||||
if bool(req.get("defer", False)):
|
|
||||||
pending_copies[copy.copy_id] = copy
|
|
||||||
emit({"type": "copy_started", "copy_id": copy.copy_id})
|
|
||||||
else:
|
|
||||||
perform_copy(copy)
|
|
||||||
emit({"type": "copy_completed", "copy_id": copy.copy_id})
|
|
||||||
return True
|
|
||||||
|
|
||||||
if typ == "complete_copy":
|
|
||||||
copy_id = int(req["copy_id"])
|
|
||||||
copy = pending_copies.pop(copy_id, None)
|
|
||||||
if copy is None:
|
|
||||||
backend_error("copy_failed")
|
|
||||||
return True
|
|
||||||
perform_copy(copy)
|
|
||||||
emit({"type": "copy_completed", "copy_id": copy_id})
|
|
||||||
return True
|
|
||||||
|
|
||||||
if typ == "wrap_for_tinygrad":
|
|
||||||
if consume_failure("invalid_view"):
|
|
||||||
return True
|
|
||||||
handle_id = int(req["handle_id"])
|
|
||||||
obj = objects.get(handle_id)
|
|
||||||
if obj is None:
|
|
||||||
backend_error("invalid_view")
|
|
||||||
return True
|
|
||||||
dtype = str(req["dtype"])
|
|
||||||
shape = str(req["shape"])
|
|
||||||
if obj.dtype != dtype or obj.shape != shape:
|
|
||||||
backend_error("invalid_view")
|
|
||||||
return True
|
|
||||||
# Force materialization at view time so success depends on live tensor state.
|
|
||||||
_ = obj.tensor.tolist()
|
|
||||||
emit({"type": "view", "handle_id": handle_id, "dtype": dtype, "shape": shape})
|
|
||||||
return True
|
|
||||||
|
|
||||||
if typ == "free":
|
|
||||||
handle_id = int(req["handle_id"])
|
|
||||||
if handle_id not in objects:
|
|
||||||
backend_error("invalid_view")
|
|
||||||
return True
|
|
||||||
del objects[handle_id]
|
|
||||||
emit({"type": "freed", "handle_id": handle_id})
|
|
||||||
return True
|
|
||||||
|
|
||||||
if typ == "restart":
|
|
||||||
generation = int(req["generation"])
|
|
||||||
objects.clear()
|
|
||||||
pending_copies.clear()
|
|
||||||
fail_next = None
|
|
||||||
emit({"type": "worker_ready", "generation": generation})
|
|
||||||
return True
|
|
||||||
|
|
||||||
if typ == "fail_next":
|
|
||||||
fail_next = str(req["failure"])
|
|
||||||
emit({"type": "ok"})
|
|
||||||
return True
|
|
||||||
|
|
||||||
if typ == "shutdown":
|
|
||||||
emit({"type": "worker_stopped"})
|
|
||||||
return False
|
|
||||||
|
|
||||||
fatal("protocol_error", f"unknown command type {typ!r}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
for line in sys.stdin:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
req = json.loads(line)
|
|
||||||
if not isinstance(req, dict):
|
|
||||||
raise ValueError("request must be an object")
|
|
||||||
if not handle(req):
|
|
||||||
return 0
|
|
||||||
except SystemExit:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
fatal("backend_exception", str(exc))
|
|
||||||
return 2
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -23,3 +23,6 @@ serde_json = "1"
|
||||||
pyo3 = { version = "0.23", features = ["extension-module"] }
|
pyo3 = { version = "0.23", features = ["extension-module"] }
|
||||||
pyo3-async-runtimes = { version = "0.23", features = ["tokio-runtime"] }
|
pyo3-async-runtimes = { version = "0.23", features = ["tokio-runtime"] }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ version = "0.1.0"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["jupyter", "ipykernel", "pytest"]
|
dev = ["jupyter", "ipykernel", "maturin>=1.7,<2", "pytest", "pytest-timeout"]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
cache-keys = [
|
cache-keys = [
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use std::ptr;
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use data_plane::blob::{ArenaView, Blob, ContentDigest, WritableArenaView};
|
use data_plane::blob::{Blob, BlobView, ContentDigest, WritableArenaView};
|
||||||
use data_plane::bootstrap as dp_bootstrap;
|
use data_plane::bootstrap as dp_bootstrap;
|
||||||
use data_plane::data_plane::{BlobWriter, DataPlane, DataPlaneBootstrap, parse_actor_address};
|
use data_plane::data_plane::{BlobWriter, DataPlane, DataPlaneBootstrap, parse_actor_address};
|
||||||
use data_plane::path::DataPath;
|
use data_plane::path::DataPath;
|
||||||
|
|
@ -55,9 +55,6 @@ fn data_plane_error(error: DataPlaneError) -> PyErr {
|
||||||
DataPlaneError::PathNotFound(path) => {
|
DataPlaneError::PathNotFound(path) => {
|
||||||
PyErr::new::<DataPathError, _>(format!("data path not found: {path}"))
|
PyErr::new::<DataPathError, _>(format!("data path not found: {path}"))
|
||||||
}
|
}
|
||||||
DataPlaneError::PathAlreadyExists(path) => {
|
|
||||||
PyErr::new::<DataPathError, _>(format!("data path already exists: {path}"))
|
|
||||||
}
|
|
||||||
DataPlaneError::Blob(reason) => PyErr::new::<BlobError, _>(format!("{reason:?}")),
|
DataPlaneError::Blob(reason) => PyErr::new::<BlobError, _>(format!("{reason:?}")),
|
||||||
DataPlaneError::Attachment(reason) => {
|
DataPlaneError::Attachment(reason) => {
|
||||||
PyErr::new::<SessionError, _>(format!("attachment failed: {reason:?}"))
|
PyErr::new::<SessionError, _>(format!("attachment failed: {reason:?}"))
|
||||||
|
|
@ -190,15 +187,15 @@ fn build_child_routing(
|
||||||
let binder = OutboxRouteBinder::new(router, route_transport);
|
let binder = OutboxRouteBinder::new(router, route_transport);
|
||||||
binder.ensure_routable(host_session);
|
binder.ensure_routable(host_session);
|
||||||
|
|
||||||
driver.enable_actor_bridge(
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
runtime.clone(),
|
runtime: runtime.clone(),
|
||||||
codecs,
|
codec: codecs,
|
||||||
HashMap::new(),
|
routes: HashMap::new(),
|
||||||
ActorAddress::default(),
|
swim: ActorAddress::default(),
|
||||||
relay_mirror,
|
relay_mirror,
|
||||||
route_view,
|
route_view,
|
||||||
outbox,
|
outbox,
|
||||||
);
|
});
|
||||||
driver.install_actor_bridge_pump(ROUTE_POLL);
|
driver.install_actor_bridge_pump(ROUTE_POLL);
|
||||||
driver.join(std::slice::from_ref(&host_endpoint));
|
driver.join(std::slice::from_ref(&host_endpoint));
|
||||||
|
|
||||||
|
|
@ -295,14 +292,14 @@ impl PyBlob {
|
||||||
self.inner.digest().map(digest_hex)
|
self.inner.digest().map(digest_hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map(&self, py: Python<'_>) -> PyResult<Py<PyArenaView>> {
|
fn map(&self, py: Python<'_>) -> PyResult<Py<PyBlobView>> {
|
||||||
let inner = self
|
let inner = self
|
||||||
.inner
|
.inner
|
||||||
.map()
|
.map()
|
||||||
.map_err(|error| data_plane_error(DataPlaneError::Blob(BlobFailure::from(error))))?;
|
.map_err(|error| data_plane_error(DataPlaneError::Blob(BlobFailure::from(error))))?;
|
||||||
Py::new(
|
Py::new(
|
||||||
py,
|
py,
|
||||||
PyArenaView {
|
PyBlobView {
|
||||||
inner: Some(inner),
|
inner: Some(inner),
|
||||||
exports: 0,
|
exports: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -319,17 +316,17 @@ fn digest_hex(digest: &ContentDigest) -> String {
|
||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pyclass(name = "ArenaView")]
|
#[pyclass(name = "BlobView")]
|
||||||
pub struct PyArenaView {
|
pub struct PyBlobView {
|
||||||
inner: Option<ArenaView>,
|
inner: Option<BlobView>,
|
||||||
exports: usize,
|
exports: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pymethods]
|
#[pymethods]
|
||||||
impl PyArenaView {
|
impl PyBlobView {
|
||||||
fn __enter__(slf: PyRef<'_, Self>) -> PyResult<PyRef<'_, Self>> {
|
fn __enter__(slf: PyRef<'_, Self>) -> PyResult<PyRef<'_, Self>> {
|
||||||
if slf.inner.is_none() {
|
if slf.inner.is_none() {
|
||||||
return Err(PyBufferError::new_err("arena view is closed"));
|
return Err(PyBufferError::new_err("blob view is closed"));
|
||||||
}
|
}
|
||||||
Ok(slf)
|
Ok(slf)
|
||||||
}
|
}
|
||||||
|
|
@ -347,7 +344,7 @@ impl PyArenaView {
|
||||||
fn close(&mut self) -> PyResult<()> {
|
fn close(&mut self) -> PyResult<()> {
|
||||||
if self.exports != 0 {
|
if self.exports != 0 {
|
||||||
return Err(PyBufferError::new_err(
|
return Err(PyBufferError::new_err(
|
||||||
"cannot close an arena view with active buffer exports",
|
"cannot close a blob view with active buffer exports",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.inner.take();
|
self.inner.take();
|
||||||
|
|
@ -364,10 +361,10 @@ impl PyArenaView {
|
||||||
let inner = borrowed
|
let inner = borrowed
|
||||||
.inner
|
.inner
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| PyBufferError::new_err("arena view is closed"))?;
|
.ok_or_else(|| PyBufferError::new_err("blob view is closed"))?;
|
||||||
(inner.as_ptr().cast_mut(), inner.len())
|
(inner.as_ptr().cast_mut(), inner.len())
|
||||||
};
|
};
|
||||||
// SAFETY: the retained `ArenaView` owns the stable mapping and the
|
// SAFETY: the retained `BlobView` owns the stable mapping and the
|
||||||
// Python buffer owns the cloned `slf` reference until release.
|
// Python buffer owns the cloned `slf` reference until release.
|
||||||
unsafe { fill_buffer(view, flags, pointer, length, true, slf.clone().into_any()) }?;
|
unsafe { fill_buffer(view, flags, pointer, length, true, slf.clone().into_any()) }?;
|
||||||
slf.borrow_mut().exports += 1;
|
slf.borrow_mut().exports += 1;
|
||||||
|
|
@ -727,8 +724,10 @@ fn run(py: Python<'_>, main: Bound<'_, PyAny>) -> PyResult<()> {
|
||||||
host_session,
|
host_session,
|
||||||
capability,
|
capability,
|
||||||
Some(child_node),
|
Some(child_node),
|
||||||
attachment_engine,
|
data_plane::data_plane::AttachDeadline {
|
||||||
ROUTE_DEADLINE,
|
engine: attachment_engine,
|
||||||
|
timeout: ROUTE_DEADLINE,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
.map_err(data_plane_error)?;
|
.map_err(data_plane_error)?;
|
||||||
|
|
||||||
|
|
@ -769,14 +768,101 @@ impl data_plane::host::HostRouteRegistrar for DebugHostRouteRegistrar {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
struct DebugBlobSender {
|
||||||
|
runtime: Runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
impl data_plane::blob_transfer::BlobTransferSender for DebugBlobSender {
|
||||||
|
fn start_file(
|
||||||
|
&self,
|
||||||
|
request: data_plane::blob_transfer::FileTransferRequest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
let mut bytes = vec![0_u8; request.length as usize];
|
||||||
|
request
|
||||||
|
.file
|
||||||
|
.read_exact_at(&mut bytes, request.offset)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
data_plane::blob_transfer::BlobTransferEvent::Chunk {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
data_plane::blob_transfer::BlobTransferEvent::Finished {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
request.completion.complete(Ok(()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
struct DebugBlobReceiver;
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
impl data_plane::blob_transfer::BlobTransferReceiver for DebugBlobReceiver {
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
destination: ActorAddress,
|
||||||
|
transfer_id: data_plane::blob_transfer::BlobTransferId,
|
||||||
|
) -> Result<data_plane::blob_transfer::BlobTransferOffer, String> {
|
||||||
|
Ok(data_plane::blob_transfer::BlobTransferOffer {
|
||||||
|
transfer_id,
|
||||||
|
destination,
|
||||||
|
failure_proxy: None,
|
||||||
|
transport: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&self, _offer: &data_plane::blob_transfer::BlobTransferOffer) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
struct DebugNamespaceDiscovery(ActorAddress);
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
impl data_plane::namespace::NamespaceDiscovery for DebugNamespaceDiscovery {
|
||||||
|
fn current_directory(&self) -> Option<ActorAddress> {
|
||||||
|
Some(self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
struct DebugSourceRegistrar;
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
impl data_plane::source::BlobSourcePublisher for DebugSourceRegistrar {
|
||||||
|
fn publish_source(&self, _source: ActorAddress) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(debug_assertions, target_os = "linux"))]
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
#[pyclass(name = "_TestDataPlaneHost")]
|
#[pyclass(name = "_TestDataPlaneHost")]
|
||||||
struct PyTestDataPlaneHost {
|
struct PyTestDataPlaneHost {
|
||||||
_driver: Arc<IrohDriver>,
|
_driver: Arc<IrohDriver>,
|
||||||
_engine: Engine,
|
_engine: Engine,
|
||||||
runtime: Runtime,
|
|
||||||
host_session: ActorAddress,
|
|
||||||
handoff: data_plane::bootstrap::JobHandoff,
|
handoff: data_plane::bootstrap::JobHandoff,
|
||||||
|
namespace_root: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
impl Drop for PyTestDataPlaneHost {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self._driver.shutdown();
|
||||||
|
let _ = std::fs::remove_dir_all(&self.namespace_root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(debug_assertions, target_os = "linux"))]
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
|
@ -793,51 +879,6 @@ impl PyTestDataPlaneHost {
|
||||||
fn arena_fd(&self) -> RawFd {
|
fn arena_fd(&self) -> RawFd {
|
||||||
std::os::fd::AsRawFd::as_raw_fd(&self.handoff.arena_fd)
|
std::os::fd::AsRawFd::as_raw_fd(&self.handoff.arena_fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn published<'py>(
|
|
||||||
&self,
|
|
||||||
py: Python<'py>,
|
|
||||||
path: String,
|
|
||||||
) -> PyResult<Option<Bound<'py, pyo3::types::PyBytes>>> {
|
|
||||||
let path =
|
|
||||||
DataPath::parse(path).map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
|
||||||
let published = future::block_on(async {
|
|
||||||
let ask = self
|
|
||||||
.runtime
|
|
||||||
.ask::<
|
|
||||||
data_plane::protocol::HostSessionIn,
|
|
||||||
Option<data_plane::protocol::PublishedBlobInfo>,
|
|
||||||
>(
|
|
||||||
self.host_session,
|
|
||||||
|reply_to| data_plane::protocol::HostSessionIn::InspectPublished {
|
|
||||||
path,
|
|
||||||
reply_to,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
|
||||||
Ok::<_, PyErr>(ask.await)
|
|
||||||
})?;
|
|
||||||
let Some(published) = published else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
// SAFETY: dup returns a fresh descriptor or -1.
|
|
||||||
let fd = unsafe { libc::dup(std::os::fd::AsRawFd::as_raw_fd(&self.handoff.arena_fd)) };
|
|
||||||
if fd < 0 {
|
|
||||||
return Err(PyRuntimeError::new_err(
|
|
||||||
std::io::Error::last_os_error().to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// SAFETY: `fd` is a fresh uniquely-owned duplicate.
|
|
||||||
let file = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) });
|
|
||||||
let mut bytes = vec![0_u8; published.metadata.length as usize];
|
|
||||||
std::os::unix::fs::FileExt::read_exact_at(
|
|
||||||
&file,
|
|
||||||
&mut bytes,
|
|
||||||
published.lease.offset + data_plane::blob::BLOB_HEADER_LEN,
|
|
||||||
)
|
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
|
||||||
Ok(Some(pyo3::types::PyBytes::new(py, &bytes)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(debug_assertions, target_os = "linux"))]
|
#[cfg(all(debug_assertions, target_os = "linux"))]
|
||||||
|
|
@ -896,15 +937,50 @@ fn _test_data_plane_host() -> PyResult<PyTestDataPlaneHost> {
|
||||||
)
|
)
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
||||||
let capability = JobCapability::new([0x5a; 32]);
|
let capability = JobCapability::new([0x5a; 32]);
|
||||||
let weights: Arc<[u8]> =
|
let namespace_root = std::env::temp_dir().join(format!(
|
||||||
Arc::from(include_bytes!("../../../../apps/myelin/jobs/tiny_linear.weights").as_slice());
|
"swactor-python-namespace-{}",
|
||||||
|
ActorAddress::new_random().to_full_hex()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&namespace_root)
|
||||||
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
||||||
|
let source_sender: Arc<dyn data_plane::blob_transfer::BlobTransferSender> =
|
||||||
|
Arc::new(DebugBlobSender {
|
||||||
|
runtime: runtime.clone(),
|
||||||
|
});
|
||||||
|
let source_publisher: Arc<dyn data_plane::source::BlobSourcePublisher> =
|
||||||
|
Arc::new(DebugSourceRegistrar);
|
||||||
|
let namespace_service = data_plane::control::DataNamespaceService::recover(
|
||||||
|
runtime.clone(),
|
||||||
|
namespace_root.join("namespace.json"),
|
||||||
|
Arc::clone(&source_sender),
|
||||||
|
Arc::clone(&source_publisher),
|
||||||
|
)
|
||||||
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
||||||
|
let directory = namespace_service.directory();
|
||||||
let weights_path = DataPath::parse("/models/tiny-linear/weights")
|
let weights_path = DataPath::parse("/models/tiny-linear/weights")
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
||||||
let blobs = std::collections::BTreeMap::new();
|
let weights_file = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../../apps/myelin/jobs/tiny_linear.weights");
|
||||||
|
future::block_on(
|
||||||
|
namespace_service
|
||||||
|
.control()
|
||||||
|
.register(weights_path, data_plane::blob::file(weights_file)),
|
||||||
|
)
|
||||||
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
||||||
|
let namespace_proxy = runtime
|
||||||
|
.spawn(data_plane::namespace::NamespaceClientActor::new(
|
||||||
|
engine.handle(),
|
||||||
|
runtime.create_sender(),
|
||||||
|
Arc::new(DebugNamespaceDiscovery(directory)),
|
||||||
|
ROUTE_POLL,
|
||||||
|
))
|
||||||
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
||||||
|
let namespace = data_plane::namespace::NamespaceClient::new(runtime.clone(), namespace_proxy);
|
||||||
let host_session = runtime
|
let host_session = runtime
|
||||||
.spawn(
|
.spawn(
|
||||||
data_plane::host::HostDataPlaneSessionActor::new(
|
data_plane::host::HostDataPlaneSessionActor::new(
|
||||||
data_plane::host::HostDataPlaneConfig {
|
data_plane::host::HostDataPlaneConfig {
|
||||||
|
runtime: runtime.clone(),
|
||||||
arena,
|
arena,
|
||||||
arena_generation: 1,
|
arena_generation: 1,
|
||||||
session_generation: 1,
|
session_generation: 1,
|
||||||
|
|
@ -914,13 +990,18 @@ fn _test_data_plane_host() -> PyResult<PyTestDataPlaneHost> {
|
||||||
read_prefixes: vec![
|
read_prefixes: vec![
|
||||||
DataPath::parse("/models")
|
DataPath::parse("/models")
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?,
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?,
|
||||||
|
DataPath::parse("/runs/test-run/results")
|
||||||
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?,
|
||||||
],
|
],
|
||||||
write_prefixes: vec![
|
write_prefixes: vec![
|
||||||
DataPath::parse("/runs/test-run/results")
|
DataPath::parse("/runs/test-run/results")
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?,
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
blobs,
|
namespace: Some(namespace),
|
||||||
|
transfer_receiver: Some(Arc::new(DebugBlobReceiver)),
|
||||||
|
source_sender: Some(source_sender),
|
||||||
|
source_publisher: Some(source_publisher),
|
||||||
route_registrar: Some(registrar),
|
route_registrar: Some(registrar),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -928,48 +1009,16 @@ fn _test_data_plane_host() -> PyResult<PyTestDataPlaneHost> {
|
||||||
)
|
)
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
||||||
data_plane::host::install_session_env(&mut handoff, host_session, capability);
|
data_plane::host::install_session_env(&mut handoff, host_session, capability);
|
||||||
future::block_on(async {
|
|
||||||
runtime
|
|
||||||
.ask::<data_plane::protocol::HostSessionIn, Result<(), DataPlaneError>>(
|
|
||||||
host_session,
|
|
||||||
|reply_to| data_plane::protocol::HostSessionIn::BeginBlobSource {
|
|
||||||
path: weights_path.clone(),
|
|
||||||
metadata: data_plane::blob::BlobMetadata {
|
|
||||||
length: weights.len() as u64,
|
|
||||||
digest: Some(ContentDigest::sha256(&weights)),
|
|
||||||
},
|
|
||||||
reply_to,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?
|
|
||||||
.await
|
|
||||||
.map_err(data_plane_error)
|
|
||||||
})?;
|
|
||||||
runtime
|
|
||||||
.send_to(
|
|
||||||
host_session,
|
|
||||||
data_plane::protocol::HostSessionIn::BlobSourceChunk {
|
|
||||||
path: weights_path.clone(),
|
|
||||||
bytes: weights.to_vec(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
|
||||||
runtime
|
|
||||||
.send_to(
|
|
||||||
host_session,
|
|
||||||
data_plane::protocol::HostSessionIn::FinishBlobSource { path: weights_path },
|
|
||||||
)
|
|
||||||
.map_err(|error| PyRuntimeError::new_err(error.to_string()))?;
|
|
||||||
|
|
||||||
driver.enable_actor_bridge(
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
runtime.clone(),
|
runtime: runtime.clone(),
|
||||||
codecs,
|
codec: codecs,
|
||||||
HashMap::new(),
|
routes: HashMap::new(),
|
||||||
ActorAddress::default(),
|
swim: ActorAddress::default(),
|
||||||
relay_mirror,
|
relay_mirror,
|
||||||
route_view,
|
route_view,
|
||||||
outbox,
|
outbox,
|
||||||
);
|
});
|
||||||
driver.install_actor_bridge_pump(ROUTE_POLL);
|
driver.install_actor_bridge_pump(ROUTE_POLL);
|
||||||
handoff.env.insert(
|
handoff.env.insert(
|
||||||
dp_bootstrap::ENV_DATA_PLANE_ENDPOINT.to_owned(),
|
dp_bootstrap::ENV_DATA_PLANE_ENDPOINT.to_owned(),
|
||||||
|
|
@ -980,9 +1029,8 @@ fn _test_data_plane_host() -> PyResult<PyTestDataPlaneHost> {
|
||||||
Ok(PyTestDataPlaneHost {
|
Ok(PyTestDataPlaneHost {
|
||||||
_driver: Arc::new(driver),
|
_driver: Arc::new(driver),
|
||||||
_engine: engine,
|
_engine: engine,
|
||||||
runtime,
|
|
||||||
host_session,
|
|
||||||
handoff,
|
handoff,
|
||||||
|
namespace_root,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -994,7 +1042,7 @@ pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||||
module.add("SessionError", module.py().get_type::<SessionError>())?;
|
module.add("SessionError", module.py().get_type::<SessionError>())?;
|
||||||
module.add_class::<PyDataPlane>()?;
|
module.add_class::<PyDataPlane>()?;
|
||||||
module.add_class::<PyBlob>()?;
|
module.add_class::<PyBlob>()?;
|
||||||
module.add_class::<PyArenaView>()?;
|
module.add_class::<PyBlobView>()?;
|
||||||
module.add_class::<PyStreamWriter>()?;
|
module.add_class::<PyStreamWriter>()?;
|
||||||
module.add_class::<PyContext>()?;
|
module.add_class::<PyContext>()?;
|
||||||
module.add_function(wrap_pyfunction!(run, module)?)?;
|
module.add_function(wrap_pyfunction!(run, module)?)?;
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,9 @@ def test_run_attaches_before_main_and_maps_blob_buffer_directly(monkeypatch, hos
|
||||||
blob = await ctx.data.read_blob("/models/tiny-linear/weights")
|
blob = await ctx.data.read_blob("/models/tiny-linear/weights")
|
||||||
assert isinstance(blob, swactor.Blob)
|
assert isinstance(blob, swactor.Blob)
|
||||||
assert blob.length == 24
|
assert blob.length == 24
|
||||||
assert len(blob.digest) == 64
|
assert blob.digest is None
|
||||||
with blob.map() as mapped:
|
with blob.map() as mapped:
|
||||||
assert isinstance(mapped, swactor.ArenaView)
|
assert isinstance(mapped, swactor.BlobView)
|
||||||
assert struct.unpack_from("<6f", mapped) == pytest.approx(
|
assert struct.unpack_from("<6f", mapped) == pytest.approx(
|
||||||
(1.5, -2.0, 0.5, 4.0, 0.25, -0.75)
|
(1.5, -2.0, 0.5, 4.0, 0.25, -0.75)
|
||||||
)
|
)
|
||||||
|
|
@ -194,9 +194,13 @@ def test_write_blob_seals_cleanly_and_exception_aborts(monkeypatch, host):
|
||||||
except AbortWrite:
|
except AbortWrite:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
complete = await ctx.data.read_blob("/runs/self/results/complete")
|
||||||
|
with complete.map() as mapped:
|
||||||
|
assert bytes(mapped) == b"result"
|
||||||
|
with pytest.raises(swactor.DataPathError, match="not found"):
|
||||||
|
await ctx.data.read_blob("/runs/self/results/aborted")
|
||||||
|
|
||||||
swactor.run(main)
|
swactor.run(main)
|
||||||
assert bytes(host.published("/runs/self/results/complete")) == b"result"
|
|
||||||
assert host.published("/runs/self/results/aborted") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_path_and_authorization_are_typed(monkeypatch, host):
|
def test_missing_path_and_authorization_are_typed(monkeypatch, host):
|
||||||
|
|
@ -206,7 +210,7 @@ def test_missing_path_and_authorization_are_typed(monkeypatch, host):
|
||||||
with pytest.raises(swactor.DataPathError, match="not found"):
|
with pytest.raises(swactor.DataPathError, match="not found"):
|
||||||
await ctx.data.read_blob("/models/missing")
|
await ctx.data.read_blob("/models/missing")
|
||||||
with pytest.raises(PermissionError):
|
with pytest.raises(PermissionError):
|
||||||
await ctx.data.read_blob("/runs/self/results/private")
|
await ctx.data.read_blob("/runs/self/private")
|
||||||
|
|
||||||
swactor.run(main)
|
swactor.run(main)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1282,6 +1282,30 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maturin"
|
||||||
|
version = "1.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/b3/addd877f871fb1860d46d3a4f206ecb10b946c85846805e6367631926fd3/maturin-1.14.1.tar.gz", hash = "sha256:9d6577a62cd08e0ceba7a0db06fb098e0c9b1b3429bad747a4f3a18215a1b3df", size = 369637, upload-time = "2026-06-19T05:19:49.774Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f0/97c5a5bd9c71653a066c0976a484eaaae50b9369557838a4176b7b0bdaa5/maturin-1.14.1-py3-none-linux_armv6l.whl", hash = "sha256:522292398945442cdafa9daeb2271b2340fbde57027b818f923f88eab04174f8", size = 10207496, upload-time = "2026-06-19T05:19:09.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/83/294bca639b0e052f1e2f65199b3db258780c7d4e31408b934c9c974a1379/maturin-1.14.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ffe5ad71f21d1e6603c4dd75f7fee34adf5ed5ebcebb692886549888ebb329ed", size = 19680113, upload-time = "2026-06-19T05:19:13.43Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/b6/79c881410a3b1c187f7eb3d407aecae646c6a4433d630d72200359015e83/maturin-1.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3306078070c1508fd715b9116070cbcaff5959024272a9f1e6f5cb29768b86c", size = 10169205, upload-time = "2026-06-19T05:19:16.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/9d/44b6f26dcb7f7a04c5501ac2dbb6ca1490150682baa525ca5860504f9eab/maturin-1.14.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:cd457cd88961156e26379e1155bd287cc0ec1c8b2f1582b0660fb31b87c8842d", size = 10188098, upload-time = "2026-06-19T05:19:19.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/bd/9c0d5d6983905ce2c9edaa073a7e89355a9cf7f396988e05d32f1c37785d/maturin-1.14.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:dfc54ae32e6fcb18302193ab9a30b0b25eefffba994ae13238974805533ef75e", size = 10627576, upload-time = "2026-06-19T05:19:22.713Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/33/b096412bd6a7cb399652b260666f901adf88a687181a6dbd6a3f89f0a94e/maturin-1.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a131d912b5267e640bc96d70f4914e10590aed64082ec9abacba7cea52004224", size = 10085181, upload-time = "2026-06-19T05:19:25.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/8d/08c3bf469c38a23c9e6c877e338193001eb604d010fedc08341974e38528/maturin-1.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:be18fc568fb76884c0205456336892a75105ec398e6b667cd777c6268bd06d69", size = 10026363, upload-time = "2026-06-19T05:19:28.904Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/a4/c4d1a92839f8745ab4aab988a7db884a79d6d710bd3b286fcf9316dece1a/maturin-1.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:994a0c8ba3ad8a92b3a9ee1b02645d200d610216b15cff5102b0fe65e8e08666", size = 13321347, upload-time = "2026-06-19T05:19:32.411Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/fa/170f04624d03fd07d2a8b1b67de83a127af93aef9eaa425839553347297b/maturin-1.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be80866363e605d137991b491a741a84cde9ae350183c4c85f49690ca9aaaa65", size = 10877609, upload-time = "2026-06-19T05:19:35.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/ad/1ae2e1d0ded282bf2c55ac13f0811d87deb425e200ae64a15785675dede9/maturin-1.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5282dffd4b539d2be245f4e5b1a5ab6bc1033b58f4a4872f5833f9d43c954aa4", size = 10417316, upload-time = "2026-06-19T05:19:38.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/27/bf677183920718da49cd7982d6a3ffc440aad8919329f571d189f81b7bdf/maturin-1.14.1-py3-none-win32.whl", hash = "sha256:1a04de0a20188f95c721b5702eed18140bdcccb28c386797093eca3f62f4d4e0", size = 8931293, upload-time = "2026-06-19T05:19:41.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/4b/585adeb9167b08d3cdff0032a938b0e72655c92003df4f52c3f696a1bcc2/maturin-1.14.1-py3-none-win_amd64.whl", hash = "sha256:3c9f94640ecc4895e94abaf834a0684430032c865b2748a36c12461fd9252fdd", size = 10314067, upload-time = "2026-06-19T05:19:44.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/d4/dac8c0720ae246be1700afb6fbdbbea20fe35b13f6570b2f70faa005df77/maturin-1.14.1-py3-none-win_arm64.whl", hash = "sha256:15cea8fcb3ba47dd636f50092bb34baea8b04ac777392f23e6bf8a9a61efb894", size = 9718943, upload-time = "2026-06-19T05:19:47.49Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistune"
|
name = "mistune"
|
||||||
version = "3.2.0"
|
version = "3.2.0"
|
||||||
|
|
@ -1644,6 +1668,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-timeout"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||||
|
{ name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|
@ -2313,8 +2350,10 @@ dev = [
|
||||||
{ name = "ipykernel", version = "6.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
{ name = "ipykernel", version = "6.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||||
{ name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
{ name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||||
{ name = "jupyter" },
|
{ name = "jupyter" },
|
||||||
|
{ name = "maturin" },
|
||||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||||
{ name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
{ name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||||
|
{ name = "pytest-timeout" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
|
|
@ -2323,7 +2362,9 @@ dev = [
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "ipykernel" },
|
{ name = "ipykernel" },
|
||||||
{ name = "jupyter" },
|
{ name = "jupyter" },
|
||||||
|
{ name = "maturin", specifier = ">=1.7,<2" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-timeout" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,6 @@ crate-type = ["cdylib"]
|
||||||
swactor = { path = "../../..", default-features = false, features = ["wasm", "std"] }
|
swactor = { path = "../../..", default-features = false, features = ["wasm", "std"] }
|
||||||
swactor-engine = { path = "../../engine", default-features = false }
|
swactor-engine = { path = "../../engine", default-features = false }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,12 @@ pub struct WasmRuntime {
|
||||||
backend: SteppingBackend,
|
backend: SteppingBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for WasmRuntime {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
impl WasmRuntime {
|
impl WasmRuntime {
|
||||||
#[wasm_bindgen(constructor)]
|
#[wasm_bindgen(constructor)]
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,6 @@ demo-control = []
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,10 @@ mod properties {
|
||||||
concurrent in any::<bool>(),
|
concurrent in any::<bool>(),
|
||||||
destination_disappears in any::<bool>(),
|
destination_disappears in any::<bool>(),
|
||||||
) {
|
) {
|
||||||
let mut config = RuntimeConfig::default();
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
config.worker_count = 1;
|
worker_count: 1,
|
||||||
let parts = RuntimeParts::new(config);
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
let runtime = parts.runtime().clone();
|
let runtime = parts.runtime().clone();
|
||||||
let backend = SteppingBackend::new();
|
let backend = SteppingBackend::new();
|
||||||
let _engine =
|
let _engine =
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,9 @@ struct NodeCard {
|
||||||
errors: Vec<String>,
|
errors: Vec<String>,
|
||||||
cpu: Option<CpuSnapshot>,
|
cpu: Option<CpuSnapshot>,
|
||||||
gpu: Option<GpuSnapshot>,
|
gpu: Option<GpuSnapshot>,
|
||||||
|
memory: Option<telemetry::hardware::memory::HostMemorySample>,
|
||||||
net: Option<crate::hardware_view::NetSnapshot>,
|
net: Option<crate::hardware_view::NetSnapshot>,
|
||||||
|
storage: Option<telemetry::hardware::storage::HostStorageSample>,
|
||||||
process: Option<crate::hardware_view::ProcessSnapshot>,
|
process: Option<crate::hardware_view::ProcessSnapshot>,
|
||||||
history: Vec<HardwareHistorySnapshot>,
|
history: Vec<HardwareHistorySnapshot>,
|
||||||
actor_summary: ActorSummarySnapshot,
|
actor_summary: ActorSummarySnapshot,
|
||||||
|
|
@ -329,15 +331,9 @@ fn stream_key(stream: &StreamEvent) -> String {
|
||||||
fn node_card(node: &FusedNode, now: Instant) -> NodeCard {
|
fn node_card(node: &FusedNode, now: Instant) -> NodeCard {
|
||||||
let summary = node.hardware.summary();
|
let summary = node.hardware.summary();
|
||||||
let totals = node.actors.totals();
|
let totals = node.actors.totals();
|
||||||
let mut roster: Vec<RosterRow> = node.actors.actors.values().map(roster_row).collect();
|
// Address-keyed map order is the stable default. Volatile telemetry must
|
||||||
// Busiest actors first; ties fall back to address for stable rendering.
|
// not move a row out from under the pointer; the page offers explicit sorts.
|
||||||
roster.sort_by(|left, right| {
|
let roster: Vec<RosterRow> = node.actors.actors.values().map(roster_row).collect();
|
||||||
right
|
|
||||||
.msg_per_sec
|
|
||||||
.partial_cmp(&left.msg_per_sec)
|
|
||||||
.map_or(std::cmp::Ordering::Equal, |order| order)
|
|
||||||
.then_with(|| left.address.cmp(&right.address))
|
|
||||||
});
|
|
||||||
NodeCard {
|
NodeCard {
|
||||||
stream: StreamKeySnapshot {
|
stream: StreamKeySnapshot {
|
||||||
key: stream_key(&node.stream),
|
key: stream_key(&node.stream),
|
||||||
|
|
@ -352,7 +348,9 @@ fn node_card(node: &FusedNode, now: Instant) -> NodeCard {
|
||||||
errors: node.hardware.errors(),
|
errors: node.hardware.errors(),
|
||||||
cpu: node.hardware.cpu.as_ref().map(CpuSnapshot::from),
|
cpu: node.hardware.cpu.as_ref().map(CpuSnapshot::from),
|
||||||
gpu: node.hardware.gpu.as_ref().map(GpuSnapshot::from),
|
gpu: node.hardware.gpu.as_ref().map(GpuSnapshot::from),
|
||||||
|
memory: node.hardware.memory.clone(),
|
||||||
net: node.hardware.net.clone(),
|
net: node.hardware.net.clone(),
|
||||||
|
storage: node.hardware.storage.clone(),
|
||||||
process: node.hardware.process.clone(),
|
process: node.hardware.process.clone(),
|
||||||
history: node
|
history: node
|
||||||
.hardware
|
.hardware
|
||||||
|
|
@ -362,11 +360,16 @@ fn node_card(node: &FusedNode, now: Instant) -> NodeCard {
|
||||||
ms_ago: duration_ms(now.duration_since(sample.at)),
|
ms_ago: duration_ms(now.duration_since(sample.at)),
|
||||||
sample_unix_ms: sample.sample_unix_ms,
|
sample_unix_ms: sample.sample_unix_ms,
|
||||||
cpu_total_percent: sample.cpu_total_percent,
|
cpu_total_percent: sample.cpu_total_percent,
|
||||||
|
cpu_cores_percent: sample.cpu_cores_percent.clone(),
|
||||||
gpu_max_percent: sample.gpu_max_percent,
|
gpu_max_percent: sample.gpu_max_percent,
|
||||||
gpu_memory_used_mib: sample.gpu_memory_used_mib,
|
gpu_memory_used_mib: sample.gpu_memory_used_mib,
|
||||||
gpu_memory_total_mib: sample.gpu_memory_total_mib,
|
gpu_memory_total_mib: sample.gpu_memory_total_mib,
|
||||||
net_rx_bps: sample.net_rx_bps,
|
net_rx_bps: sample.net_rx_bps,
|
||||||
net_tx_bps: sample.net_tx_bps,
|
net_tx_bps: sample.net_tx_bps,
|
||||||
|
memory_used_percent: sample.memory_used_percent,
|
||||||
|
memory_pressure_some_avg10: sample.memory_pressure_some_avg10,
|
||||||
|
storage_used_percent: sample.storage_used_percent,
|
||||||
|
io_pressure_some_avg10: sample.io_pressure_some_avg10,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
actor_summary: ActorSummarySnapshot {
|
actor_summary: ActorSummarySnapshot {
|
||||||
|
|
@ -667,6 +670,191 @@ mod tests {
|
||||||
assert_eq!(still_live.first(), Some(&"orch"));
|
assert_eq!(still_live.first(), Some(&"orch"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hardware_channels_fold_into_fleet_snapshot() {
|
||||||
|
let view = ControlPlaneView::default();
|
||||||
|
let stream = StreamId::new(NodeId::new("worker"), Lifetime(1));
|
||||||
|
let cpu = json!({
|
||||||
|
"schema":"host.cpu.v1",
|
||||||
|
"seq":1,
|
||||||
|
"sample_unix_ms":1_000,
|
||||||
|
"query_elapsed_ms":1,
|
||||||
|
"host":{
|
||||||
|
"logical_cpus":8,
|
||||||
|
"total_percent":42.5,
|
||||||
|
"idle_percent":57.5,
|
||||||
|
"iowait_percent":0.0,
|
||||||
|
"steal_percent":0.0,
|
||||||
|
"load1":1.0,
|
||||||
|
"load5":0.5,
|
||||||
|
"load15":0.25
|
||||||
|
},
|
||||||
|
"cores":[
|
||||||
|
{"index":0,"total_percent":25.0,"idle_percent":75.0,"iowait_percent":0.0,"steal_percent":0.0},
|
||||||
|
{"index":1,"total_percent":60.0,"idle_percent":40.0,"iowait_percent":0.0,"steal_percent":0.0}
|
||||||
|
],
|
||||||
|
"processes":[],
|
||||||
|
"error":null
|
||||||
|
});
|
||||||
|
let gpu = json!({
|
||||||
|
"schema":"host.gpu.v1",
|
||||||
|
"seq":1,
|
||||||
|
"sample_unix_ms":1_000,
|
||||||
|
"query_elapsed_ms":2,
|
||||||
|
"gpus":[{
|
||||||
|
"index":0,
|
||||||
|
"uuid":"gpu-0",
|
||||||
|
"name":"test gpu",
|
||||||
|
"memory_used_mib":512,
|
||||||
|
"memory_total_mib":4096,
|
||||||
|
"utilization_gpu_percent":71,
|
||||||
|
"utilization_memory_percent":12,
|
||||||
|
"temperature_c":55,
|
||||||
|
"power_draw_w":25.0
|
||||||
|
}],
|
||||||
|
"processes":[],
|
||||||
|
"error":null
|
||||||
|
});
|
||||||
|
let memory = json!({
|
||||||
|
"schema":"host.memory.v1",
|
||||||
|
"seq":1,
|
||||||
|
"sample_unix_ms":2_000,
|
||||||
|
"query_elapsed_ms":1,
|
||||||
|
"total_bytes":16_000,
|
||||||
|
"available_bytes":4_000,
|
||||||
|
"used_bytes":12_000,
|
||||||
|
"cached_bytes":2_000,
|
||||||
|
"swap_total_bytes":8_000,
|
||||||
|
"swap_used_bytes":1_000,
|
||||||
|
"pressure":{
|
||||||
|
"some_avg10":1.25,
|
||||||
|
"some_avg60":0.75,
|
||||||
|
"some_avg300":0.5,
|
||||||
|
"some_total_us":100,
|
||||||
|
"full_avg10":0.1,
|
||||||
|
"full_avg60":0.05,
|
||||||
|
"full_avg300":0.01,
|
||||||
|
"full_total_us":10
|
||||||
|
},
|
||||||
|
"error":null
|
||||||
|
});
|
||||||
|
let net_sample = |seq, sample_unix_ms, rx_bytes, tx_bytes| {
|
||||||
|
json!({
|
||||||
|
"schema":"host.net.v1",
|
||||||
|
"seq":seq,
|
||||||
|
"sample_unix_ms":sample_unix_ms,
|
||||||
|
"interfaces":[{
|
||||||
|
"name":"eth0",
|
||||||
|
"rx_bytes":rx_bytes,
|
||||||
|
"tx_bytes":tx_bytes,
|
||||||
|
"rx_packets":10,
|
||||||
|
"tx_packets":10,
|
||||||
|
"rx_errors":0,
|
||||||
|
"tx_errors":0,
|
||||||
|
"rx_dropped":0,
|
||||||
|
"tx_dropped":0
|
||||||
|
}],
|
||||||
|
"error":null
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let storage = json!({
|
||||||
|
"schema":"host.storage.v1",
|
||||||
|
"seq":1,
|
||||||
|
"sample_unix_ms":2_000,
|
||||||
|
"query_elapsed_ms":1,
|
||||||
|
"filesystems":[{
|
||||||
|
"mount":"/",
|
||||||
|
"total_bytes":100_000,
|
||||||
|
"used_bytes":80_000,
|
||||||
|
"available_bytes":20_000,
|
||||||
|
"used_percent":80.0
|
||||||
|
}],
|
||||||
|
"pressure":{
|
||||||
|
"some_avg10":2.5,
|
||||||
|
"some_avg60":1.5,
|
||||||
|
"some_avg300":0.5,
|
||||||
|
"some_total_us":200,
|
||||||
|
"full_avg10":0.2,
|
||||||
|
"full_avg60":0.1,
|
||||||
|
"full_avg300":0.05,
|
||||||
|
"full_total_us":20
|
||||||
|
},
|
||||||
|
"error":null
|
||||||
|
});
|
||||||
|
|
||||||
|
for (position, channel, payload) in [
|
||||||
|
(0, "host.cpu", cpu),
|
||||||
|
(1, "host.gpu", gpu),
|
||||||
|
(2, "host.memory", memory),
|
||||||
|
(3, "host.net", net_sample(0, 1_000, 1_000, 2_000)),
|
||||||
|
(4, "host.net", net_sample(1, 2_000, 2_000, 3_500)),
|
||||||
|
(5, "host.storage", storage),
|
||||||
|
] {
|
||||||
|
ingest_json(
|
||||||
|
&view,
|
||||||
|
&stream,
|
||||||
|
position,
|
||||||
|
channel,
|
||||||
|
serde_json::to_vec(&payload).expect("hardware payload"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = view.snapshot_json();
|
||||||
|
let node = &snapshot["live"][0];
|
||||||
|
assert_eq!(node["cpu"]["host"]["total_percent"], json!(42.5));
|
||||||
|
assert_eq!(node["gpu"]["gpus"][0]["utilization_gpu_percent"], json!(71));
|
||||||
|
assert_eq!(node["cpu"]["cores"][1]["total_percent"], json!(60.0));
|
||||||
|
assert_eq!(node["memory"]["used_bytes"], json!(12_000));
|
||||||
|
assert_eq!(node["net"]["interfaces"][0]["rx_bps"], json!(1_000.0));
|
||||||
|
assert_eq!(node["net"]["interfaces"][0]["tx_bps"], json!(1_500.0));
|
||||||
|
assert_eq!(
|
||||||
|
node["storage"]["filesystems"][0]["used_percent"],
|
||||||
|
json!(80.0)
|
||||||
|
);
|
||||||
|
assert_eq!(node["history"][0]["cpu_cores_percent"], json!([25.0, 60.0]));
|
||||||
|
assert_eq!(
|
||||||
|
node["history"][0]["memory_pressure_some_avg10"],
|
||||||
|
json!(1.25)
|
||||||
|
);
|
||||||
|
assert_eq!(node["history"][0]["io_pressure_some_avg10"], json!(2.5));
|
||||||
|
assert_eq!(node["last_sample_unix_ms"], json!(2_000));
|
||||||
|
assert!(node["errors"].as_array().expect("errors").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roster_default_order_does_not_follow_volatile_throughput() {
|
||||||
|
let view = ControlPlaneView::default();
|
||||||
|
let stream = StreamId::new(NodeId::new("node"), Lifetime(1));
|
||||||
|
ingest_json(
|
||||||
|
&view,
|
||||||
|
&stream,
|
||||||
|
0,
|
||||||
|
"runtime.actors",
|
||||||
|
actors_payload(
|
||||||
|
0,
|
||||||
|
json!([
|
||||||
|
{ "address": "zz", "messages_processed": 100 },
|
||||||
|
{ "address": "aa", "messages_processed": 1 }
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let mut state = view.state.write();
|
||||||
|
let actors = &mut state.streams.get_mut("node#1").expect("node").actors.actors;
|
||||||
|
actors.get_mut("zz").expect("zz actor").msg_per_sec = 10_000.0;
|
||||||
|
actors.get_mut("aa").expect("aa actor").msg_per_sec = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = view.snapshot_json();
|
||||||
|
let addresses: Vec<&str> = snapshot["live"][0]["roster"]
|
||||||
|
.as_array()
|
||||||
|
.expect("roster")
|
||||||
|
.iter()
|
||||||
|
.map(|actor| actor["address"].as_str().expect("address"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(addresses, vec!["aa", "zz"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn newer_life_generation_evicts_superseded_stream() {
|
fn newer_life_generation_evicts_superseded_stream() {
|
||||||
let view = ControlPlaneView::default();
|
let view = ControlPlaneView::default();
|
||||||
|
|
@ -701,7 +889,7 @@ mod tests {
|
||||||
fn stale_pool_is_hard_capped() {
|
fn stale_pool_is_hard_capped() {
|
||||||
let view = ControlPlaneView::default();
|
let view = ControlPlaneView::default();
|
||||||
for index in 0..(STALE_POOL_CAP as u64 + 5) {
|
for index in 0..(STALE_POOL_CAP as u64 + 5) {
|
||||||
let stream = StreamId::new(NodeId::new(&format!("old-{index}")), Lifetime(1));
|
let stream = StreamId::new(NodeId::new(format!("old-{index}")), Lifetime(1));
|
||||||
ingest_json(
|
ingest_json(
|
||||||
&view,
|
&view,
|
||||||
&stream,
|
&stream,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,12 @@
|
||||||
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
|
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
|
||||||
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--divider); font-variant-numeric: tabular-nums; }
|
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--divider); font-variant-numeric: tabular-nums; }
|
||||||
th { color: var(--amber); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
th { color: var(--amber); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.roster-sort-controls { display: inline-flex; gap: 2px; margin-left: 4px; vertical-align: middle; }
|
||||||
|
.roster-sort-button { position: relative; width: 14px; height: 14px; padding: 0; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: var(--r); font-size: 0; line-height: 0; cursor: pointer; }
|
||||||
|
.roster-sort-button::before { content: ""; position: absolute; left: 3px; width: 0; height: 0; border-left: 3px solid transparent; border-right: 3px solid transparent; }
|
||||||
|
.roster-sort-button[data-direction="asc"]::before { top: 3px; border-bottom: 5px solid currentColor; }
|
||||||
|
.roster-sort-button[data-direction="desc"]::before { bottom: 3px; border-top: 5px solid currentColor; }
|
||||||
|
.roster-sort-button:hover, .roster-sort-button[aria-pressed="true"] { color: var(--cyan); border-color: var(--cyan); background: var(--panel-hover); }
|
||||||
tbody tr[data-addr] { cursor: pointer; transition: background var(--t); }
|
tbody tr[data-addr] { cursor: pointer; transition: background var(--t); }
|
||||||
tbody tr[data-addr]:hover { background: var(--row-hover); }
|
tbody tr[data-addr]:hover { background: var(--row-hover); }
|
||||||
tbody tr[data-selected="true"] { background: var(--selected); box-shadow: inset 2px 0 0 var(--selected-edge); }
|
tbody tr[data-selected="true"] { background: var(--selected); box-shadow: inset 2px 0 0 var(--selected-edge); }
|
||||||
|
|
@ -102,6 +108,33 @@
|
||||||
.empty { padding: 24px; border: 1px dashed var(--border); border-radius: var(--r); color: var(--muted); font-family: var(--mono); font-size: 12px; }
|
.empty { padding: 24px; border: 1px dashed var(--border); border-radius: var(--r); color: var(--muted); font-family: var(--mono); font-size: 12px; }
|
||||||
.notice { font-size: 12px; color: var(--muted); margin: 6px 0; }
|
.notice { font-size: 12px; color: var(--muted); margin: 6px 0; }
|
||||||
canvas { width: 100%; height: 64px; background: var(--inset); border: 1px solid var(--border); border-radius: var(--r); }
|
canvas { width: 100%; height: 64px; background: var(--inset); border: 1px solid var(--border); border-radius: var(--r); }
|
||||||
|
.hardware-source { margin-bottom: 8px; }
|
||||||
|
.hardware-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 8px; }
|
||||||
|
.hw-card { min-width: 0; padding: 10px; background: var(--inset); border: 1px solid var(--divider); border-radius: var(--r); }
|
||||||
|
.hw-card.cpu { grid-column: span 7; }
|
||||||
|
.hw-card.memory { grid-column: span 5; }
|
||||||
|
.hw-card.storage, .hw-card.gpu, .hw-card.network { grid-column: span 4; }
|
||||||
|
.hw-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 7px; }
|
||||||
|
.hw-card-title { color: var(--muted); font: 700 10px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .09em; }
|
||||||
|
.hw-card-value { color: var(--text); font: 700 16px/1 var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.hw-card-subtitle { margin-top: 5px; color: var(--muted); font: 11px/1.35 var(--mono); }
|
||||||
|
.metric-pair { display: flex; justify-content: space-between; gap: 8px; margin-top: 6px; color: var(--muted); font: 11px/1.3 var(--mono); }
|
||||||
|
.metric-pair strong { color: var(--text); font-weight: 600; }
|
||||||
|
.pressure-value.ok { color: var(--ok); } .pressure-value.warn { color: var(--amber); } .pressure-value.bad { color: var(--bad); }
|
||||||
|
canvas.thread-graph { height: 72px; background: var(--bg); border-color: var(--divider); image-rendering: pixelated; }
|
||||||
|
.thread-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(26px, 1fr)); gap: 3px; margin-top: 5px; }
|
||||||
|
.thread-cell { min-width: 0; height: 20px; display: grid; place-items: center; border: 1px solid var(--divider); border-radius: var(--r); color: var(--text); font: 9px/1 var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.network-list { display: grid; gap: 6px; }
|
||||||
|
.network-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; font: 11px/1.25 var(--mono); }
|
||||||
|
.network-row .interface { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
||||||
|
.hw-empty { color: var(--muted); font: 11px/1.35 var(--mono); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hw-card.cpu { grid-column: span 12; }
|
||||||
|
.hw-card.memory, .hw-card.storage, .hw-card.gpu, .hw-card.network { grid-column: span 6; }
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.hw-card.cpu, .hw-card.memory, .hw-card.storage, .hw-card.gpu, .hw-card.network { grid-column: span 12; }
|
||||||
|
}
|
||||||
.err { color: var(--bad); }
|
.err { color: var(--bad); }
|
||||||
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; animation: none !important; } }
|
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; animation: none !important; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -134,6 +167,8 @@ const params = new URLSearchParams(window.location.search);
|
||||||
let selectedStream = params.get('stream') || null;
|
let selectedStream = params.get('stream') || null;
|
||||||
let selectedActor = params.get('actor') || null;
|
let selectedActor = params.get('actor') || null;
|
||||||
let rosterFilter = '';
|
let rosterFilter = '';
|
||||||
|
let rosterSort = { key: 'address', direction: 'asc' };
|
||||||
|
let hardwareSource = 'node';
|
||||||
let lastSnapshot = null;
|
let lastSnapshot = null;
|
||||||
let detailTimer = null;
|
let detailTimer = null;
|
||||||
// A1: last-rendered HTML per region. A poll that yields identical markup
|
// A1: last-rendered HTML per region. A poll that yields identical markup
|
||||||
|
|
@ -144,6 +179,14 @@ let lastRosterHtml = null;
|
||||||
let lastMachineHtml = null;
|
let lastMachineHtml = null;
|
||||||
let lastDossierHtml = null;
|
let lastDossierHtml = null;
|
||||||
|
|
||||||
|
window.addEventListener('dashboard-hardware-source', event => {
|
||||||
|
const next = event.detail?.source === 'orchestrator' ? 'orchestrator' : 'node';
|
||||||
|
if (next === hardwareSource) return;
|
||||||
|
hardwareSource = next;
|
||||||
|
lastMachineHtml = null;
|
||||||
|
if (lastSnapshot) render();
|
||||||
|
});
|
||||||
|
|
||||||
// Receipt ages (dossier) render as empty spans carrying an absolute epoch —
|
// Receipt ages (dossier) render as empty spans carrying an absolute epoch —
|
||||||
// stable across polls — and a 1 Hz pass rewrites their textContent. All other
|
// stable across polls — and a 1 Hz pass rewrites their textContent. All other
|
||||||
// ticking text (card seen/stats) is written per-poll by updateCardTexts.
|
// ticking text (card seen/stats) is written per-poll by updateCardTexts.
|
||||||
|
|
@ -173,6 +216,17 @@ function fmtRate(value) {
|
||||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' k';
|
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' k';
|
||||||
return n.toFixed(n < 10 ? 1 : 0);
|
return n.toFixed(n < 10 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
function fmtBytes(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return '—';
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||||
|
let scaled = Math.max(0, n), unit = 0;
|
||||||
|
while (scaled >= 1024 && unit < units.length - 1) {
|
||||||
|
scaled /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return scaled.toFixed(scaled < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit];
|
||||||
|
}
|
||||||
function typeShort(name) {
|
function typeShort(name) {
|
||||||
if (!name) return '—';
|
if (!name) return '—';
|
||||||
const parts = String(name).split('::');
|
const parts = String(name).split('::');
|
||||||
|
|
@ -291,6 +345,14 @@ function updateCardTexts(nodes) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hardwareNodeFor(node, live, stale) {
|
||||||
|
if (hardwareSource !== 'orchestrator' || node.stream.origin === 'orchestrator') {
|
||||||
|
return { node, mirrored: false };
|
||||||
|
}
|
||||||
|
const orchestrator = live.concat(stale).find(candidate => candidate.stream.origin === 'orchestrator');
|
||||||
|
return orchestrator ? { node: orchestrator, mirrored: true } : { node, mirrored: false };
|
||||||
|
}
|
||||||
|
|
||||||
function renderNode(page, node, live, stale) {
|
function renderNode(page, node, live, stale) {
|
||||||
const summary = node.actor_summary || {};
|
const summary = node.actor_summary || {};
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
|
|
@ -333,7 +395,8 @@ function renderNode(page, node, live, stale) {
|
||||||
if (actorCount) actorCount.textContent = fmt(summary.actors);
|
if (actorCount) actorCount.textContent = fmt(summary.actors);
|
||||||
const seen = page.querySelector('h2 [data-seen]');
|
const seen = page.querySelector('h2 [data-seen]');
|
||||||
if (seen) seen.textContent = node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago';
|
if (seen) seen.textContent = node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago';
|
||||||
const machine = machineDetail(node);
|
const machineSource = hardwareNodeFor(node, live, stale);
|
||||||
|
const machine = machineDetail(machineSource.node, machineSource.mirrored);
|
||||||
const machineSlot = document.getElementById('machine-detail');
|
const machineSlot = document.getElementById('machine-detail');
|
||||||
if (machineSlot && machine !== lastMachineHtml) {
|
if (machineSlot && machine !== lastMachineHtml) {
|
||||||
machineSlot.innerHTML = machine;
|
machineSlot.innerHTML = machine;
|
||||||
|
|
@ -347,67 +410,192 @@ function renderNode(page, node, live, stale) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function machineDetail(node) {
|
function percentOf(used, total) {
|
||||||
const cpu = node.cpu, gpu = node.gpu, net = node.net;
|
const numerator = Number(used), denominator = Number(total);
|
||||||
const rows = [];
|
return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator > 0
|
||||||
const cpuTotal = cpu && cpu.host ? cpu.host.total_percent : null;
|
? numerator * 100 / denominator
|
||||||
rows.push(`<div class="bar-row"><span class="name">CPU</span>${bar(cpuTotal)}<span class="num">${cpuTotal == null ? '—' : fmt(cpuTotal, 1) + '%'}</span></div>`);
|
: null;
|
||||||
if (gpu && gpu.gpus) {
|
|
||||||
gpu.gpus.forEach((device, index) => {
|
|
||||||
const util = device.utilization_gpu_percent;
|
|
||||||
const used = device.memory_used_mib || 0, total = device.memory_total_mib || 0;
|
|
||||||
rows.push(`<div class="bar-row"><span class="name">GPU ${fmt(index)}</span>${bar(util)}<span class="num">${util == null ? '—' : fmt(util) + '%'}</span></div>`);
|
|
||||||
if (total) rows.push(`<div class="bar-row"><span class="name"> </span>${bar(used, total)}<span class="num">${fmt(used)}/${fmt(total)} MiB</span></div>`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (net && net.interfaces) {
|
|
||||||
net.interfaces.slice(0, 4).forEach(nic => {
|
|
||||||
rows.push(`<div class="bar-row"><span class="name" title="${esc(nic.name)}">${esc(nic.name.slice(0, 12))}</span><span class="num" style="width:auto;flex:1">${fmtRate(nic.rx_bps)}↓ ${fmtRate(nic.tx_bps)}↑</span></div>`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (node.history && node.history.length) drawMachineHistory(node.history);
|
|
||||||
const errors = (node.errors || []).map(e => `<div class="notice err">${esc(e)}</div>`).join('');
|
|
||||||
return `<div class="bars">${rows.join('')}</div><canvas id="machine-spark" width="600" height="64"></canvas>${errors}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawMachineHistory(history) {
|
function pressureTone(value, warn = 1, bad = 5) {
|
||||||
|
const pressure = Number(value);
|
||||||
|
if (!Number.isFinite(pressure)) return '';
|
||||||
|
return pressure >= bad ? 'bad' : pressure >= warn ? 'warn' : 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function machineDetail(node, mirrored = false) {
|
||||||
|
const cpu = node.cpu, gpu = node.gpu, memory = node.memory, net = node.net, storage = node.storage;
|
||||||
|
const cpuHost = cpu?.host;
|
||||||
|
const cpuTotal = cpuHost?.total_percent;
|
||||||
|
const cores = [...(cpu?.cores || [])].sort((left, right) => left.index - right.index);
|
||||||
|
const threadHeight = Math.min(92, Math.max(42, cores.length * 4));
|
||||||
|
drawThreadGraph(node.history || [], cores.map(core => core.total_percent));
|
||||||
|
const threadCells = cores.map(core => {
|
||||||
|
const value = core.total_percent;
|
||||||
|
const load = value == null ? 0 : Math.max(0, Math.min(100, Number(value)));
|
||||||
|
const label = value == null ? '—' : fmt(value);
|
||||||
|
return `<span class="thread-cell" title="Thread ${fmt(core.index)} · ${value == null ? 'no sample' : fmt(value, 1) + '%'}" style="background:linear-gradient(to top,var(--ok) 0%,var(--ok) ${load}%,var(--bg) ${load}%,var(--bg) 100%)">${label}</span>`;
|
||||||
|
}).join('');
|
||||||
|
const load = [cpuHost?.load1, cpuHost?.load5, cpuHost?.load15].map(value => fmt(value, 2)).join(' / ');
|
||||||
|
const cpuLabel = cpuTotal == null ? '—' : fmt(cpuTotal, 1) + '%';
|
||||||
|
|
||||||
|
const memoryPercent = percentOf(memory?.used_bytes, memory?.total_bytes);
|
||||||
|
const memoryPressure = memory?.pressure?.some_avg10;
|
||||||
|
const swapPercent = percentOf(memory?.swap_used_bytes, memory?.swap_total_bytes);
|
||||||
|
const memoryLabel = memoryPercent == null ? '—' : fmt(memoryPercent, 1) + '%';
|
||||||
|
|
||||||
|
const filesystem = storage?.filesystems?.[0];
|
||||||
|
const storagePercent = filesystem?.used_percent;
|
||||||
|
const ioPressure = storage?.pressure?.some_avg10;
|
||||||
|
const storageLabel = storagePercent == null ? '—' : fmt(storagePercent, 1) + '%';
|
||||||
|
|
||||||
|
const gpuDevices = gpu?.gpus || [];
|
||||||
|
const gpuMax = gpuDevices.reduce((maximum, device) => {
|
||||||
|
const value = Number(device.utilization_gpu_percent);
|
||||||
|
return Number.isFinite(value) ? Math.max(maximum, value) : maximum;
|
||||||
|
}, 0);
|
||||||
|
const gpuRows = gpuDevices.map((device, index) => {
|
||||||
|
const utilization = device.utilization_gpu_percent;
|
||||||
|
const memoryPercent = percentOf(device.memory_used_mib, device.memory_total_mib);
|
||||||
|
return `<div class="metric-pair"><span>GPU ${fmt(index)}</span><strong>${utilization == null ? '—' : fmt(utilization) + '%'}</strong></div>
|
||||||
|
${bar(utilization)}
|
||||||
|
<div class="metric-pair"><span>Memory</span><strong>${device.memory_total_mib ? fmt(device.memory_used_mib) + ' / ' + fmt(device.memory_total_mib) + ' MiB' : '—'}</strong></div>
|
||||||
|
${device.memory_total_mib ? bar(memoryPercent) : ''}`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const networkRows = (net?.interfaces || []).slice(0, 4).map(nic => `
|
||||||
|
<div class="network-row">
|
||||||
|
<span class="interface" title="${esc(nic.name)}">${esc(nic.name)}</span>
|
||||||
|
<span>${fmtRate(nic.rx_bps)}↓ ${fmtRate(nic.tx_bps)}↑</span>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const source = mirrored ? '<div class="hardware-source muted">Mock node · local orchestrator hardware</div>' : '';
|
||||||
|
const errors = (node.errors || []).map(error => `<div class="notice err">${esc(error)}</div>`).join('');
|
||||||
|
return `${source}<div class="hardware-grid">
|
||||||
|
<section class="hw-card cpu">
|
||||||
|
<div class="hw-card-head"><span class="hw-card-title">CPU · ${fmt(cores.length)} threads</span><span class="hw-card-value">${cpuLabel}</span></div>
|
||||||
|
<canvas id="thread-graph" class="thread-graph" width="560" height="${threadHeight}" style="height:${threadHeight}px" role="img" aria-label="Per-thread CPU utilization history"></canvas>
|
||||||
|
<div class="thread-strip">${threadCells || '<span class="hw-empty">Waiting for per-thread samples</span>'}</div>
|
||||||
|
<div class="hw-card-subtitle">Load 1 / 5 / 15 min ${load}</div>
|
||||||
|
</section>
|
||||||
|
<section class="hw-card memory">
|
||||||
|
<div class="hw-card-head"><span class="hw-card-title">Memory</span><span class="hw-card-value">${memoryLabel}</span></div>
|
||||||
|
${bar(memoryPercent)}
|
||||||
|
<div class="metric-pair"><span>Used</span><strong>${fmtBytes(memory?.used_bytes)} / ${fmtBytes(memory?.total_bytes)}</strong></div>
|
||||||
|
<div class="metric-pair"><span>Available</span><strong>${fmtBytes(memory?.available_bytes)}</strong></div>
|
||||||
|
<div class="metric-pair"><span>Swap</span><strong>${swapPercent == null ? '—' : fmt(swapPercent, 1) + '%'}</strong></div>
|
||||||
|
<div class="metric-pair"><span>PSI some · 10s</span><strong class="pressure-value ${pressureTone(memoryPressure)}">${memoryPressure == null ? '—' : fmt(memoryPressure, 2) + '%'}</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="hw-card storage">
|
||||||
|
<div class="hw-card-head"><span class="hw-card-title">Storage · ${esc(filesystem?.mount || '/')}</span><span class="hw-card-value">${storageLabel}</span></div>
|
||||||
|
${bar(storagePercent)}
|
||||||
|
<div class="metric-pair"><span>Used</span><strong>${fmtBytes(filesystem?.used_bytes)} / ${fmtBytes(filesystem?.total_bytes)}</strong></div>
|
||||||
|
<div class="metric-pair"><span>Available</span><strong>${fmtBytes(filesystem?.available_bytes)}</strong></div>
|
||||||
|
<div class="metric-pair"><span>I/O PSI · 10s</span><strong class="pressure-value ${pressureTone(ioPressure)}">${ioPressure == null ? '—' : fmt(ioPressure, 2) + '%'}</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="hw-card gpu">
|
||||||
|
<div class="hw-card-head"><span class="hw-card-title">GPU · ${fmt(gpuDevices.length)} devices</span><span class="hw-card-value">${gpuDevices.length ? fmt(gpuMax) + '%' : '—'}</span></div>
|
||||||
|
${gpuRows || '<div class="hw-empty">No GPU telemetry</div>'}
|
||||||
|
</section>
|
||||||
|
<section class="hw-card network">
|
||||||
|
<div class="hw-card-head"><span class="hw-card-title">Network</span><span class="hw-card-value">${fmt(net?.interfaces?.length || 0)}</span></div>
|
||||||
|
<div class="network-list">${networkRows || '<div class="hw-empty">No interface telemetry</div>'}</div>
|
||||||
|
</section>
|
||||||
|
</div>${errors}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawThreadGraph(history, currentCores) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const canvas = document.getElementById('machine-spark');
|
const canvas = document.getElementById('thread-graph');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
const ratio = Math.max(1, window.devicePixelRatio || 1);
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const width = Math.max(1, Math.round(rect.width));
|
||||||
|
const height = Math.max(1, Math.round(rect.height));
|
||||||
|
const pixelWidth = Math.round(width * ratio), pixelHeight = Math.round(height * ratio);
|
||||||
|
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||||||
|
canvas.width = pixelWidth;
|
||||||
|
canvas.height = pixelHeight;
|
||||||
|
}
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||||
const points = history.map(h => ({ cpu: h.cpu_total_percent, gpu: h.gpu_max_percent }));
|
const styles = getComputedStyle(document.documentElement);
|
||||||
drawLine(ctx, points.map(p => p.gpu), '#fbbf24');
|
const background = styles.getPropertyValue('--bg').trim() || '#00060c';
|
||||||
drawLine(ctx, points.map(p => p.cpu), '#34d399');
|
const divider = styles.getPropertyValue('--divider').trim() || '#0d2c4a';
|
||||||
|
const foreground = styles.getPropertyValue('--ok').trim() || '#00d400';
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = background;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const threadCount = Math.max(currentCores.length, ...history.map(point => point.cpu_cores_percent?.length || 0));
|
||||||
|
if (!threadCount) return;
|
||||||
|
const rowHeight = height / threadCount;
|
||||||
|
ctx.fillStyle = divider;
|
||||||
|
for (let row = 1; row < threadCount; row += 1) {
|
||||||
|
ctx.fillRect(0, Math.floor(row * rowHeight), width, 1);
|
||||||
|
}
|
||||||
|
const slot = 2;
|
||||||
|
const points = history
|
||||||
|
.map(point => point.cpu_cores_percent || [])
|
||||||
|
.slice(-Math.floor(width / slot));
|
||||||
|
if (!points.length) points.push(currentCores);
|
||||||
|
ctx.fillStyle = foreground;
|
||||||
|
points.forEach((values, pointIndex) => {
|
||||||
|
const x = width - (points.length - pointIndex) * slot;
|
||||||
|
values.forEach((value, threadIndex) => {
|
||||||
|
if (value == null || !Number.isFinite(Number(value))) return;
|
||||||
|
const utilization = Math.max(0, Math.min(100, Number(value)));
|
||||||
|
ctx.globalAlpha = 0.10 + utilization * 0.009;
|
||||||
|
const y = Math.ceil(threadIndex * rowHeight);
|
||||||
|
ctx.fillRect(x, y, slot, Math.max(1, Math.floor(rowHeight) - 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawLine(ctx, values, color) {
|
function rosterSortControls(key, label) {
|
||||||
const valid = values.filter(v => v != null);
|
return `<span class="roster-sort-controls">
|
||||||
if (valid.length < 2) return;
|
<button type="button" class="roster-sort-button" data-sort="${key}" data-direction="asc" aria-label="Sort ${label} ascending" title="Sort ${label} ascending" aria-pressed="${rosterSort.key === key && rosterSort.direction === 'asc'}">↑</button>
|
||||||
const max = Math.max(100, ...valid);
|
<button type="button" class="roster-sort-button" data-sort="${key}" data-direction="desc" aria-label="Sort ${label} descending" title="Sort ${label} descending" aria-pressed="${rosterSort.key === key && rosterSort.direction === 'desc'}">↓</button>
|
||||||
const step = ctx.canvas.width / (values.length - 1 || 1);
|
</span>`;
|
||||||
ctx.strokeStyle = color;
|
}
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.beginPath();
|
function compareRosterValues(left, right) {
|
||||||
let started = false;
|
const leftMissing = left == null || left === '';
|
||||||
values.forEach((value, index) => {
|
const rightMissing = right == null || right === '';
|
||||||
if (value == null) return;
|
if (leftMissing || rightMissing) return leftMissing === rightMissing ? 0 : leftMissing ? 1 : -1;
|
||||||
const x = index * step;
|
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||||||
const y = ctx.canvas.height - (value / max) * (ctx.canvas.height - 6) - 3;
|
if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right);
|
||||||
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
|
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
});
|
}
|
||||||
ctx.stroke();
|
|
||||||
|
function isLegacyHardwareSampler(actor) {
|
||||||
|
const actorType = String(actor.actor_type || '');
|
||||||
|
return (actorType.includes('BlockingSamplerActor') && actorType.includes('telemetry::hardware'))
|
||||||
|
|| /Host(?:Cpu|Gpu|Net)SamplerActor/.test(actorType);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRoster(node) {
|
function renderRoster(node) {
|
||||||
const wrap = document.getElementById('roster-wrap');
|
const wrap = document.getElementById('roster-wrap');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
let rows = node.roster || [];
|
let rows = [...(node.roster || [])];
|
||||||
|
if (hardwareSource === 'orchestrator') {
|
||||||
|
// Mock containers may come from an older local image. Current samplers are
|
||||||
|
// engine tasks; do not leak legacy sampler actors into the simulated roster.
|
||||||
|
rows = rows.filter(actor => !isLegacyHardwareSampler(actor));
|
||||||
|
const actorCount = document.querySelector('[data-actor-count]');
|
||||||
|
if (actorCount) actorCount.textContent = fmt(rows.length);
|
||||||
|
}
|
||||||
if (rosterFilter) {
|
if (rosterFilter) {
|
||||||
const q = rosterFilter.toLowerCase();
|
const q = rosterFilter.toLowerCase();
|
||||||
rows = rows.filter(a => `${a.name || ''} ${a.actor_type || ''} ${a.address} ${a.worker_id ?? ''}`.toLowerCase().includes(q));
|
rows = rows.filter(a => `${a.name || ''} ${a.actor_type || ''} ${a.address} ${a.worker_id ?? ''}`.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
|
rows.sort((left, right) => {
|
||||||
|
const compared = compareRosterValues(left[rosterSort.key], right[rosterSort.key]);
|
||||||
|
return (rosterSort.direction === 'asc' ? compared : -compared)
|
||||||
|
|| String(left.address).localeCompare(String(right.address));
|
||||||
|
});
|
||||||
const capped = rows.slice(0, ROSTER_RENDER_CAP);
|
const capped = rows.slice(0, ROSTER_RENDER_CAP);
|
||||||
const notice = rows.length > capped.length
|
const notice = rows.length > capped.length
|
||||||
? `<div class="notice">showing ${fmt(capped.length)} of ${fmt(rows.length)} — refine the filter to see more</div>`
|
? `<div class="notice">showing ${fmt(capped.length)} of ${fmt(rows.length)} — refine the filter to see more</div>`
|
||||||
|
|
@ -417,7 +605,7 @@ function renderRoster(node) {
|
||||||
html = '<div class="empty">No actors on this stream (or none match the filter).</div>';
|
html = '<div class="empty">No actors on this stream (or none match the filter).</div>';
|
||||||
} else {
|
} else {
|
||||||
html = notice + `<table>
|
html = notice + `<table>
|
||||||
<thead><tr><th>Actor</th><th>Type</th><th>State</th><th>Mailbox</th><th>Msg/s</th><th>Processed</th><th>Worker</th><th>Last message</th></tr></thead>
|
<thead><tr><th>Actor${rosterSortControls('address', 'actor')}</th><th>Type${rosterSortControls('actor_type', 'type')}</th><th>State${rosterSortControls('poisoned', 'state')}</th><th>Mailbox${rosterSortControls('mailbox_depth', 'mailbox')}</th><th>Msg/s${rosterSortControls('msg_per_sec', 'throughput')}</th><th>Processed${rosterSortControls('messages_processed', 'processed count')}</th><th>Worker${rosterSortControls('worker_id', 'worker')}</th><th>Last message${rosterSortControls('last_msg_type', 'last message')}</th></tr></thead>
|
||||||
<tbody>${capped.map(a => `<tr data-addr="${esc(a.address)}" tabindex="0" ${a.address === selectedActor ? 'data-selected="true"' : ''}>
|
<tbody>${capped.map(a => `<tr data-addr="${esc(a.address)}" tabindex="0" ${a.address === selectedActor ? 'data-selected="true"' : ''}>
|
||||||
<td class="mono">${esc(shortAddr(a.address))}${a.name ? `<br><span class="muted">${esc(a.name)}</span>` : ''}</td>
|
<td class="mono">${esc(shortAddr(a.address))}${a.name ? `<br><span class="muted">${esc(a.name)}</span>` : ''}</td>
|
||||||
<td><span class="muted" title="${esc(a.actor_type || '')}">${typeShort(a.actor_type)}</span></td>
|
<td><span class="muted" title="${esc(a.actor_type || '')}">${typeShort(a.actor_type)}</span></td>
|
||||||
|
|
@ -552,6 +740,12 @@ pageEl.addEventListener('click', e => {
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sortButton = e.target.closest('.roster-sort-button');
|
||||||
|
if (sortButton) {
|
||||||
|
rosterSort = { key: sortButton.dataset.sort, direction: sortButton.dataset.direction };
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rosterRow = e.target.closest('tr[data-addr]');
|
const rosterRow = e.target.closest('tr[data-addr]');
|
||||||
if (rosterRow) {
|
if (rosterRow) {
|
||||||
selectedActor = rosterRow.getAttribute('data-addr');
|
selectedActor = rosterRow.getAttribute('data-addr');
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ use telemetry::hardware::cpu::{
|
||||||
use telemetry::hardware::gpu::{
|
use telemetry::hardware::gpu::{
|
||||||
GpuDeviceSample, GpuProcessSample, HOST_GPU_CHANNEL, HostGpuSample,
|
GpuDeviceSample, GpuProcessSample, HOST_GPU_CHANNEL, HostGpuSample,
|
||||||
};
|
};
|
||||||
|
use telemetry::hardware::memory::{HOST_MEMORY_CHANNEL, HostMemorySample};
|
||||||
use telemetry::hardware::net::{HOST_NET_CHANNEL, HostNetSample, NetInterfaceSample};
|
use telemetry::hardware::net::{HOST_NET_CHANNEL, HostNetSample, NetInterfaceSample};
|
||||||
|
use telemetry::hardware::storage::{HOST_STORAGE_CHANNEL, HostStorageSample};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
|
@ -27,7 +29,9 @@ pub(crate) struct NodeHardwareState {
|
||||||
decode_errors: BTreeMap<&'static str, String>,
|
decode_errors: BTreeMap<&'static str, String>,
|
||||||
pub(crate) cpu: Option<HostCpuSample>,
|
pub(crate) cpu: Option<HostCpuSample>,
|
||||||
pub(crate) gpu: Option<HostGpuSample>,
|
pub(crate) gpu: Option<HostGpuSample>,
|
||||||
|
pub(crate) memory: Option<HostMemorySample>,
|
||||||
pub(crate) net: Option<NetSnapshot>,
|
pub(crate) net: Option<NetSnapshot>,
|
||||||
|
pub(crate) storage: Option<HostStorageSample>,
|
||||||
pub(crate) process: Option<ProcessSnapshot>,
|
pub(crate) process: Option<ProcessSnapshot>,
|
||||||
pub(crate) history: VecDeque<HardwareHistoryState>,
|
pub(crate) history: VecDeque<HardwareHistoryState>,
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +43,9 @@ impl NodeHardwareState {
|
||||||
decode_errors: BTreeMap::new(),
|
decode_errors: BTreeMap::new(),
|
||||||
cpu: None,
|
cpu: None,
|
||||||
gpu: None,
|
gpu: None,
|
||||||
|
memory: None,
|
||||||
net: None,
|
net: None,
|
||||||
|
storage: None,
|
||||||
process: None,
|
process: None,
|
||||||
history: VecDeque::with_capacity(HISTORY_CAP),
|
history: VecDeque::with_capacity(HISTORY_CAP),
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +70,14 @@ impl NodeHardwareState {
|
||||||
}
|
}
|
||||||
Err(error) => self.store_decode_error(HOST_GPU_CHANNEL, error),
|
Err(error) => self.store_decode_error(HOST_GPU_CHANNEL, error),
|
||||||
},
|
},
|
||||||
|
HOST_MEMORY_CHANNEL => match HostMemorySample::decode(payload) {
|
||||||
|
Ok(sample) => {
|
||||||
|
self.memory = Some(sample);
|
||||||
|
self.decode_errors.remove(HOST_MEMORY_CHANNEL);
|
||||||
|
self.update_history(now);
|
||||||
|
}
|
||||||
|
Err(error) => self.store_decode_error(HOST_MEMORY_CHANNEL, error),
|
||||||
|
},
|
||||||
HOST_NET_CHANNEL => match HostNetSample::decode(payload) {
|
HOST_NET_CHANNEL => match HostNetSample::decode(payload) {
|
||||||
Ok(sample) => {
|
Ok(sample) => {
|
||||||
self.net = Some(NetSnapshot::from_sample(sample, self.net.as_ref()));
|
self.net = Some(NetSnapshot::from_sample(sample, self.net.as_ref()));
|
||||||
|
|
@ -72,6 +86,14 @@ impl NodeHardwareState {
|
||||||
}
|
}
|
||||||
Err(error) => self.store_decode_error(HOST_NET_CHANNEL, error),
|
Err(error) => self.store_decode_error(HOST_NET_CHANNEL, error),
|
||||||
},
|
},
|
||||||
|
HOST_STORAGE_CHANNEL => match HostStorageSample::decode(payload) {
|
||||||
|
Ok(sample) => {
|
||||||
|
self.storage = Some(sample);
|
||||||
|
self.decode_errors.remove(HOST_STORAGE_CHANNEL);
|
||||||
|
self.update_history(now);
|
||||||
|
}
|
||||||
|
Err(error) => self.store_decode_error(HOST_STORAGE_CHANNEL, error),
|
||||||
|
},
|
||||||
_ => {
|
_ => {
|
||||||
if channel.starts_with("proc.") && channel.ends_with(".lifecycle") {
|
if channel.starts_with("proc.") && channel.ends_with(".lifecycle") {
|
||||||
self.process = decode_process_snapshot(payload);
|
self.process = decode_process_snapshot(payload);
|
||||||
|
|
@ -99,6 +121,35 @@ impl NodeHardwareState {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|sample| sample.host.as_ref())
|
.and_then(|sample| sample.host.as_ref())
|
||||||
.and_then(|host| host.total_percent);
|
.and_then(|host| host.total_percent);
|
||||||
|
let cpu_cores_percent = self
|
||||||
|
.cpu
|
||||||
|
.as_ref()
|
||||||
|
.map(|sample| {
|
||||||
|
sample
|
||||||
|
.cores
|
||||||
|
.iter()
|
||||||
|
.map(|core| core.total_percent)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let memory_used_percent = self.memory.as_ref().and_then(|sample| {
|
||||||
|
Some(sample.used_bytes? as f64 * 100.0 / sample.total_bytes?.max(1) as f64)
|
||||||
|
});
|
||||||
|
let memory_pressure_some_avg10 = self
|
||||||
|
.memory
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sample| sample.pressure.as_ref())
|
||||||
|
.map(|pressure| pressure.some_avg10);
|
||||||
|
let storage_used_percent = self
|
||||||
|
.storage
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sample| sample.filesystems.first())
|
||||||
|
.and_then(|filesystem| filesystem.used_percent);
|
||||||
|
let io_pressure_some_avg10 = self
|
||||||
|
.storage
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sample| sample.pressure.as_ref())
|
||||||
|
.map(|pressure| pressure.some_avg10);
|
||||||
|
|
||||||
let mut gpu_max_percent = None;
|
let mut gpu_max_percent = None;
|
||||||
let mut gpu_memory_used_mib = 0_u64;
|
let mut gpu_memory_used_mib = 0_u64;
|
||||||
|
|
@ -128,17 +179,24 @@ impl NodeHardwareState {
|
||||||
sample_unix_ms: [
|
sample_unix_ms: [
|
||||||
self.cpu.as_ref().map(|sample| sample.sample_unix_ms),
|
self.cpu.as_ref().map(|sample| sample.sample_unix_ms),
|
||||||
self.gpu.as_ref().map(|sample| sample.sample_unix_ms),
|
self.gpu.as_ref().map(|sample| sample.sample_unix_ms),
|
||||||
|
self.memory.as_ref().map(|sample| sample.sample_unix_ms),
|
||||||
self.net.as_ref().map(|sample| sample.sample_unix_ms),
|
self.net.as_ref().map(|sample| sample.sample_unix_ms),
|
||||||
|
self.storage.as_ref().map(|sample| sample.sample_unix_ms),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.max(),
|
.max(),
|
||||||
cpu_total_percent,
|
cpu_total_percent,
|
||||||
|
cpu_cores_percent,
|
||||||
gpu_max_percent,
|
gpu_max_percent,
|
||||||
gpu_memory_used_mib,
|
gpu_memory_used_mib,
|
||||||
gpu_memory_total_mib,
|
gpu_memory_total_mib,
|
||||||
net_rx_bps,
|
net_rx_bps,
|
||||||
net_tx_bps,
|
net_tx_bps,
|
||||||
|
memory_used_percent,
|
||||||
|
memory_pressure_some_avg10,
|
||||||
|
storage_used_percent,
|
||||||
|
io_pressure_some_avg10,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,11 +207,16 @@ impl NodeHardwareState {
|
||||||
{
|
{
|
||||||
last.sample_unix_ms = summary.sample_unix_ms;
|
last.sample_unix_ms = summary.sample_unix_ms;
|
||||||
last.cpu_total_percent = summary.cpu_total_percent;
|
last.cpu_total_percent = summary.cpu_total_percent;
|
||||||
|
last.cpu_cores_percent = summary.cpu_cores_percent.clone();
|
||||||
last.gpu_max_percent = summary.gpu_max_percent;
|
last.gpu_max_percent = summary.gpu_max_percent;
|
||||||
last.gpu_memory_used_mib = summary.gpu_memory_used_mib;
|
last.gpu_memory_used_mib = summary.gpu_memory_used_mib;
|
||||||
last.gpu_memory_total_mib = summary.gpu_memory_total_mib;
|
last.gpu_memory_total_mib = summary.gpu_memory_total_mib;
|
||||||
last.net_rx_bps = summary.net_rx_bps;
|
last.net_rx_bps = summary.net_rx_bps;
|
||||||
last.net_tx_bps = summary.net_tx_bps;
|
last.net_tx_bps = summary.net_tx_bps;
|
||||||
|
last.memory_used_percent = summary.memory_used_percent;
|
||||||
|
last.memory_pressure_some_avg10 = summary.memory_pressure_some_avg10;
|
||||||
|
last.storage_used_percent = summary.storage_used_percent;
|
||||||
|
last.io_pressure_some_avg10 = summary.io_pressure_some_avg10;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,11 +227,16 @@ impl NodeHardwareState {
|
||||||
at: now,
|
at: now,
|
||||||
sample_unix_ms: summary.sample_unix_ms,
|
sample_unix_ms: summary.sample_unix_ms,
|
||||||
cpu_total_percent: summary.cpu_total_percent,
|
cpu_total_percent: summary.cpu_total_percent,
|
||||||
|
cpu_cores_percent: summary.cpu_cores_percent,
|
||||||
gpu_max_percent: summary.gpu_max_percent,
|
gpu_max_percent: summary.gpu_max_percent,
|
||||||
gpu_memory_used_mib: summary.gpu_memory_used_mib,
|
gpu_memory_used_mib: summary.gpu_memory_used_mib,
|
||||||
gpu_memory_total_mib: summary.gpu_memory_total_mib,
|
gpu_memory_total_mib: summary.gpu_memory_total_mib,
|
||||||
net_rx_bps: summary.net_rx_bps,
|
net_rx_bps: summary.net_rx_bps,
|
||||||
net_tx_bps: summary.net_tx_bps,
|
net_tx_bps: summary.net_tx_bps,
|
||||||
|
memory_used_percent: summary.memory_used_percent,
|
||||||
|
memory_pressure_some_avg10: summary.memory_pressure_some_avg10,
|
||||||
|
storage_used_percent: summary.storage_used_percent,
|
||||||
|
io_pressure_some_avg10: summary.io_pressure_some_avg10,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,9 +248,23 @@ impl NodeHardwareState {
|
||||||
if let Some(error) = self.gpu.as_ref().and_then(|sample| sample.error.as_ref()) {
|
if let Some(error) = self.gpu.as_ref().and_then(|sample| sample.error.as_ref()) {
|
||||||
errors.push(format!("{HOST_GPU_CHANNEL}: {error}"));
|
errors.push(format!("{HOST_GPU_CHANNEL}: {error}"));
|
||||||
}
|
}
|
||||||
|
if let Some(error) = self
|
||||||
|
.memory
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sample| sample.error.as_ref())
|
||||||
|
{
|
||||||
|
errors.push(format!("{HOST_MEMORY_CHANNEL}: {error}"));
|
||||||
|
}
|
||||||
if let Some(error) = self.net.as_ref().and_then(|sample| sample.error.as_ref()) {
|
if let Some(error) = self.net.as_ref().and_then(|sample| sample.error.as_ref()) {
|
||||||
errors.push(format!("{HOST_NET_CHANNEL}: {error}"));
|
errors.push(format!("{HOST_NET_CHANNEL}: {error}"));
|
||||||
}
|
}
|
||||||
|
if let Some(error) = self
|
||||||
|
.storage
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sample| sample.error.as_ref())
|
||||||
|
{
|
||||||
|
errors.push(format!("{HOST_STORAGE_CHANNEL}: {error}"));
|
||||||
|
}
|
||||||
errors
|
errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -308,26 +390,36 @@ impl NetInterfaceSnapshot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct HardwareSummary {
|
pub(crate) struct HardwareSummary {
|
||||||
pub(crate) sample_unix_ms: Option<u64>,
|
pub(crate) sample_unix_ms: Option<u64>,
|
||||||
pub(crate) cpu_total_percent: Option<f64>,
|
pub(crate) cpu_total_percent: Option<f64>,
|
||||||
|
pub(crate) cpu_cores_percent: Vec<Option<f64>>,
|
||||||
pub(crate) gpu_max_percent: Option<u64>,
|
pub(crate) gpu_max_percent: Option<u64>,
|
||||||
pub(crate) gpu_memory_used_mib: u64,
|
pub(crate) gpu_memory_used_mib: u64,
|
||||||
pub(crate) gpu_memory_total_mib: u64,
|
pub(crate) gpu_memory_total_mib: u64,
|
||||||
pub(crate) net_rx_bps: f64,
|
pub(crate) net_rx_bps: f64,
|
||||||
pub(crate) net_tx_bps: f64,
|
pub(crate) net_tx_bps: f64,
|
||||||
|
pub(crate) memory_used_percent: Option<f64>,
|
||||||
|
pub(crate) memory_pressure_some_avg10: Option<f64>,
|
||||||
|
pub(crate) storage_used_percent: Option<f64>,
|
||||||
|
pub(crate) io_pressure_some_avg10: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct HardwareHistoryState {
|
pub(crate) struct HardwareHistoryState {
|
||||||
pub(crate) at: Instant,
|
pub(crate) at: Instant,
|
||||||
pub(crate) sample_unix_ms: Option<u64>,
|
pub(crate) sample_unix_ms: Option<u64>,
|
||||||
pub(crate) cpu_total_percent: Option<f64>,
|
pub(crate) cpu_total_percent: Option<f64>,
|
||||||
|
pub(crate) cpu_cores_percent: Vec<Option<f64>>,
|
||||||
pub(crate) gpu_max_percent: Option<u64>,
|
pub(crate) gpu_max_percent: Option<u64>,
|
||||||
pub(crate) gpu_memory_used_mib: u64,
|
pub(crate) gpu_memory_used_mib: u64,
|
||||||
pub(crate) gpu_memory_total_mib: u64,
|
pub(crate) gpu_memory_total_mib: u64,
|
||||||
pub(crate) net_rx_bps: f64,
|
pub(crate) net_rx_bps: f64,
|
||||||
pub(crate) net_tx_bps: f64,
|
pub(crate) net_tx_bps: f64,
|
||||||
|
pub(crate) memory_used_percent: Option<f64>,
|
||||||
|
pub(crate) memory_pressure_some_avg10: Option<f64>,
|
||||||
|
pub(crate) storage_used_percent: Option<f64>,
|
||||||
|
pub(crate) io_pressure_some_avg10: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -383,11 +475,16 @@ pub(crate) struct HardwareHistorySnapshot {
|
||||||
pub(crate) ms_ago: u64,
|
pub(crate) ms_ago: u64,
|
||||||
pub(crate) sample_unix_ms: Option<u64>,
|
pub(crate) sample_unix_ms: Option<u64>,
|
||||||
pub(crate) cpu_total_percent: Option<f64>,
|
pub(crate) cpu_total_percent: Option<f64>,
|
||||||
|
pub(crate) cpu_cores_percent: Vec<Option<f64>>,
|
||||||
pub(crate) gpu_max_percent: Option<u64>,
|
pub(crate) gpu_max_percent: Option<u64>,
|
||||||
pub(crate) gpu_memory_used_mib: u64,
|
pub(crate) gpu_memory_used_mib: u64,
|
||||||
pub(crate) gpu_memory_total_mib: u64,
|
pub(crate) gpu_memory_total_mib: u64,
|
||||||
pub(crate) net_rx_bps: f64,
|
pub(crate) net_rx_bps: f64,
|
||||||
pub(crate) net_tx_bps: f64,
|
pub(crate) net_tx_bps: f64,
|
||||||
|
pub(crate) memory_used_percent: Option<f64>,
|
||||||
|
pub(crate) memory_pressure_some_avg10: Option<f64>,
|
||||||
|
pub(crate) storage_used_percent: Option<f64>,
|
||||||
|
pub(crate) io_pressure_some_avg10: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn duration_ms(duration: Duration) -> u64 {
|
pub(crate) fn duration_ms(duration: Duration) -> u64 {
|
||||||
|
|
|
||||||
|
|
@ -469,7 +469,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_valid_for_route(&self) -> bool {
|
fn is_valid_for_route(&self) -> bool {
|
||||||
self.payload % 5 == 0
|
self.payload.is_multiple_of(5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
//! out. Receipts are bounded and interval-spaced so noisy actors cannot flood
|
//! out. Receipts are bounded and interval-spaced so noisy actors cannot flood
|
||||||
//! the page.
|
//! the page.
|
||||||
|
|
||||||
|
use std::cmp::Reverse;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
|
@ -172,8 +173,10 @@ impl RuntimeState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn totals(&self) -> Totals {
|
pub(crate) fn totals(&self) -> Totals {
|
||||||
let mut totals = Totals::default();
|
let mut totals = Totals {
|
||||||
totals.actors = self.actors.len().min(u32::MAX as usize) as u32;
|
actors: self.actors.len().min(u32::MAX as usize) as u32,
|
||||||
|
..Totals::default()
|
||||||
|
};
|
||||||
for actor in self.actors.values() {
|
for actor in self.actors.values() {
|
||||||
totals.mailbox_depth = totals.mailbox_depth.saturating_add(actor.mailbox_depth);
|
totals.mailbox_depth = totals.mailbox_depth.saturating_add(actor.mailbox_depth);
|
||||||
totals.msg_per_sec += actor.msg_per_sec;
|
totals.msg_per_sec += actor.msg_per_sec;
|
||||||
|
|
@ -449,7 +452,7 @@ fn parse_message_type_counts(value: Option<&Value>) -> Option<Vec<(String, u64)>
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(name, count)| value_to_u64(count).map(|count| (name.clone(), count)))
|
.filter_map(|(name, count)| value_to_u64(count).map(|count| (name.clone(), count)))
|
||||||
.collect();
|
.collect();
|
||||||
out.sort_by(|a, b| b.1.cmp(&a.1));
|
out.sort_by_key(|&(_, count)| Reverse(count));
|
||||||
return Some(out);
|
return Some(out);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ publish = false
|
||||||
[dependencies]
|
[dependencies]
|
||||||
telemetry = { path = "../telemetry" }
|
telemetry = { path = "../telemetry" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
swactor = { path = "../..", features = ["serde", "transport"] }
|
swactor = { path = "../..", features = ["serde", "transport"] }
|
||||||
swactor-transport = { path = "../transport" }
|
swactor-transport = { path = "../transport" }
|
||||||
|
|
@ -17,6 +18,10 @@ swactor-engine = { path = "../engine" }
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
futures-lite = "2"
|
futures-lite = "2"
|
||||||
|
proptest = "1"
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -345,11 +345,11 @@ impl ArenaManager {
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.pending.is_empty() {
|
if self.pending.is_empty()
|
||||||
if let Some(lease) = self.try_allocate(&request) {
|
&& let Some(lease) = self.try_allocate(&request)
|
||||||
|
{
|
||||||
return vec![ArenaEvent::RingLeased { lease }];
|
return vec![ArenaEvent::RingLeased { lease }];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let request_id = request.request_id;
|
let request_id = request.request_id;
|
||||||
self.pending.push_back(QueuedLease {
|
self.pending.push_back(QueuedLease {
|
||||||
|
|
@ -517,12 +517,12 @@ impl ArenaManager {
|
||||||
if range.start == range.end {
|
if range.start == range.end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(last) = coalesced.last_mut() {
|
if let Some(last) = coalesced.last_mut()
|
||||||
if range.start <= last.end {
|
&& range.start <= last.end
|
||||||
|
{
|
||||||
last.end = last.end.max(range.end);
|
last.end = last.end.max(range.end);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
coalesced.push(range);
|
coalesced.push(range);
|
||||||
}
|
}
|
||||||
self.free_ranges = coalesced;
|
self.free_ranges = coalesced;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::ptr::NonNull;
|
use std::ptr::NonNull;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
|
@ -25,6 +26,21 @@ const DIGEST_OFFSET: usize = 32;
|
||||||
const DIGEST_LEN: usize = 32;
|
const DIGEST_LEN: usize = 32;
|
||||||
const RESERVED_OFFSET: usize = 64;
|
const RESERVED_OFFSET: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct FileRegistration {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileRegistration {
|
||||||
|
pub(crate) fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file(path: impl Into<PathBuf>) -> FileRegistration {
|
||||||
|
FileRegistration { path: path.into() }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum BlobAccess {
|
pub enum BlobAccess {
|
||||||
|
|
@ -89,20 +105,6 @@ pub enum BlobSharedState {
|
||||||
Released = 5,
|
Released = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlobSharedState {
|
|
||||||
fn from_u64(value: u64) -> Option<Self> {
|
|
||||||
match value {
|
|
||||||
0 => Some(Self::Vacant),
|
|
||||||
1 => Some(Self::Filling),
|
|
||||||
2 => Some(Self::Writable),
|
|
||||||
3 => Some(Self::Sealed),
|
|
||||||
4 => Some(Self::Aborted),
|
|
||||||
5 => Some(Self::Released),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum BlobError {
|
pub enum BlobError {
|
||||||
RangeOutOfBounds {
|
RangeOutOfBounds {
|
||||||
|
|
@ -249,7 +251,7 @@ impl Blob {
|
||||||
self.guard.lease
|
self.guard.lease
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn map(&self) -> Result<ArenaView, BlobError> {
|
pub fn map(&self) -> Result<BlobView, BlobError> {
|
||||||
validate_mapped_header(
|
validate_mapped_header(
|
||||||
&self.guard.arena,
|
&self.guard.arena,
|
||||||
self.guard.lease,
|
self.guard.lease,
|
||||||
|
|
@ -264,7 +266,7 @@ impl Blob {
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
let range = mapped_range(&self.guard.arena, payload_offset, self.guard.lease.length)?;
|
let range = mapped_range(&self.guard.arena, payload_offset, self.guard.lease.length)?;
|
||||||
Ok(ArenaView {
|
Ok(BlobView {
|
||||||
guard: self.guard.clone(),
|
guard: self.guard.clone(),
|
||||||
payload_offset: range.start,
|
payload_offset: range.start,
|
||||||
length: range.len(),
|
length: range.len(),
|
||||||
|
|
@ -272,13 +274,13 @@ impl Blob {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ArenaView {
|
pub struct BlobView {
|
||||||
guard: Arc<BlobLeaseGuard>,
|
guard: Arc<BlobLeaseGuard>,
|
||||||
payload_offset: usize,
|
payload_offset: usize,
|
||||||
length: usize,
|
length: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArenaView {
|
impl BlobView {
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.length
|
self.length
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +298,7 @@ impl ArenaView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsRef<[u8]> for ArenaView {
|
impl AsRef<[u8]> for BlobView {
|
||||||
fn as_ref(&self) -> &[u8] {
|
fn as_ref(&self) -> &[u8] {
|
||||||
// SAFETY: construction bounds-checks the range, the arena mapping is
|
// SAFETY: construction bounds-checks the range, the arena mapping is
|
||||||
// stable, and the shared guard prevents lease reuse while this view is
|
// stable, and the shared guard prevents lease reuse while this view is
|
||||||
|
|
@ -305,7 +307,7 @@ impl AsRef<[u8]> for ArenaView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deref for ArenaView {
|
impl Deref for BlobView {
|
||||||
type Target = [u8];
|
type Target = [u8];
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
fn deref(&self) -> &Self::Target {
|
||||||
|
|
@ -484,38 +486,6 @@ impl Drop for WritableArenaView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn install_read_blob(
|
|
||||||
arena: &ArenaManager,
|
|
||||||
allocation: &RingLease,
|
|
||||||
generation: u64,
|
|
||||||
bytes: &[u8],
|
|
||||||
digest: Option<ContentDigest>,
|
|
||||||
) -> Result<(BlobLease, BlobMetadata), BlobError> {
|
|
||||||
let length = u64::try_from(bytes.len()).map_err(|_| BlobError::InvalidHostLease)?;
|
|
||||||
let lease = descriptor_from_allocation(allocation, generation, length, BlobAccess::ReadOnly)?;
|
|
||||||
let metadata = BlobMetadata { length, digest };
|
|
||||||
let header = host_header_ptr(arena, lease)?;
|
|
||||||
write_header(header, lease, &metadata, BlobSharedState::Filling);
|
|
||||||
|
|
||||||
if let Some(expected) = &metadata.digest {
|
|
||||||
if !expected.matches(bytes) {
|
|
||||||
host_state(arena, lease)?.store(BlobSharedState::Aborted as u64, Ordering::Release);
|
|
||||||
return Err(BlobError::DigestMismatch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = arena
|
|
||||||
.region_ptr(lease.offset + BLOB_HEADER_LEN, length)
|
|
||||||
.ok_or(BlobError::InvalidHostLease)?;
|
|
||||||
// SAFETY: the allocation owns exactly `length` payload bytes and `bytes`
|
|
||||||
// has that same length. This is the only host-to-child process-boundary copy.
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), payload.as_ptr(), bytes.len());
|
|
||||||
}
|
|
||||||
host_state(arena, lease)?.store(BlobSharedState::Sealed as u64, Ordering::Release);
|
|
||||||
Ok((lease, metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn install_filling_read_blob(
|
pub(crate) fn install_filling_read_blob(
|
||||||
arena: &ArenaManager,
|
arena: &ArenaManager,
|
||||||
allocation: &RingLease,
|
allocation: &RingLease,
|
||||||
|
|
@ -727,7 +697,7 @@ fn validate_header(
|
||||||
length: lease.length,
|
length: lease.length,
|
||||||
arena_size,
|
arena_size,
|
||||||
})?;
|
})?;
|
||||||
if state_address as usize % std::mem::align_of::<AtomicU64>() != 0 {
|
if !(state_address as usize).is_multiple_of(std::mem::align_of::<AtomicU64>()) {
|
||||||
return Err(BlobError::UnalignedHeader {
|
return Err(BlobError::UnalignedHeader {
|
||||||
offset: lease.offset,
|
offset: lease.offset,
|
||||||
});
|
});
|
||||||
|
|
@ -808,7 +778,7 @@ fn validate_header(
|
||||||
fn mapped_state(arena: &MappedArena, lease: BlobLease) -> Result<&AtomicU64, BlobError> {
|
fn mapped_state(arena: &MappedArena, lease: BlobLease) -> Result<&AtomicU64, BlobError> {
|
||||||
let range = mapped_range(arena, lease.offset, BLOB_HEADER_LEN + lease.length)?;
|
let range = mapped_range(arena, lease.offset, BLOB_HEADER_LEN + lease.length)?;
|
||||||
let state_offset = range.start + STATE_OFFSET;
|
let state_offset = range.start + STATE_OFFSET;
|
||||||
if state_offset % std::mem::align_of::<AtomicU64>() != 0 {
|
if !state_offset.is_multiple_of(std::mem::align_of::<AtomicU64>()) {
|
||||||
return Err(BlobError::UnalignedHeader {
|
return Err(BlobError::UnalignedHeader {
|
||||||
offset: lease.offset,
|
offset: lease.offset,
|
||||||
});
|
});
|
||||||
|
|
@ -826,7 +796,7 @@ fn host_header_ptr(arena: &ArenaManager, lease: BlobLease) -> Result<NonNull<u8>
|
||||||
fn host_state(arena: &ArenaManager, lease: BlobLease) -> Result<&AtomicU64, BlobError> {
|
fn host_state(arena: &ArenaManager, lease: BlobLease) -> Result<&AtomicU64, BlobError> {
|
||||||
let header = host_header_ptr(arena, lease)?;
|
let header = host_header_ptr(arena, lease)?;
|
||||||
let address = lease.offset + STATE_OFFSET as u64;
|
let address = lease.offset + STATE_OFFSET as u64;
|
||||||
if address as usize % std::mem::align_of::<AtomicU64>() != 0 {
|
if !(address as usize).is_multiple_of(std::mem::align_of::<AtomicU64>()) {
|
||||||
return Err(BlobError::UnalignedHeader {
|
return Err(BlobError::UnalignedHeader {
|
||||||
offset: lease.offset,
|
offset: lease.offset,
|
||||||
});
|
});
|
||||||
|
|
@ -860,8 +830,3 @@ fn checked_raw_range(arena_size: u64, offset: u64, length: u64) -> Result<(), Bl
|
||||||
arena_size,
|
arena_size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn state_name(value: u64) -> Option<BlobSharedState> {
|
|
||||||
BlobSharedState::from_u64(value)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
103
crates/data-plane/src/blob_transfer.rs
Normal file
103
crates/data-plane/src/blob_transfer.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
//! Transport-neutral contracts for one-shot fixed-length blob transfers.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
use crate::blob::{BlobLease, BlobMetadata};
|
||||||
|
use crate::protocol::DataPlaneError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor_transport::{CodecRegistry, JsonCodec, NetworkMessage};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct BlobTransferId(pub u64);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BlobTransferOffer {
|
||||||
|
pub transfer_id: BlobTransferId,
|
||||||
|
pub destination: ActorAddress,
|
||||||
|
pub failure_proxy: Option<ActorAddress>,
|
||||||
|
pub transport: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum BlobTransferEvent {
|
||||||
|
Chunk {
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
Finished {
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
},
|
||||||
|
Failed {
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
Allocated(Result<(BlobLease, BlobMetadata), DataPlaneError>),
|
||||||
|
AllocatorFailed(DataPlaneError),
|
||||||
|
Sealed(Result<(), DataPlaneError>),
|
||||||
|
Released(Result<(), DataPlaneError>),
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkMessage for BlobTransferEvent {
|
||||||
|
fn type_tag() -> &'static str {
|
||||||
|
"data-plane.blob-transfer.event.v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait BlobTransferCompletion: Send + 'static {
|
||||||
|
fn complete(self: Box<Self>, result: Result<(), String>);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileTransferRequest {
|
||||||
|
pub offer: BlobTransferOffer,
|
||||||
|
pub file: File,
|
||||||
|
pub offset: u64,
|
||||||
|
pub length: u64,
|
||||||
|
pub completion: Box<dyn BlobTransferCompletion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait BlobTransferSender: Send + Sync + 'static {
|
||||||
|
fn start_file(&self, request: FileTransferRequest) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait BlobTransferReceiver: Send + Sync + 'static {
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
destination: ActorAddress,
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
) -> Result<BlobTransferOffer, String>;
|
||||||
|
|
||||||
|
fn cancel(&self, offer: &BlobTransferOffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum BlobTransferFailure {
|
||||||
|
Start(String),
|
||||||
|
Source(String),
|
||||||
|
Transport(String),
|
||||||
|
Length { expected: u64, found: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BlobTransferFailure {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Start(reason) => write!(f, "blob transfer did not start: {reason}"),
|
||||||
|
Self::Source(reason) => write!(f, "blob source failed: {reason}"),
|
||||||
|
Self::Transport(reason) => write!(f, "blob transport failed: {reason}"),
|
||||||
|
Self::Length { expected, found } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"blob transfer length mismatch: expected {expected}, found {found}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for BlobTransferFailure {}
|
||||||
|
|
||||||
|
pub fn register_blob_transfer_codecs(registry: &mut CodecRegistry) {
|
||||||
|
registry.register::<BlobTransferEvent, _>(JsonCodec::default());
|
||||||
|
}
|
||||||
|
|
@ -49,7 +49,10 @@
|
||||||
use std::ptr::NonNull;
|
use std::ptr::NonNull;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use crate::arena::{ArenaEvent, ArenaManager, ArenaRequest, LeaseRing, LeaseRequestId, RingLease, RingLeaseRejection, RingSpec};
|
use crate::arena::{
|
||||||
|
ArenaEvent, ArenaManager, ArenaRequest, LeaseRequestId, LeaseRing, RingLease,
|
||||||
|
RingLeaseRejection, RingSpec,
|
||||||
|
};
|
||||||
|
|
||||||
/// `"SWRG"` read little-endian.
|
/// `"SWRG"` read little-endian.
|
||||||
pub const RING_MAGIC: u32 = u32::from_le_bytes(*b"SWRG");
|
pub const RING_MAGIC: u32 = u32::from_le_bytes(*b"SWRG");
|
||||||
|
|
@ -98,7 +101,6 @@ pub enum Role {
|
||||||
Consumer,
|
Consumer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// A reserved, not-yet-committed span of the data region.
|
/// A reserved, not-yet-committed span of the data region.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct Reservation {
|
pub struct Reservation {
|
||||||
|
|
@ -149,19 +151,42 @@ pub enum InstallError {
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum HeaderError {
|
pub enum HeaderError {
|
||||||
BadMagic { found: u32 },
|
BadMagic {
|
||||||
UnsupportedVersion { found: u16, supported: u16 },
|
found: u32,
|
||||||
ReservedBytesNotZero { at: u64 },
|
},
|
||||||
CapacityMismatch { header: u64, handle: u64 },
|
UnsupportedVersion {
|
||||||
GenerationMismatch { header: u64, handle: u64 },
|
found: u16,
|
||||||
CommitBelowConsume { commit: u64, consume: u64 },
|
supported: u16,
|
||||||
ReadableExceedsCapacity { commit: u64, consume: u64, capacity: u64 },
|
},
|
||||||
|
ReservedBytesNotZero {
|
||||||
|
at: u64,
|
||||||
|
},
|
||||||
|
CapacityMismatch {
|
||||||
|
header: u64,
|
||||||
|
handle: u64,
|
||||||
|
},
|
||||||
|
GenerationMismatch {
|
||||||
|
header: u64,
|
||||||
|
handle: u64,
|
||||||
|
},
|
||||||
|
CommitBelowConsume {
|
||||||
|
commit: u64,
|
||||||
|
consume: u64,
|
||||||
|
},
|
||||||
|
ReadableExceedsCapacity {
|
||||||
|
commit: u64,
|
||||||
|
consume: u64,
|
||||||
|
capacity: u64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum AttachError {
|
pub enum AttachError {
|
||||||
/// The handle itself points outside the arena.
|
/// The handle itself points outside the arena.
|
||||||
OutOfBounds { end: u64, arena_len: u64 },
|
OutOfBounds {
|
||||||
|
end: u64,
|
||||||
|
arena_len: u64,
|
||||||
|
},
|
||||||
Header(HeaderError),
|
Header(HeaderError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,12 +198,24 @@ pub enum RecordError {
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum FlowError {
|
pub enum FlowError {
|
||||||
InsufficientSpace { requested: u64, free: u64 },
|
InsufficientSpace {
|
||||||
BeyondCommitted { requested: u64, readable: u64 },
|
requested: u64,
|
||||||
|
free: u64,
|
||||||
|
},
|
||||||
|
BeyondCommitted {
|
||||||
|
requested: u64,
|
||||||
|
readable: u64,
|
||||||
|
},
|
||||||
/// The reservation's generation no longer matches the ring header.
|
/// The reservation's generation no longer matches the ring header.
|
||||||
StaleReservation { reservation: u64, ring: u64 },
|
StaleReservation {
|
||||||
|
reservation: u64,
|
||||||
|
ring: u64,
|
||||||
|
},
|
||||||
/// Operation reserved for the other role (property P3).
|
/// Operation reserved for the other role (property P3).
|
||||||
RoleViolation { operation: &'static str, role: Role },
|
RoleViolation {
|
||||||
|
operation: &'static str,
|
||||||
|
role: Role,
|
||||||
|
},
|
||||||
/// The header stopped validating mid-protocol (property P5).
|
/// The header stopped validating mid-protocol (property P5).
|
||||||
Corrupt(HeaderError),
|
Corrupt(HeaderError),
|
||||||
BadRecord(RecordError),
|
BadRecord(RecordError),
|
||||||
|
|
@ -289,16 +326,16 @@ pub fn attach(
|
||||||
arena_len: arena.arena_len(),
|
arena_len: arena.arena_len(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let endpoint = Endpoint { header, info: handle, role };
|
let endpoint = Endpoint {
|
||||||
endpoint
|
header,
|
||||||
.validate_fixed()
|
info: handle,
|
||||||
.map_err(AttachError::Header)?;
|
role,
|
||||||
|
};
|
||||||
|
endpoint.validate_fixed().map_err(AttachError::Header)?;
|
||||||
endpoint
|
endpoint
|
||||||
.validate_generation(handle.generation)
|
.validate_generation(handle.generation)
|
||||||
.map_err(AttachError::Header)?;
|
.map_err(AttachError::Header)?;
|
||||||
endpoint
|
endpoint.validate_cursors().map_err(AttachError::Header)?;
|
||||||
.validate_cursors()
|
|
||||||
.map_err(AttachError::Header)?;
|
|
||||||
Ok(endpoint)
|
Ok(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,7 +499,11 @@ impl Endpoint {
|
||||||
// SAFETY: as `copy_into`.
|
// SAFETY: as `copy_into`.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(self.data_ptr().add(start), bytes.as_mut_ptr(), first);
|
std::ptr::copy_nonoverlapping(self.data_ptr().add(start), bytes.as_mut_ptr(), first);
|
||||||
std::ptr::copy_nonoverlapping(self.data_ptr(), bytes.as_mut_ptr().add(first), len - first);
|
std::ptr::copy_nonoverlapping(
|
||||||
|
self.data_ptr(),
|
||||||
|
bytes.as_mut_ptr().add(first),
|
||||||
|
len - first,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
|
|
@ -478,7 +519,10 @@ impl Endpoint {
|
||||||
let used = commit - consume;
|
let used = commit - consume;
|
||||||
let free = self.info.capacity - used;
|
let free = self.info.capacity - used;
|
||||||
if len > free {
|
if len > free {
|
||||||
return Err(FlowError::InsufficientSpace { requested: len, free });
|
return Err(FlowError::InsufficientSpace {
|
||||||
|
requested: len,
|
||||||
|
free,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(Reservation {
|
Ok(Reservation {
|
||||||
start: commit,
|
start: commit,
|
||||||
|
|
@ -492,7 +536,8 @@ impl Endpoint {
|
||||||
self.check("write", Role::Producer)?;
|
self.check("write", Role::Producer)?;
|
||||||
self.stale_check(reservation)?;
|
self.stale_check(reservation)?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reservation.len as usize, bytes.len(),
|
reservation.len as usize,
|
||||||
|
bytes.len(),
|
||||||
"reservation length must match the payload"
|
"reservation length must match the payload"
|
||||||
);
|
);
|
||||||
self.copy_into(reservation.start, bytes);
|
self.copy_into(reservation.start, bytes);
|
||||||
|
|
@ -539,7 +584,10 @@ impl Endpoint {
|
||||||
let (commit, consume) = (self.commit_cursor(), self.consume_cursor());
|
let (commit, consume) = (self.commit_cursor(), self.consume_cursor());
|
||||||
let readable = commit - consume;
|
let readable = commit - consume;
|
||||||
if len > readable {
|
if len > readable {
|
||||||
return Err(FlowError::BeyondCommitted { requested: len, readable });
|
return Err(FlowError::BeyondCommitted {
|
||||||
|
requested: len,
|
||||||
|
readable,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(self.copy_out(consume, len))
|
Ok(self.copy_out(consume, len))
|
||||||
}
|
}
|
||||||
|
|
@ -550,7 +598,10 @@ impl Endpoint {
|
||||||
let (commit, consume) = (self.commit_cursor(), self.consume_cursor());
|
let (commit, consume) = (self.commit_cursor(), self.consume_cursor());
|
||||||
let readable = commit - consume;
|
let readable = commit - consume;
|
||||||
if len > readable {
|
if len > readable {
|
||||||
return Err(FlowError::BeyondCommitted { requested: len, readable });
|
return Err(FlowError::BeyondCommitted {
|
||||||
|
requested: len,
|
||||||
|
readable,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
self.atomic(OFF_CONSUME)
|
self.atomic(OFF_CONSUME)
|
||||||
.store(consume + len, Ordering::Release);
|
.store(consume + len, Ordering::Release);
|
||||||
|
|
|
||||||
158
crates/data-plane/src/control.rs
Normal file
158
crates/data-plane/src/control.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
//! Reusable namespace service lifecycle and public file-registration control.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::runtime::Runtime;
|
||||||
|
|
||||||
|
use crate::blob::FileRegistration;
|
||||||
|
use crate::blob_transfer::BlobTransferSender;
|
||||||
|
use crate::namespace::{DataDirectoryActor, DirectoryClient, NamespaceError, OperationId};
|
||||||
|
use crate::namespace_store::SourceRecovery;
|
||||||
|
use crate::path::DataPath;
|
||||||
|
use crate::source::{BlobSourceIn, BlobSourcePublisher, FileBlobSourceActor};
|
||||||
|
|
||||||
|
pub struct DataNamespaceService {
|
||||||
|
directory: ActorAddress,
|
||||||
|
control: DataPlaneControl,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataNamespaceService {
|
||||||
|
pub fn recover(
|
||||||
|
runtime: Runtime,
|
||||||
|
store_path: impl AsRef<Path>,
|
||||||
|
source_sender: Arc<dyn BlobTransferSender>,
|
||||||
|
source_publisher: Arc<dyn BlobSourcePublisher>,
|
||||||
|
) -> Result<Self, NamespaceError> {
|
||||||
|
let recovery_runtime = runtime.clone();
|
||||||
|
let recovery_sender = Arc::clone(&source_sender);
|
||||||
|
let recovery_publisher = Arc::clone(&source_publisher);
|
||||||
|
let directory = DataDirectoryActor::recover(
|
||||||
|
store_path,
|
||||||
|
move |recovery, expected_length| match recovery {
|
||||||
|
SourceRecovery::File { path } => {
|
||||||
|
let source = FileBlobSourceActor::recover(
|
||||||
|
recovery_runtime.clone(),
|
||||||
|
Arc::clone(&recovery_sender),
|
||||||
|
path,
|
||||||
|
expected_length,
|
||||||
|
)?;
|
||||||
|
let source = recovery_runtime.spawn(source).map_err(|error| {
|
||||||
|
NamespaceError::SourceRecovery(format!(
|
||||||
|
"spawn recovered file source {}: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if let Err(error) = recovery_publisher.publish_source(source) {
|
||||||
|
let _ = recovery_runtime.send_to(source, BlobSourceIn::Retire);
|
||||||
|
return Err(NamespaceError::SourceRecovery(error));
|
||||||
|
}
|
||||||
|
Ok(source)
|
||||||
|
}
|
||||||
|
SourceRecovery::Actor { actor } => Ok(*actor),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let directory = runtime.spawn(directory).map_err(|error| {
|
||||||
|
NamespaceError::SourceRecovery(format!("spawn data directory: {error}"))
|
||||||
|
})?;
|
||||||
|
let control = DataPlaneControl {
|
||||||
|
runtime: runtime.clone(),
|
||||||
|
directory: DirectoryClient::new(runtime, directory),
|
||||||
|
source_sender,
|
||||||
|
source_publisher,
|
||||||
|
};
|
||||||
|
Ok(Self { directory, control })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn directory(&self) -> ActorAddress {
|
||||||
|
self.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn control(&self) -> DataPlaneControl {
|
||||||
|
self.control.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DataPlaneControl {
|
||||||
|
runtime: Runtime,
|
||||||
|
directory: DirectoryClient,
|
||||||
|
source_sender: Arc<dyn BlobTransferSender>,
|
||||||
|
source_publisher: Arc<dyn BlobSourcePublisher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataPlaneControl {
|
||||||
|
pub async fn ensure(
|
||||||
|
&self,
|
||||||
|
path: DataPath,
|
||||||
|
registration: FileRegistration,
|
||||||
|
) -> Result<(), NamespaceError> {
|
||||||
|
match self.directory.resolve(path.clone()).await {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(NamespaceError::PathNotFound(_)) | Err(NamespaceError::SourceRecovery(_)) => {
|
||||||
|
self.register(path, registration).await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
&self,
|
||||||
|
path: DataPath,
|
||||||
|
registration: FileRegistration,
|
||||||
|
) -> Result<(), NamespaceError> {
|
||||||
|
let source = FileBlobSourceActor::open(
|
||||||
|
self.runtime.clone(),
|
||||||
|
Arc::clone(&self.source_sender),
|
||||||
|
registration.path(),
|
||||||
|
)?;
|
||||||
|
let length = source.length();
|
||||||
|
let recovery = source.recovery();
|
||||||
|
let source = self
|
||||||
|
.runtime
|
||||||
|
.spawn(source)
|
||||||
|
.map_err(|error| NamespaceError::SourceRecovery(error.to_string()))?;
|
||||||
|
let mut cleanup = PendingSourceRegistration {
|
||||||
|
runtime: self.runtime.clone(),
|
||||||
|
source,
|
||||||
|
armed: true,
|
||||||
|
};
|
||||||
|
self.source_publisher
|
||||||
|
.publish_source(source)
|
||||||
|
.map_err(NamespaceError::SourceRecovery)?;
|
||||||
|
self.directory
|
||||||
|
.register(path, source, length, recovery, random_operation_id())
|
||||||
|
.await?;
|
||||||
|
cleanup.armed = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unregister(&self, path: DataPath) -> Result<(), NamespaceError> {
|
||||||
|
self.directory
|
||||||
|
.unregister(path, random_operation_id())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingSourceRegistration {
|
||||||
|
runtime: Runtime,
|
||||||
|
source: ActorAddress,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PendingSourceRegistration {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
let _ = self.runtime.send_to(self.source, BlobSourceIn::Retire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_operation_id() -> OperationId {
|
||||||
|
let actor = ActorAddress::new_random();
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
bytes.copy_from_slice(&actor.0[..16]);
|
||||||
|
OperationId::from_u128(u128::from_be_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Child-side data-plane session, per-operation actors, and native API.
|
//! Child-side data-plane session, per-operation actors, and native API.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::os::fd::OwnedFd;
|
use std::os::fd::OwnedFd;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
@ -30,6 +30,11 @@ pub struct DataPlaneBootstrap {
|
||||||
pub data_plane: DataPlane,
|
pub data_plane: DataPlane,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct AttachDeadline {
|
||||||
|
pub engine: EngineHandle,
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
impl DataPlaneBootstrap {
|
impl DataPlaneBootstrap {
|
||||||
pub async fn attach(
|
pub async fn attach(
|
||||||
arena_fd: OwnedFd,
|
arena_fd: OwnedFd,
|
||||||
|
|
@ -94,8 +99,7 @@ impl DataPlaneBootstrap {
|
||||||
host_session: ActorAddress,
|
host_session: ActorAddress,
|
||||||
job_capability: JobCapability,
|
job_capability: JobCapability,
|
||||||
child_node: Option<[u8; 32]>,
|
child_node: Option<[u8; 32]>,
|
||||||
engine: EngineHandle,
|
deadline: AttachDeadline,
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<Self, DataPlaneError> {
|
) -> Result<Self, DataPlaneError> {
|
||||||
let sender = runtime.create_sender();
|
let sender = runtime.create_sender();
|
||||||
Self::attach_mapped_inner(
|
Self::attach_mapped_inner(
|
||||||
|
|
@ -105,7 +109,7 @@ impl DataPlaneBootstrap {
|
||||||
host_session,
|
host_session,
|
||||||
job_capability,
|
job_capability,
|
||||||
child_node,
|
child_node,
|
||||||
Some((engine, sender, timeout)),
|
Some((deadline.engine, sender, deadline.timeout)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +138,7 @@ impl DataPlaneBootstrap {
|
||||||
session_generation: None,
|
session_generation: None,
|
||||||
attach_reply: Some(attach_reply),
|
attach_reply: Some(attach_reply),
|
||||||
operations: HashSet::new(),
|
operations: HashSet::new(),
|
||||||
|
read_operations: HashMap::new(),
|
||||||
state: ChildSessionState::Attaching,
|
state: ChildSessionState::Attaching,
|
||||||
})
|
})
|
||||||
.map_err(|error| DataPlaneError::SessionFailed(error.to_string()))?;
|
.map_err(|error| DataPlaneError::SessionFailed(error.to_string()))?;
|
||||||
|
|
@ -153,6 +158,26 @@ impl DataPlaneBootstrap {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ReadCancellation {
|
||||||
|
runtime: Runtime,
|
||||||
|
child_session: ActorAddress,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ReadCancellation {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
let _ = self.runtime.send_to(
|
||||||
|
self.child_session,
|
||||||
|
ChildSessionIn::CancelRead {
|
||||||
|
reply_to: self.reply_to,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DataPlane {
|
pub struct DataPlane {
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
|
|
@ -170,16 +195,29 @@ impl DataPlane {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_blob(&self, path: &DataPath) -> Result<Blob, DataPlaneError> {
|
pub async fn read_blob(&self, path: &DataPath) -> Result<Blob, DataPlaneError> {
|
||||||
let ask = self
|
let inbox = self
|
||||||
.runtime
|
.runtime
|
||||||
.ask::<ChildSessionIn, Result<Blob, DataPlaneError>>(self.child_session, |reply_to| {
|
.new_inbox::<Result<Blob, DataPlaneError>>()
|
||||||
|
.map_err(|error| DataPlaneError::SessionFailed(error.to_string()))?;
|
||||||
|
let reply_to = *inbox.addr();
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
self.child_session,
|
||||||
ChildSessionIn::ReadBlob {
|
ChildSessionIn::ReadBlob {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
reply_to,
|
reply_to,
|
||||||
}
|
},
|
||||||
})
|
)
|
||||||
.map_err(|error| DataPlaneError::SessionFailed(error.to_string()))?;
|
.map_err(|error| DataPlaneError::SessionFailed(error.to_string()))?;
|
||||||
ask.await
|
let mut cancellation = ReadCancellation {
|
||||||
|
runtime: self.runtime.clone(),
|
||||||
|
child_session: self.child_session,
|
||||||
|
reply_to,
|
||||||
|
armed: true,
|
||||||
|
};
|
||||||
|
let result = inbox.recv().await;
|
||||||
|
cancellation.armed = false;
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_blob_path(&self, path: &str) -> Result<Blob, DataPlaneError> {
|
pub async fn read_blob_path(&self, path: &str) -> Result<Blob, DataPlaneError> {
|
||||||
|
|
@ -259,7 +297,6 @@ impl BlobWriter {
|
||||||
|
|
||||||
pub async fn seal(&mut self) -> Result<(), DataPlaneError> {
|
pub async fn seal(&mut self) -> Result<(), DataPlaneError> {
|
||||||
let metadata = self.writable.seal()?;
|
let metadata = self.writable.seal()?;
|
||||||
self.finalized = true;
|
|
||||||
let lease = self.writable.lease();
|
let lease = self.writable.lease();
|
||||||
let ask = self
|
let ask = self
|
||||||
.runtime
|
.runtime
|
||||||
|
|
@ -271,7 +308,11 @@ impl BlobWriter {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_err(|error| DataPlaneError::SessionFailed(error.to_string()))?;
|
.map_err(|error| DataPlaneError::SessionFailed(error.to_string()))?;
|
||||||
ask.await
|
let result = ask.await;
|
||||||
|
if result.is_ok() {
|
||||||
|
self.finalized = true;
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn abort(&mut self) -> Result<(), DataPlaneError> {
|
pub async fn abort(&mut self) -> Result<(), DataPlaneError> {
|
||||||
|
|
@ -293,10 +334,11 @@ impl BlobWriter {
|
||||||
|
|
||||||
impl Drop for BlobWriter {
|
impl Drop for BlobWriter {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if self.finalized || self.writable.is_finished() {
|
if self.finalized {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.writable.abort().is_ok() {
|
let should_abort = self.writable.is_finished() || self.writable.abort().is_ok();
|
||||||
|
if should_abort {
|
||||||
let _ = self.runtime.send_to(
|
let _ = self.runtime.send_to(
|
||||||
self.operation,
|
self.operation,
|
||||||
ChildOperationIn::AbortRequested {
|
ChildOperationIn::AbortRequested {
|
||||||
|
|
@ -318,6 +360,7 @@ pub struct ChildDataPlaneSessionActor {
|
||||||
session_generation: Option<u64>,
|
session_generation: Option<u64>,
|
||||||
attach_reply: Option<ActorAddress>,
|
attach_reply: Option<ActorAddress>,
|
||||||
operations: HashSet<ActorAddress>,
|
operations: HashSet<ActorAddress>,
|
||||||
|
read_operations: HashMap<ActorAddress, ActorAddress>,
|
||||||
state: ChildSessionState,
|
state: ChildSessionState,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,6 +451,7 @@ impl ActorInterface for ChildDataPlaneSessionActor {
|
||||||
match ctx.spawn(operation) {
|
match ctx.spawn(operation) {
|
||||||
Ok(operation) => {
|
Ok(operation) => {
|
||||||
self.operations.insert(operation);
|
self.operations.insert(operation);
|
||||||
|
self.read_operations.insert(reply_to, operation);
|
||||||
}
|
}
|
||||||
Err(error) => self.fail_local_open(
|
Err(error) => self.fail_local_open(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -416,6 +460,12 @@ impl ActorInterface for ChildDataPlaneSessionActor {
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ChildSessionIn::CancelRead { reply_to } => {
|
||||||
|
if let Some(operation) = self.read_operations.remove(&reply_to) {
|
||||||
|
self.operations.remove(&operation);
|
||||||
|
let _ = ctx.stop_actor(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
ChildSessionIn::OpenWriteBlob {
|
ChildSessionIn::OpenWriteBlob {
|
||||||
path,
|
path,
|
||||||
length,
|
length,
|
||||||
|
|
@ -504,6 +554,8 @@ impl ActorInterface for ChildDataPlaneSessionActor {
|
||||||
}
|
}
|
||||||
ChildSessionIn::OperationDone { operation } => {
|
ChildSessionIn::OperationDone { operation } => {
|
||||||
self.operations.remove(&operation);
|
self.operations.remove(&operation);
|
||||||
|
self.read_operations
|
||||||
|
.retain(|_, read_operation| *read_operation != operation);
|
||||||
}
|
}
|
||||||
ChildSessionIn::Close => {
|
ChildSessionIn::Close => {
|
||||||
if matches!(
|
if matches!(
|
||||||
|
|
@ -516,6 +568,7 @@ impl ActorInterface for ChildDataPlaneSessionActor {
|
||||||
for operation in self.operations.iter().copied() {
|
for operation in self.operations.iter().copied() {
|
||||||
let _ = ctx.stop_actor(operation);
|
let _ = ctx.stop_actor(operation);
|
||||||
}
|
}
|
||||||
|
self.read_operations.clear();
|
||||||
let _ = ctx.send(self.host_session, HostSessionIn::Close);
|
let _ = ctx.send(self.host_session, HostSessionIn::Close);
|
||||||
self.state = ChildSessionState::Closed;
|
self.state = ChildSessionState::Closed;
|
||||||
}
|
}
|
||||||
|
|
@ -641,6 +694,12 @@ impl ActorInterface for ReadBlobOperationActor {
|
||||||
|
|
||||||
fn on_stop(&mut self, ctx: &Ctx<'_>) {
|
fn on_stop(&mut self, ctx: &Ctx<'_>) {
|
||||||
if !self.replied {
|
if !self.replied {
|
||||||
|
let _ = ctx.send(
|
||||||
|
self.host_session,
|
||||||
|
HostSessionIn::CancelReadBlob {
|
||||||
|
operation: ctx.self_addr(),
|
||||||
|
},
|
||||||
|
);
|
||||||
let _ = ctx.send(
|
let _ = ctx.send(
|
||||||
self.reply_to,
|
self.reply_to,
|
||||||
Err::<Blob, _>(DataPlaneError::OperationCancelled),
|
Err::<Blob, _>(DataPlaneError::OperationCancelled),
|
||||||
|
|
@ -850,7 +909,10 @@ impl ActorInterface for WriteBlobOperationActor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ChildOperationIn::AbortRequested { reply_to, lease }
|
ChildOperationIn::AbortRequested { reply_to, lease }
|
||||||
if self.state == WriteOperationState::Filling =>
|
if matches!(
|
||||||
|
self.state,
|
||||||
|
WriteOperationState::Filling | WriteOperationState::Sealing
|
||||||
|
) =>
|
||||||
{
|
{
|
||||||
let Some((host_binding, expected_lease, _)) = self.grant.clone() else {
|
let Some((host_binding, expected_lease, _)) = self.grant.clone() else {
|
||||||
self.fail(ctx, DataPlaneError::OperationCancelled);
|
self.fail(ctx, DataPlaneError::OperationCancelled);
|
||||||
|
|
|
||||||
|
|
@ -517,14 +517,12 @@ impl EdgeEstablisherState {
|
||||||
let worker_ring_cleanup_required = record.worker_ring_cleanup_required();
|
let worker_ring_cleanup_required = record.worker_ring_cleanup_required();
|
||||||
record.state = EdgeProvisionState::Stopping;
|
record.state = EdgeProvisionState::Stopping;
|
||||||
|
|
||||||
if cancel_lease {
|
if cancel_lease && let Some(request_id) = request_id {
|
||||||
if let Some(request_id) = request_id {
|
|
||||||
self.commands.push(EdgeCommand::CancelQueuedLease {
|
self.commands.push(EdgeCommand::CancelQueuedLease {
|
||||||
request_id,
|
request_id,
|
||||||
edge_id,
|
edge_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let Some(ring_id) = ring_id else {
|
let Some(ring_id) = ring_id else {
|
||||||
self.mark_stopped_with_event(edge_id);
|
self.mark_stopped_with_event(edge_id);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,8 +7,10 @@
|
||||||
|
|
||||||
pub mod arena;
|
pub mod arena;
|
||||||
pub mod blob;
|
pub mod blob;
|
||||||
|
pub mod blob_transfer;
|
||||||
pub mod bootstrap;
|
pub mod bootstrap;
|
||||||
pub mod byte_ring;
|
pub mod byte_ring;
|
||||||
|
pub mod control;
|
||||||
pub mod data_plane;
|
pub mod data_plane;
|
||||||
pub mod edge_lifecycle;
|
pub mod edge_lifecycle;
|
||||||
pub mod edge_runtime;
|
pub mod edge_runtime;
|
||||||
|
|
@ -16,7 +18,10 @@ pub mod edge_wire;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod mapped_arena;
|
pub mod mapped_arena;
|
||||||
|
pub mod namespace;
|
||||||
|
pub mod namespace_store;
|
||||||
pub mod object_record;
|
pub mod object_record;
|
||||||
pub mod path;
|
pub mod path;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod ring;
|
pub mod ring;
|
||||||
|
pub mod source;
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,10 @@ impl MappedArena {
|
||||||
self.len
|
self.len
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len == 0
|
||||||
|
}
|
||||||
|
|
||||||
pub fn base_ptr(&self) -> *const u8 {
|
pub fn base_ptr(&self) -> *const u8 {
|
||||||
self.ptr.as_ptr().cast_const()
|
self.ptr.as_ptr().cast_const()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
822
crates/data-plane/src/namespace.rs
Normal file
822
crates/data-plane/src/namespace.rs
Normal file
|
|
@ -0,0 +1,822 @@
|
||||||
|
//! Authoritative virtual blob namespace actor and restart-tolerant client proxy.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
|
use swactor::runtime::{ExternalSender, Runtime};
|
||||||
|
use swactor_engine::EngineHandle;
|
||||||
|
use swactor_transport::{CodecRegistry, JsonCodec, NetworkMessage};
|
||||||
|
|
||||||
|
use crate::blob_transfer::{BlobTransferEvent, BlobTransferId};
|
||||||
|
use crate::namespace_store::{
|
||||||
|
MutationReceipt, MutationRejection, MutationRequest, NamespaceStore, NamespaceStoreError,
|
||||||
|
PersistedBinding, PersistedMutationResult, PersistedOperation,
|
||||||
|
};
|
||||||
|
use crate::path::DataPath;
|
||||||
|
use crate::source::BlobSourceIn;
|
||||||
|
|
||||||
|
pub use crate::namespace_store::{OperationId, SourceRecovery};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct DirectoryRequestId(pub u64);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BlobBinding {
|
||||||
|
pub source: ActorAddress,
|
||||||
|
pub length: u64,
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum NamespaceError {
|
||||||
|
PathNotFound(DataPath),
|
||||||
|
OperationConflict(OperationId),
|
||||||
|
Storage(String),
|
||||||
|
SourceRecovery(String),
|
||||||
|
DirectoryUnavailable(String),
|
||||||
|
Protocol(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for NamespaceError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::PathNotFound(path) => write!(f, "data path not found: {path}"),
|
||||||
|
Self::OperationConflict(operation) => write!(
|
||||||
|
f,
|
||||||
|
"namespace operation ID {:02x?} was reused for a different request",
|
||||||
|
operation.bytes()
|
||||||
|
),
|
||||||
|
Self::Storage(reason) => write!(f, "namespace persistence failed: {reason}"),
|
||||||
|
Self::SourceRecovery(reason) => write!(f, "namespace source recovery failed: {reason}"),
|
||||||
|
Self::DirectoryUnavailable(reason) => {
|
||||||
|
write!(f, "namespace directory is unavailable: {reason}")
|
||||||
|
}
|
||||||
|
Self::Protocol(reason) => write!(f, "namespace protocol failed: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for NamespaceError {}
|
||||||
|
|
||||||
|
impl From<NamespaceStoreError> for NamespaceError {
|
||||||
|
fn from(error: NamespaceStoreError) -> Self {
|
||||||
|
Self::Storage(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum DataDirectoryIn {
|
||||||
|
Register {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
path: DataPath,
|
||||||
|
source: ActorAddress,
|
||||||
|
length: u64,
|
||||||
|
recovery: SourceRecovery,
|
||||||
|
operation_id: OperationId,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
|
Resolve {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
path: DataPath,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
|
Unregister {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkMessage for DataDirectoryIn {
|
||||||
|
fn type_tag() -> &'static str {
|
||||||
|
"data-plane.data-directory.in.v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum DataDirectoryOut {
|
||||||
|
Registered {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
authority_epoch: u64,
|
||||||
|
result: Result<MutationReceipt, NamespaceError>,
|
||||||
|
},
|
||||||
|
Resolved {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
authority_epoch: u64,
|
||||||
|
result: Result<BlobBinding, NamespaceError>,
|
||||||
|
},
|
||||||
|
Unregistered {
|
||||||
|
request_id: DirectoryRequestId,
|
||||||
|
authority_epoch: u64,
|
||||||
|
result: Result<MutationReceipt, NamespaceError>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataDirectoryOut {
|
||||||
|
pub fn request_id(&self) -> DirectoryRequestId {
|
||||||
|
match self {
|
||||||
|
Self::Registered { request_id, .. }
|
||||||
|
| Self::Resolved { request_id, .. }
|
||||||
|
| Self::Unregistered { request_id, .. } => *request_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum NamespaceRequest {
|
||||||
|
Register {
|
||||||
|
path: DataPath,
|
||||||
|
source: ActorAddress,
|
||||||
|
length: u64,
|
||||||
|
recovery: SourceRecovery,
|
||||||
|
operation_id: OperationId,
|
||||||
|
},
|
||||||
|
Resolve {
|
||||||
|
path: DataPath,
|
||||||
|
},
|
||||||
|
Unregister {
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum NamespaceClientIn {
|
||||||
|
Request {
|
||||||
|
request: NamespaceRequest,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
|
Cancel {
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
|
DirectoryReply(DataDirectoryOut),
|
||||||
|
TransferFailed {
|
||||||
|
destination: ActorAddress,
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
Retry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkMessage for NamespaceClientIn {
|
||||||
|
fn type_tag() -> &'static str {
|
||||||
|
"data-plane.namespace-client.in.v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum RuntimeSource {
|
||||||
|
Available(ActorAddress),
|
||||||
|
Unavailable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DataDirectoryActor {
|
||||||
|
store: NamespaceStore,
|
||||||
|
sources: BTreeMap<DataPath, RuntimeSource>,
|
||||||
|
authority_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataDirectoryActor {
|
||||||
|
pub fn recover(
|
||||||
|
store_path: impl AsRef<Path>,
|
||||||
|
mut recover_source: impl FnMut(&SourceRecovery, u64) -> Result<ActorAddress, NamespaceError>,
|
||||||
|
) -> Result<Self, NamespaceError> {
|
||||||
|
let mut store = NamespaceStore::open(store_path)?;
|
||||||
|
let bindings = store.snapshot().bindings.clone();
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
for (path, binding) in bindings {
|
||||||
|
let source = match recover_source(&binding.recovery, binding.length) {
|
||||||
|
Ok(actor) => RuntimeSource::Available(actor),
|
||||||
|
Err(error) => RuntimeSource::Unavailable(error.to_string()),
|
||||||
|
};
|
||||||
|
sources.insert(path, source);
|
||||||
|
}
|
||||||
|
let authority_epoch = store.advance_authority_epoch()?;
|
||||||
|
Ok(Self {
|
||||||
|
store,
|
||||||
|
sources,
|
||||||
|
authority_epoch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authority_epoch(&self) -> u64 {
|
||||||
|
self.authority_epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay(
|
||||||
|
&self,
|
||||||
|
operation_id: OperationId,
|
||||||
|
request: &MutationRequest,
|
||||||
|
) -> Option<Result<MutationReceipt, NamespaceError>> {
|
||||||
|
self.store
|
||||||
|
.snapshot()
|
||||||
|
.operations
|
||||||
|
.get(&operation_id)
|
||||||
|
.map(|operation| {
|
||||||
|
if &operation.request != request {
|
||||||
|
return Err(NamespaceError::OperationConflict(operation_id));
|
||||||
|
}
|
||||||
|
match &operation.result {
|
||||||
|
PersistedMutationResult::Committed(receipt) => Ok(*receipt),
|
||||||
|
PersistedMutationResult::Rejected(MutationRejection::PathNotFound(path)) => {
|
||||||
|
Err(NamespaceError::PathNotFound(path.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(
|
||||||
|
&mut self,
|
||||||
|
path: DataPath,
|
||||||
|
source: ActorAddress,
|
||||||
|
length: u64,
|
||||||
|
recovery: SourceRecovery,
|
||||||
|
operation_id: OperationId,
|
||||||
|
retired: Option<ActorAddress>,
|
||||||
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
|
let request = MutationRequest::Register {
|
||||||
|
path: path.clone(),
|
||||||
|
length,
|
||||||
|
recovery: recovery.clone(),
|
||||||
|
};
|
||||||
|
if let Some(replayed) = self.replay(operation_id, &request) {
|
||||||
|
return replayed;
|
||||||
|
}
|
||||||
|
let revision = self.store.snapshot().next_revision;
|
||||||
|
let next_revision = revision
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|revision| *revision != 0)
|
||||||
|
.ok_or(NamespaceStoreError::RevisionExhausted)?;
|
||||||
|
let receipt = MutationReceipt { revision };
|
||||||
|
let mut next = self.store.snapshot().clone();
|
||||||
|
next.next_revision = next_revision;
|
||||||
|
next.bindings.insert(
|
||||||
|
path.clone(),
|
||||||
|
PersistedBinding {
|
||||||
|
length,
|
||||||
|
revision,
|
||||||
|
recovery,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Some(retired) = retired
|
||||||
|
&& !next.retirements.contains(&retired)
|
||||||
|
{
|
||||||
|
next.retirements.push(retired);
|
||||||
|
}
|
||||||
|
next.operations.insert(
|
||||||
|
operation_id,
|
||||||
|
PersistedOperation {
|
||||||
|
request,
|
||||||
|
result: PersistedMutationResult::Committed(receipt),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.store.commit(next)?;
|
||||||
|
self.sources.insert(path, RuntimeSource::Available(source));
|
||||||
|
Ok(receipt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&self, path: &DataPath) -> Result<BlobBinding, NamespaceError> {
|
||||||
|
let persisted = self
|
||||||
|
.store
|
||||||
|
.snapshot()
|
||||||
|
.bindings
|
||||||
|
.get(path)
|
||||||
|
.ok_or_else(|| NamespaceError::PathNotFound(path.clone()))?;
|
||||||
|
match self.sources.get(path) {
|
||||||
|
Some(RuntimeSource::Available(source)) => Ok(BlobBinding {
|
||||||
|
source: *source,
|
||||||
|
length: persisted.length,
|
||||||
|
revision: persisted.revision,
|
||||||
|
}),
|
||||||
|
Some(RuntimeSource::Unavailable(reason)) => {
|
||||||
|
Err(NamespaceError::SourceRecovery(reason.clone()))
|
||||||
|
}
|
||||||
|
None => Err(NamespaceError::SourceRecovery(format!(
|
||||||
|
"binding {path} has no recovered runtime source"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unregister(
|
||||||
|
&mut self,
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
retired: Option<ActorAddress>,
|
||||||
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
|
let request = MutationRequest::Unregister { path: path.clone() };
|
||||||
|
if let Some(replayed) = self.replay(operation_id, &request) {
|
||||||
|
return replayed;
|
||||||
|
}
|
||||||
|
if !self.store.snapshot().bindings.contains_key(&path) {
|
||||||
|
let mut next = self.store.snapshot().clone();
|
||||||
|
next.operations.insert(
|
||||||
|
operation_id,
|
||||||
|
PersistedOperation {
|
||||||
|
request,
|
||||||
|
result: PersistedMutationResult::Rejected(MutationRejection::PathNotFound(
|
||||||
|
path.clone(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.store.commit(next)?;
|
||||||
|
return Err(NamespaceError::PathNotFound(path));
|
||||||
|
}
|
||||||
|
let revision = self.store.snapshot().next_revision;
|
||||||
|
let next_revision = revision
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|revision| *revision != 0)
|
||||||
|
.ok_or(NamespaceStoreError::RevisionExhausted)?;
|
||||||
|
let receipt = MutationReceipt { revision };
|
||||||
|
let mut next = self.store.snapshot().clone();
|
||||||
|
next.next_revision = next_revision;
|
||||||
|
next.bindings.remove(&path);
|
||||||
|
if let Some(retired) = retired
|
||||||
|
&& !next.retirements.contains(&retired)
|
||||||
|
{
|
||||||
|
next.retirements.push(retired);
|
||||||
|
}
|
||||||
|
next.operations.insert(
|
||||||
|
operation_id,
|
||||||
|
PersistedOperation {
|
||||||
|
request,
|
||||||
|
result: PersistedMutationResult::Committed(receipt),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.store.commit(next)?;
|
||||||
|
self.sources.remove(&path);
|
||||||
|
Ok(receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for DataDirectoryActor {
|
||||||
|
type Incoming = DataDirectoryIn;
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn on_start(&mut self, ctx: &Ctx<'_>) {
|
||||||
|
for source in self.store.snapshot().retirements.iter().copied() {
|
||||||
|
let _ = ctx.send(source, BlobSourceIn::Retire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&mut self, ctx: &Ctx<'_>, message: DataDirectoryIn) {
|
||||||
|
match message {
|
||||||
|
DataDirectoryIn::Register {
|
||||||
|
request_id,
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
length,
|
||||||
|
recovery,
|
||||||
|
operation_id,
|
||||||
|
reply_to,
|
||||||
|
} => {
|
||||||
|
let replayed = self.store.snapshot().operations.contains_key(&operation_id);
|
||||||
|
let retired = (!replayed)
|
||||||
|
.then(|| self.sources.get(&path))
|
||||||
|
.flatten()
|
||||||
|
.and_then(|runtime_source| match runtime_source {
|
||||||
|
RuntimeSource::Available(actor) if *actor != source => Some(*actor),
|
||||||
|
RuntimeSource::Available(_) | RuntimeSource::Unavailable(_) => None,
|
||||||
|
});
|
||||||
|
let result = self.register(path, source, length, recovery, operation_id, retired);
|
||||||
|
if result.is_ok()
|
||||||
|
&& let Some(retired) = retired
|
||||||
|
{
|
||||||
|
let _ = ctx.send(retired, BlobSourceIn::Retire);
|
||||||
|
}
|
||||||
|
let _ = ctx.send(
|
||||||
|
reply_to,
|
||||||
|
NamespaceClientIn::DirectoryReply(DataDirectoryOut::Registered {
|
||||||
|
request_id,
|
||||||
|
authority_epoch: self.authority_epoch,
|
||||||
|
result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DataDirectoryIn::Resolve {
|
||||||
|
request_id,
|
||||||
|
path,
|
||||||
|
reply_to,
|
||||||
|
} => {
|
||||||
|
let result = self.resolve(&path);
|
||||||
|
let _ = ctx.send(
|
||||||
|
reply_to,
|
||||||
|
NamespaceClientIn::DirectoryReply(DataDirectoryOut::Resolved {
|
||||||
|
request_id,
|
||||||
|
authority_epoch: self.authority_epoch,
|
||||||
|
result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DataDirectoryIn::Unregister {
|
||||||
|
request_id,
|
||||||
|
path,
|
||||||
|
operation_id,
|
||||||
|
reply_to,
|
||||||
|
} => {
|
||||||
|
let replayed = self.store.snapshot().operations.contains_key(&operation_id);
|
||||||
|
let retired = (!replayed)
|
||||||
|
.then(|| self.sources.get(&path))
|
||||||
|
.flatten()
|
||||||
|
.and_then(|source| match source {
|
||||||
|
RuntimeSource::Available(actor) => Some(*actor),
|
||||||
|
RuntimeSource::Unavailable(_) => None,
|
||||||
|
});
|
||||||
|
let result = self.unregister(path, operation_id, retired);
|
||||||
|
if result.is_ok()
|
||||||
|
&& let Some(retired) = retired
|
||||||
|
{
|
||||||
|
let _ = ctx.send(retired, BlobSourceIn::Retire);
|
||||||
|
}
|
||||||
|
let _ = ctx.send(
|
||||||
|
reply_to,
|
||||||
|
NamespaceClientIn::DirectoryReply(DataDirectoryOut::Unregistered {
|
||||||
|
request_id,
|
||||||
|
authority_epoch: self.authority_epoch,
|
||||||
|
result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait NamespaceDiscovery: Send + Sync + 'static {
|
||||||
|
fn current_directory(&self) -> Option<ActorAddress>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingRequest {
|
||||||
|
request: NamespaceRequest,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NamespaceClientActor {
|
||||||
|
engine: EngineHandle,
|
||||||
|
sender: ExternalSender,
|
||||||
|
discovery: Arc<dyn NamespaceDiscovery>,
|
||||||
|
retry_period: Duration,
|
||||||
|
next_request_id: u64,
|
||||||
|
pending: HashMap<DirectoryRequestId, PendingRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamespaceClientActor {
|
||||||
|
pub fn new(
|
||||||
|
engine: EngineHandle,
|
||||||
|
sender: ExternalSender,
|
||||||
|
discovery: Arc<dyn NamespaceDiscovery>,
|
||||||
|
retry_period: Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
engine,
|
||||||
|
sender,
|
||||||
|
discovery,
|
||||||
|
retry_period,
|
||||||
|
next_request_id: 1,
|
||||||
|
pending: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(&self, ctx: &Ctx<'_>, request_id: DirectoryRequestId, request: &NamespaceRequest) {
|
||||||
|
let Some(directory) = self.discovery.current_directory() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let message = match request {
|
||||||
|
NamespaceRequest::Register {
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
length,
|
||||||
|
recovery,
|
||||||
|
operation_id,
|
||||||
|
} => DataDirectoryIn::Register {
|
||||||
|
request_id,
|
||||||
|
path: path.clone(),
|
||||||
|
source: *source,
|
||||||
|
length: *length,
|
||||||
|
recovery: recovery.clone(),
|
||||||
|
operation_id: *operation_id,
|
||||||
|
reply_to: ctx.self_addr(),
|
||||||
|
},
|
||||||
|
NamespaceRequest::Resolve { path } => DataDirectoryIn::Resolve {
|
||||||
|
request_id,
|
||||||
|
path: path.clone(),
|
||||||
|
reply_to: ctx.self_addr(),
|
||||||
|
},
|
||||||
|
NamespaceRequest::Unregister { path, operation_id } => DataDirectoryIn::Unregister {
|
||||||
|
request_id,
|
||||||
|
path: path.clone(),
|
||||||
|
operation_id: *operation_id,
|
||||||
|
reply_to: ctx.self_addr(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let _ = ctx.send(directory, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for NamespaceClientActor {
|
||||||
|
type Incoming = NamespaceClientIn;
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn on_start(&mut self, ctx: &Ctx<'_>) {
|
||||||
|
self.engine.send_every(
|
||||||
|
self.retry_period,
|
||||||
|
self.sender.clone(),
|
||||||
|
ctx.self_addr(),
|
||||||
|
NamespaceClientIn::Retry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&mut self, ctx: &Ctx<'_>, message: NamespaceClientIn) {
|
||||||
|
match message {
|
||||||
|
NamespaceClientIn::Request { request, reply_to } => {
|
||||||
|
let request_id = DirectoryRequestId(self.next_request_id);
|
||||||
|
let Some(next) = self
|
||||||
|
.next_request_id
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|next| *next != 0)
|
||||||
|
else {
|
||||||
|
let _ = ctx.send(
|
||||||
|
reply_to,
|
||||||
|
DataDirectoryOut::Resolved {
|
||||||
|
request_id,
|
||||||
|
authority_epoch: 0,
|
||||||
|
result: Err(NamespaceError::Protocol(
|
||||||
|
"namespace client request IDs exhausted".to_owned(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.next_request_id = next;
|
||||||
|
self.dispatch(ctx, request_id, &request);
|
||||||
|
self.pending
|
||||||
|
.insert(request_id, PendingRequest { request, reply_to });
|
||||||
|
}
|
||||||
|
NamespaceClientIn::Cancel { reply_to } => {
|
||||||
|
self.pending
|
||||||
|
.retain(|_, pending| pending.reply_to != reply_to);
|
||||||
|
}
|
||||||
|
NamespaceClientIn::DirectoryReply(reply) => {
|
||||||
|
if let Some(pending) = self.pending.remove(&reply.request_id()) {
|
||||||
|
let _ = ctx.send(pending.reply_to, reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NamespaceClientIn::TransferFailed {
|
||||||
|
destination,
|
||||||
|
transfer_id,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
let _ = ctx.send(
|
||||||
|
destination,
|
||||||
|
BlobTransferEvent::Failed {
|
||||||
|
transfer_id,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
NamespaceClientIn::Retry => {
|
||||||
|
for (request_id, pending) in &self.pending {
|
||||||
|
self.dispatch(ctx, *request_id, &pending.request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NamespaceRequestCancellation {
|
||||||
|
runtime: Runtime,
|
||||||
|
proxy: ActorAddress,
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for NamespaceRequestCancellation {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
let _ = self.runtime.send_to(
|
||||||
|
self.proxy,
|
||||||
|
NamespaceClientIn::Cancel {
|
||||||
|
reply_to: self.reply_to,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct NamespaceClient {
|
||||||
|
runtime: Runtime,
|
||||||
|
proxy: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamespaceClient {
|
||||||
|
pub fn new(runtime: Runtime, proxy: ActorAddress) -> Self {
|
||||||
|
Self { runtime, proxy }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxy(&self) -> ActorAddress {
|
||||||
|
self.proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(&self, request: NamespaceRequest) -> Result<DataDirectoryOut, NamespaceError> {
|
||||||
|
let inbox = self
|
||||||
|
.runtime
|
||||||
|
.new_inbox::<DataDirectoryOut>()
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
let reply_to = *inbox.addr();
|
||||||
|
self.runtime
|
||||||
|
.send_to(self.proxy, NamespaceClientIn::Request { request, reply_to })
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
let mut cancellation = NamespaceRequestCancellation {
|
||||||
|
runtime: self.runtime.clone(),
|
||||||
|
proxy: self.proxy,
|
||||||
|
reply_to,
|
||||||
|
armed: true,
|
||||||
|
};
|
||||||
|
let reply = inbox.recv().await;
|
||||||
|
cancellation.armed = false;
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
&self,
|
||||||
|
path: DataPath,
|
||||||
|
source: ActorAddress,
|
||||||
|
length: u64,
|
||||||
|
recovery: SourceRecovery,
|
||||||
|
operation_id: OperationId,
|
||||||
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
|
match self
|
||||||
|
.request(NamespaceRequest::Register {
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
length,
|
||||||
|
recovery,
|
||||||
|
operation_id,
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
DataDirectoryOut::Registered { result, .. } => result,
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected register reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve(&self, path: DataPath) -> Result<BlobBinding, NamespaceError> {
|
||||||
|
match self.request(NamespaceRequest::Resolve { path }).await? {
|
||||||
|
DataDirectoryOut::Resolved { result, .. } => result,
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected resolve reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unregister(
|
||||||
|
&self,
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
|
match self
|
||||||
|
.request(NamespaceRequest::Unregister { path, operation_id })
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
DataDirectoryOut::Unregistered { result, .. } => result,
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected unregister reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DirectoryClient {
|
||||||
|
runtime: Runtime,
|
||||||
|
directory: ActorAddress,
|
||||||
|
next_request_id: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirectoryClient {
|
||||||
|
pub fn new(runtime: Runtime, directory: ActorAddress) -> Self {
|
||||||
|
Self {
|
||||||
|
runtime,
|
||||||
|
directory,
|
||||||
|
next_request_id: Arc::new(AtomicU64::new(1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn directory(&self) -> ActorAddress {
|
||||||
|
self.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_id(&self) -> DirectoryRequestId {
|
||||||
|
DirectoryRequestId(self.next_request_id.fetch_add(1, Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(
|
||||||
|
&self,
|
||||||
|
inbox: &swactor::runtime::Inbox<NamespaceClientIn>,
|
||||||
|
) -> Result<DataDirectoryOut, NamespaceError> {
|
||||||
|
match inbox.recv().await {
|
||||||
|
NamespaceClientIn::DirectoryReply(reply) => Ok(reply),
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected directory reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
&self,
|
||||||
|
path: DataPath,
|
||||||
|
source: ActorAddress,
|
||||||
|
length: u64,
|
||||||
|
recovery: SourceRecovery,
|
||||||
|
operation_id: OperationId,
|
||||||
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
|
let inbox = self
|
||||||
|
.runtime
|
||||||
|
.new_inbox::<NamespaceClientIn>()
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
self.directory,
|
||||||
|
DataDirectoryIn::Register {
|
||||||
|
request_id: self.request_id(),
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
length,
|
||||||
|
recovery,
|
||||||
|
operation_id,
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
match self.receive(&inbox).await? {
|
||||||
|
DataDirectoryOut::Registered { result, .. } => result,
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected register reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve(&self, path: DataPath) -> Result<BlobBinding, NamespaceError> {
|
||||||
|
let inbox = self
|
||||||
|
.runtime
|
||||||
|
.new_inbox::<NamespaceClientIn>()
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
self.directory,
|
||||||
|
DataDirectoryIn::Resolve {
|
||||||
|
request_id: self.request_id(),
|
||||||
|
path,
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
match self.receive(&inbox).await? {
|
||||||
|
DataDirectoryOut::Resolved { result, .. } => result,
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected resolve reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unregister(
|
||||||
|
&self,
|
||||||
|
path: DataPath,
|
||||||
|
operation_id: OperationId,
|
||||||
|
) -> Result<MutationReceipt, NamespaceError> {
|
||||||
|
let inbox = self
|
||||||
|
.runtime
|
||||||
|
.new_inbox::<NamespaceClientIn>()
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
self.directory,
|
||||||
|
DataDirectoryIn::Unregister {
|
||||||
|
request_id: self.request_id(),
|
||||||
|
path,
|
||||||
|
operation_id,
|
||||||
|
reply_to: *inbox.addr(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| NamespaceError::DirectoryUnavailable(error.to_string()))?;
|
||||||
|
match self.receive(&inbox).await? {
|
||||||
|
DataDirectoryOut::Unregistered { result, .. } => result,
|
||||||
|
other => Err(NamespaceError::Protocol(format!(
|
||||||
|
"expected unregister reply, received {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_namespace_codecs(registry: &mut CodecRegistry) {
|
||||||
|
registry.register::<DataDirectoryIn, _>(JsonCodec::default());
|
||||||
|
registry.register::<NamespaceClientIn, _>(JsonCodec::default());
|
||||||
|
}
|
||||||
407
crates/data-plane/src/namespace_store.rs
Normal file
407
crates/data-plane/src/namespace_store.rs
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
//! Crash-consistent durable state for the virtual blob namespace.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
|
||||||
|
use crate::path::DataPath;
|
||||||
|
|
||||||
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct OperationId([u8; 16]);
|
||||||
|
|
||||||
|
impl OperationId {
|
||||||
|
pub const fn from_u128(value: u128) -> Self {
|
||||||
|
Self(value.to_be_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn bytes(self) -> [u8; 16] {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for OperationId {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&format!("{:032x}", u128::from_be_bytes(self.0)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for OperationId {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let encoded = String::deserialize(deserializer)?;
|
||||||
|
if encoded.len() != 32 {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"namespace operation ID must contain 32 hexadecimal digits",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
u128::from_str_radix(&encoded, 16)
|
||||||
|
.map(Self::from_u128)
|
||||||
|
.map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum SourceRecovery {
|
||||||
|
File { path: PathBuf },
|
||||||
|
Actor { actor: ActorAddress },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PersistedBinding {
|
||||||
|
pub length: u64,
|
||||||
|
pub revision: u64,
|
||||||
|
pub recovery: SourceRecovery,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum MutationRequest {
|
||||||
|
Register {
|
||||||
|
path: DataPath,
|
||||||
|
length: u64,
|
||||||
|
recovery: SourceRecovery,
|
||||||
|
},
|
||||||
|
Unregister {
|
||||||
|
path: DataPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MutationReceipt {
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum MutationRejection {
|
||||||
|
PathNotFound(DataPath),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PersistedMutationResult {
|
||||||
|
Committed(MutationReceipt),
|
||||||
|
Rejected(MutationRejection),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PersistedOperation {
|
||||||
|
pub request: MutationRequest,
|
||||||
|
pub result: PersistedMutationResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NamespaceSnapshot {
|
||||||
|
schema_version: u32,
|
||||||
|
pub authority_epoch: u64,
|
||||||
|
pub next_revision: u64,
|
||||||
|
pub bindings: BTreeMap<DataPath, PersistedBinding>,
|
||||||
|
pub operations: BTreeMap<OperationId, PersistedOperation>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retirements: Vec<ActorAddress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NamespaceSnapshot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
authority_epoch: 0,
|
||||||
|
next_revision: 1,
|
||||||
|
bindings: BTreeMap::new(),
|
||||||
|
operations: BTreeMap::new(),
|
||||||
|
retirements: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamespaceSnapshot {
|
||||||
|
fn validate(&self) -> Result<(), NamespaceStoreError> {
|
||||||
|
if self.schema_version != SCHEMA_VERSION {
|
||||||
|
return Err(NamespaceStoreError::UnsupportedSchema {
|
||||||
|
found: self.schema_version,
|
||||||
|
supported: SCHEMA_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.next_revision == 0 {
|
||||||
|
return Err(NamespaceStoreError::Corrupt(
|
||||||
|
"next namespace revision is zero".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.bindings
|
||||||
|
.values()
|
||||||
|
.any(|binding| binding.revision == 0 || binding.revision >= self.next_revision)
|
||||||
|
{
|
||||||
|
return Err(NamespaceStoreError::Corrupt(
|
||||||
|
"binding revision is outside the committed revision range".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.operations.values().any(|operation| {
|
||||||
|
matches!(
|
||||||
|
operation.result,
|
||||||
|
PersistedMutationResult::Committed(receipt)
|
||||||
|
if receipt.revision == 0 || receipt.revision >= self.next_revision
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return Err(NamespaceStoreError::Corrupt(
|
||||||
|
"operation revision is outside the committed revision range".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum NamespaceStoreError {
|
||||||
|
Io(String),
|
||||||
|
Corrupt(String),
|
||||||
|
UnsupportedSchema { found: u32, supported: u32 },
|
||||||
|
RevisionExhausted,
|
||||||
|
AuthorityEpochExhausted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for NamespaceStoreError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(reason) => write!(f, "namespace storage I/O failed: {reason}"),
|
||||||
|
Self::Corrupt(reason) => write!(f, "namespace storage is corrupt: {reason}"),
|
||||||
|
Self::UnsupportedSchema { found, supported } => write!(
|
||||||
|
f,
|
||||||
|
"unsupported namespace schema {found}; supported schema is {supported}"
|
||||||
|
),
|
||||||
|
Self::RevisionExhausted => f.write_str("namespace revision exhausted"),
|
||||||
|
Self::AuthorityEpochExhausted => f.write_str("namespace authority epoch exhausted"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for NamespaceStoreError {}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for NamespaceStoreError {
|
||||||
|
fn from(error: std::io::Error) -> Self {
|
||||||
|
Self::Io(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum CommitFailpoint {
|
||||||
|
None,
|
||||||
|
BeforeRename,
|
||||||
|
AfterRename,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NamespaceStore {
|
||||||
|
path: PathBuf,
|
||||||
|
snapshot: NamespaceSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamespaceStore {
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, NamespaceStoreError> {
|
||||||
|
let path = path.as_ref().to_path_buf();
|
||||||
|
let snapshot = match File::open(&path) {
|
||||||
|
Ok(mut file) => {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes)?;
|
||||||
|
let snapshot: NamespaceSnapshot = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|error| NamespaceStoreError::Corrupt(error.to_string()))?;
|
||||||
|
snapshot.validate()?;
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
NamespaceSnapshot::default()
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
Ok(Self { path, snapshot })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> &NamespaceSnapshot {
|
||||||
|
&self.snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance_authority_epoch(&mut self) -> Result<u64, NamespaceStoreError> {
|
||||||
|
let mut next = self.snapshot.clone();
|
||||||
|
next.authority_epoch = next
|
||||||
|
.authority_epoch
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|epoch| *epoch != 0)
|
||||||
|
.ok_or(NamespaceStoreError::AuthorityEpochExhausted)?;
|
||||||
|
self.commit(next)?;
|
||||||
|
Ok(self.snapshot.authority_epoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit(&mut self, next: NamespaceSnapshot) -> Result<(), NamespaceStoreError> {
|
||||||
|
next.validate()?;
|
||||||
|
self.persist(&next)?;
|
||||||
|
self.snapshot = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist(&self, snapshot: &NamespaceSnapshot) -> Result<(), NamespaceStoreError> {
|
||||||
|
self.persist_with_failpoint(snapshot, CommitFailpoint::None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_with_failpoint(
|
||||||
|
&self,
|
||||||
|
snapshot: &NamespaceSnapshot,
|
||||||
|
failpoint: CommitFailpoint,
|
||||||
|
) -> Result<(), NamespaceStoreError> {
|
||||||
|
let parent = self.path.parent().ok_or_else(|| {
|
||||||
|
NamespaceStoreError::Io("namespace state path has no parent".to_owned())
|
||||||
|
})?;
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
let file_name = self.path.file_name().ok_or_else(|| {
|
||||||
|
NamespaceStoreError::Io("namespace state path has no file name".to_owned())
|
||||||
|
})?;
|
||||||
|
let temporary = parent.join(format!(".{}.tmp", file_name.to_string_lossy()));
|
||||||
|
let bytes = serde_json::to_vec(snapshot)
|
||||||
|
.map_err(|error| NamespaceStoreError::Corrupt(error.to_string()))?;
|
||||||
|
|
||||||
|
let write_result = (|| -> Result<(), NamespaceStoreError> {
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&temporary)?;
|
||||||
|
file.write_all(&bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
if failpoint == CommitFailpoint::BeforeRename {
|
||||||
|
return Err(NamespaceStoreError::Io(
|
||||||
|
"injected failure before durable replacement".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fs::rename(&temporary, &self.path)?;
|
||||||
|
File::open(parent)?.sync_all()?;
|
||||||
|
if failpoint == CommitFailpoint::AfterRename {
|
||||||
|
return Err(NamespaceStoreError::Io(
|
||||||
|
"injected crash after durable replacement".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if write_result.is_err() {
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
}
|
||||||
|
write_result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn commit_with_failpoint(
|
||||||
|
&mut self,
|
||||||
|
next: NamespaceSnapshot,
|
||||||
|
failpoint: CommitFailpoint,
|
||||||
|
) -> Result<(), NamespaceStoreError> {
|
||||||
|
next.validate()?;
|
||||||
|
self.persist_with_failpoint(&next, failpoint)?;
|
||||||
|
self.snapshot = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct TestState {
|
||||||
|
root: PathBuf,
|
||||||
|
file: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestState {
|
||||||
|
fn new() -> Self {
|
||||||
|
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"swactor-namespace-store-{}-{sequence}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&root).expect("create test state directory");
|
||||||
|
let file = root.join("namespace.json");
|
||||||
|
Self { root, file }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestState {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_with_binding(store: &NamespaceStore) -> NamespaceSnapshot {
|
||||||
|
let mut next = store.snapshot().clone();
|
||||||
|
next.bindings.insert(
|
||||||
|
DataPath::parse("/models/a").unwrap(),
|
||||||
|
PersistedBinding {
|
||||||
|
length: 8,
|
||||||
|
revision: 1,
|
||||||
|
recovery: SourceRecovery::Actor {
|
||||||
|
actor: ActorAddress([7; 32]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
next.next_revision = 2;
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_before_rename_preserves_previous_snapshot() {
|
||||||
|
let state = TestState::new();
|
||||||
|
let mut store = NamespaceStore::open(&state.file).unwrap();
|
||||||
|
store.advance_authority_epoch().unwrap();
|
||||||
|
let next = state_with_binding(&store);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.commit_with_failpoint(next, CommitFailpoint::BeforeRename)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let reopened = NamespaceStore::open(&state.file).unwrap();
|
||||||
|
assert!(reopened.snapshot().bindings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_after_rename_recovers_new_snapshot() {
|
||||||
|
let state = TestState::new();
|
||||||
|
let mut store = NamespaceStore::open(&state.file).unwrap();
|
||||||
|
store.advance_authority_epoch().unwrap();
|
||||||
|
let next = state_with_binding(&store);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.commit_with_failpoint(next, CommitFailpoint::AfterRename)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let reopened = NamespaceStore::open(&state.file).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reopened
|
||||||
|
.snapshot()
|
||||||
|
.bindings
|
||||||
|
.get(&DataPath::parse("/models/a").unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.length,
|
||||||
|
8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_snapshot_is_rejected() {
|
||||||
|
let state = TestState::new();
|
||||||
|
fs::write(&state.file, b"{not-json").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
NamespaceStore::open(&state.file),
|
||||||
|
Err(NamespaceStoreError::Corrupt(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -104,7 +104,6 @@ pub enum DataPlaneError {
|
||||||
operation: DataOperation,
|
operation: DataOperation,
|
||||||
},
|
},
|
||||||
PathNotFound(DataPath),
|
PathNotFound(DataPath),
|
||||||
PathAlreadyExists(DataPath),
|
|
||||||
SourceFailure(String),
|
SourceFailure(String),
|
||||||
ArenaExhausted,
|
ArenaExhausted,
|
||||||
Blob(BlobFailure),
|
Blob(BlobFailure),
|
||||||
|
|
@ -124,7 +123,6 @@ impl fmt::Display for DataPlaneError {
|
||||||
write!(f, "{operation:?} is not authorized for {path}")
|
write!(f, "{operation:?} is not authorized for {path}")
|
||||||
}
|
}
|
||||||
Self::PathNotFound(path) => write!(f, "data path not found: {path}"),
|
Self::PathNotFound(path) => write!(f, "data path not found: {path}"),
|
||||||
Self::PathAlreadyExists(path) => write!(f, "data path already exists: {path}"),
|
|
||||||
Self::SourceFailure(reason) => write!(f, "blob source failed: {reason}"),
|
Self::SourceFailure(reason) => write!(f, "blob source failed: {reason}"),
|
||||||
Self::ArenaExhausted => f.write_str("data-plane arena is exhausted"),
|
Self::ArenaExhausted => f.write_str("data-plane arena is exhausted"),
|
||||||
Self::Blob(reason) => write!(f, "blob lease failure: {reason:?}"),
|
Self::Blob(reason) => write!(f, "blob lease failure: {reason:?}"),
|
||||||
|
|
@ -142,14 +140,6 @@ impl From<BlobError> for DataPlaneError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct PublishedBlobInfo {
|
|
||||||
pub path: DataPath,
|
|
||||||
pub binding: ActorAddress,
|
|
||||||
pub lease: BlobLease,
|
|
||||||
pub metadata: BlobMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub enum HostSessionIn {
|
pub enum HostSessionIn {
|
||||||
Attach {
|
Attach {
|
||||||
|
|
@ -163,6 +153,9 @@ pub enum HostSessionIn {
|
||||||
child_session: ActorAddress,
|
child_session: ActorAddress,
|
||||||
operation: ActorAddress,
|
operation: ActorAddress,
|
||||||
},
|
},
|
||||||
|
CancelReadBlob {
|
||||||
|
operation: ActorAddress,
|
||||||
|
},
|
||||||
OpenWriteBlob {
|
OpenWriteBlob {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
length: u64,
|
length: u64,
|
||||||
|
|
@ -186,13 +179,6 @@ pub enum HostSessionIn {
|
||||||
lease_id: BlobLeaseId,
|
lease_id: BlobLeaseId,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
},
|
},
|
||||||
BindingPublished {
|
|
||||||
binding: ActorAddress,
|
|
||||||
operation: ActorAddress,
|
|
||||||
path: DataPath,
|
|
||||||
lease: BlobLease,
|
|
||||||
metadata: BlobMetadata,
|
|
||||||
},
|
|
||||||
BindingFaulted {
|
BindingFaulted {
|
||||||
binding: ActorAddress,
|
binding: ActorAddress,
|
||||||
operation: ActorAddress,
|
operation: ActorAddress,
|
||||||
|
|
@ -201,39 +187,8 @@ pub enum HostSessionIn {
|
||||||
BindingDone {
|
BindingDone {
|
||||||
binding: ActorAddress,
|
binding: ActorAddress,
|
||||||
},
|
},
|
||||||
InspectPublished {
|
BindingDetached {
|
||||||
path: DataPath,
|
binding: ActorAddress,
|
||||||
reply_to: ActorAddress,
|
|
||||||
},
|
|
||||||
ReleasePublished {
|
|
||||||
path: DataPath,
|
|
||||||
},
|
|
||||||
BeginBlobSource {
|
|
||||||
path: DataPath,
|
|
||||||
metadata: BlobMetadata,
|
|
||||||
reply_to: ActorAddress,
|
|
||||||
},
|
|
||||||
BlobSourceChunk {
|
|
||||||
path: DataPath,
|
|
||||||
bytes: Vec<u8>,
|
|
||||||
},
|
|
||||||
FinishBlobSource {
|
|
||||||
path: DataPath,
|
|
||||||
},
|
|
||||||
FailBlobSource {
|
|
||||||
path: DataPath,
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
SourceReady {
|
|
||||||
path: DataPath,
|
|
||||||
source: ActorAddress,
|
|
||||||
lease: BlobLease,
|
|
||||||
metadata: BlobMetadata,
|
|
||||||
},
|
|
||||||
SourceFaulted {
|
|
||||||
path: DataPath,
|
|
||||||
source: ActorAddress,
|
|
||||||
error: DataPlaneError,
|
|
||||||
},
|
},
|
||||||
ConfigureRun {
|
ConfigureRun {
|
||||||
run_id: String,
|
run_id: String,
|
||||||
|
|
@ -261,6 +216,9 @@ pub enum ChildSessionIn {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
reply_to: ActorAddress,
|
reply_to: ActorAddress,
|
||||||
},
|
},
|
||||||
|
CancelRead {
|
||||||
|
reply_to: ActorAddress,
|
||||||
|
},
|
||||||
OpenWriteBlob {
|
OpenWriteBlob {
|
||||||
path: DataPath,
|
path: DataPath,
|
||||||
length: u64,
|
length: u64,
|
||||||
|
|
@ -303,4 +261,7 @@ impl NetworkMessage for ChildSessionIn {
|
||||||
pub fn register_data_plane_codecs(registry: &mut CodecRegistry) {
|
pub fn register_data_plane_codecs(registry: &mut CodecRegistry) {
|
||||||
registry.register::<HostSessionIn, _>(JsonCodec::default());
|
registry.register::<HostSessionIn, _>(JsonCodec::default());
|
||||||
registry.register::<ChildSessionIn, _>(JsonCodec::default());
|
registry.register::<ChildSessionIn, _>(JsonCodec::default());
|
||||||
|
crate::namespace::register_namespace_codecs(registry);
|
||||||
|
crate::blob_transfer::register_blob_transfer_codecs(registry);
|
||||||
|
crate::source::register_blob_source_codecs(registry);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
299
crates/data-plane/src/source.rs
Normal file
299
crates/data-plane/src/source.rs
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
//! Persistent fixed-length blob sources.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||||
|
use swactor::runtime::Runtime;
|
||||||
|
use swactor_transport::{CodecRegistry, JsonCodec, NetworkMessage};
|
||||||
|
|
||||||
|
use crate::blob_transfer::{
|
||||||
|
BlobTransferCompletion, BlobTransferEvent, BlobTransferId, BlobTransferOffer,
|
||||||
|
BlobTransferSender, FileTransferRequest,
|
||||||
|
};
|
||||||
|
use crate::namespace::{NamespaceClientIn, NamespaceError};
|
||||||
|
use crate::namespace_store::SourceRecovery;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum BlobSourceIn {
|
||||||
|
BeginTransfer {
|
||||||
|
offer: BlobTransferOffer,
|
||||||
|
},
|
||||||
|
TransferCompleted {
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
destination: ActorAddress,
|
||||||
|
failure_proxy: Option<ActorAddress>,
|
||||||
|
result: Result<(), String>,
|
||||||
|
},
|
||||||
|
Retire,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkMessage for BlobSourceIn {
|
||||||
|
fn type_tag() -> &'static str {
|
||||||
|
"data-plane.blob-source.in.v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActorTransferCompletion {
|
||||||
|
runtime: Runtime,
|
||||||
|
source: ActorAddress,
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
destination: ActorAddress,
|
||||||
|
failure_proxy: Option<ActorAddress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobTransferCompletion for ActorTransferCompletion {
|
||||||
|
fn complete(self: Box<Self>, result: Result<(), String>) {
|
||||||
|
let _ = self.runtime.send_to(
|
||||||
|
self.source,
|
||||||
|
BlobSourceIn::TransferCompleted {
|
||||||
|
transfer_id: self.transfer_id,
|
||||||
|
destination: self.destination,
|
||||||
|
failure_proxy: self.failure_proxy,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait BlobSourcePublisher: Send + Sync + 'static {
|
||||||
|
fn publish_source(&self, source: ActorAddress) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait BlobSourceRetirement: Send + Sync + 'static {
|
||||||
|
fn retired(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileBlobSourceActor {
|
||||||
|
runtime: Runtime,
|
||||||
|
sender: Arc<dyn BlobTransferSender>,
|
||||||
|
recovery_path: Option<PathBuf>,
|
||||||
|
label: String,
|
||||||
|
file: File,
|
||||||
|
offset: u64,
|
||||||
|
length: u64,
|
||||||
|
active: HashSet<BlobTransferId>,
|
||||||
|
retiring: bool,
|
||||||
|
retirement: Option<Arc<dyn BlobSourceRetirement>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileBlobSourceActor {
|
||||||
|
pub fn open(
|
||||||
|
runtime: Runtime,
|
||||||
|
sender: Arc<dyn BlobTransferSender>,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
) -> Result<Self, NamespaceError> {
|
||||||
|
let path = path.as_ref().to_path_buf();
|
||||||
|
let file = OpenOptions::new().read(true).open(&path).map_err(|error| {
|
||||||
|
NamespaceError::SourceRecovery(format!("open {}: {error}", path.display()))
|
||||||
|
})?;
|
||||||
|
let length = file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| {
|
||||||
|
NamespaceError::SourceRecovery(format!("stat {}: {error}", path.display()))
|
||||||
|
})?
|
||||||
|
.len();
|
||||||
|
Ok(Self {
|
||||||
|
runtime,
|
||||||
|
sender,
|
||||||
|
recovery_path: Some(path.clone()),
|
||||||
|
label: path.display().to_string(),
|
||||||
|
file,
|
||||||
|
offset: 0,
|
||||||
|
length,
|
||||||
|
active: HashSet::new(),
|
||||||
|
retiring: false,
|
||||||
|
retirement: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recover(
|
||||||
|
runtime: Runtime,
|
||||||
|
sender: Arc<dyn BlobTransferSender>,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
expected_length: u64,
|
||||||
|
) -> Result<Self, NamespaceError> {
|
||||||
|
let source = Self::open(runtime, sender, path)?;
|
||||||
|
if source.length != expected_length {
|
||||||
|
return Err(NamespaceError::SourceRecovery(format!(
|
||||||
|
"recovered file {} has length {}, expected {}",
|
||||||
|
source.label, source.length, expected_length
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_file_region(
|
||||||
|
runtime: Runtime,
|
||||||
|
sender: Arc<dyn BlobTransferSender>,
|
||||||
|
file: File,
|
||||||
|
offset: u64,
|
||||||
|
length: u64,
|
||||||
|
retirement: Option<Arc<dyn BlobSourceRetirement>>,
|
||||||
|
) -> Result<Self, NamespaceError> {
|
||||||
|
let file_length = file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| NamespaceError::SourceRecovery(format!("stat arena source: {error}")))?
|
||||||
|
.len();
|
||||||
|
let end = offset.checked_add(length).ok_or_else(|| {
|
||||||
|
NamespaceError::SourceRecovery("arena source range overflow".to_owned())
|
||||||
|
})?;
|
||||||
|
if end > file_length {
|
||||||
|
return Err(NamespaceError::SourceRecovery(format!(
|
||||||
|
"arena source range ends at {end}, backing length is {file_length}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
runtime,
|
||||||
|
sender,
|
||||||
|
recovery_path: None,
|
||||||
|
label: format!("arena region {offset}..{end}"),
|
||||||
|
file,
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
active: HashSet::new(),
|
||||||
|
retiring: false,
|
||||||
|
retirement,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn length(&self) -> u64 {
|
||||||
|
self.length
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recovery(&self) -> SourceRecovery {
|
||||||
|
SourceRecovery::File {
|
||||||
|
path: self
|
||||||
|
.recovery_path
|
||||||
|
.clone()
|
||||||
|
.expect("disk source has a recovery path"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_failure(
|
||||||
|
&self,
|
||||||
|
ctx: &Ctx<'_>,
|
||||||
|
destination: ActorAddress,
|
||||||
|
failure_proxy: Option<ActorAddress>,
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
if let Some(proxy) = failure_proxy {
|
||||||
|
let _ = ctx.send(
|
||||||
|
proxy,
|
||||||
|
NamespaceClientIn::TransferFailed {
|
||||||
|
destination,
|
||||||
|
transfer_id,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let _ = ctx.send(
|
||||||
|
destination,
|
||||||
|
BlobTransferEvent::Failed {
|
||||||
|
transfer_id,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail_destination(&self, ctx: &Ctx<'_>, offer: &BlobTransferOffer, reason: String) {
|
||||||
|
self.send_failure(
|
||||||
|
ctx,
|
||||||
|
offer.destination,
|
||||||
|
offer.failure_proxy,
|
||||||
|
offer.transfer_id,
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_stop_retired(&mut self, ctx: &Ctx<'_>) {
|
||||||
|
if self.retiring && self.active.is_empty() {
|
||||||
|
if let Some(retirement) = self.retirement.take() {
|
||||||
|
retirement.retired();
|
||||||
|
}
|
||||||
|
ctx.stop_self();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for FileBlobSourceActor {
|
||||||
|
type Incoming = BlobSourceIn;
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn handle(&mut self, ctx: &Ctx<'_>, message: BlobSourceIn) {
|
||||||
|
match message {
|
||||||
|
BlobSourceIn::BeginTransfer { offer } => {
|
||||||
|
if self.retiring {
|
||||||
|
self.fail_destination(ctx, &offer, "blob source is retired".to_owned());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.active.insert(offer.transfer_id) {
|
||||||
|
self.fail_destination(
|
||||||
|
ctx,
|
||||||
|
&offer,
|
||||||
|
"duplicate blob transfer identifier".to_owned(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let file = match self.file.try_clone() {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) => {
|
||||||
|
self.active.remove(&offer.transfer_id);
|
||||||
|
self.fail_destination(
|
||||||
|
ctx,
|
||||||
|
&offer,
|
||||||
|
format!("clone source file {}: {error}", self.label),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let completion: Box<dyn BlobTransferCompletion> =
|
||||||
|
Box::new(ActorTransferCompletion {
|
||||||
|
runtime: self.runtime.clone(),
|
||||||
|
source: ctx.self_addr(),
|
||||||
|
transfer_id: offer.transfer_id,
|
||||||
|
destination: offer.destination,
|
||||||
|
failure_proxy: offer.failure_proxy,
|
||||||
|
});
|
||||||
|
let request = FileTransferRequest {
|
||||||
|
offer: offer.clone(),
|
||||||
|
file,
|
||||||
|
offset: self.offset,
|
||||||
|
length: self.length,
|
||||||
|
completion,
|
||||||
|
};
|
||||||
|
if let Err(error) = self.sender.start_file(request) {
|
||||||
|
self.active.remove(&offer.transfer_id);
|
||||||
|
self.fail_destination(ctx, &offer, error);
|
||||||
|
self.maybe_stop_retired(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BlobSourceIn::TransferCompleted {
|
||||||
|
transfer_id,
|
||||||
|
destination,
|
||||||
|
failure_proxy,
|
||||||
|
result,
|
||||||
|
} => {
|
||||||
|
if self.active.remove(&transfer_id) {
|
||||||
|
if let Err(reason) = result {
|
||||||
|
self.send_failure(ctx, destination, failure_proxy, transfer_id, reason);
|
||||||
|
}
|
||||||
|
self.maybe_stop_retired(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BlobSourceIn::Retire => {
|
||||||
|
self.retiring = true;
|
||||||
|
self.maybe_stop_retired(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_blob_source_codecs(registry: &mut CodecRegistry) {
|
||||||
|
registry.register::<BlobSourceIn, _>(JsonCodec::default());
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
#![cfg(target_os = "linux")]
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId};
|
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId};
|
||||||
use data_plane::blob::{
|
use data_plane::blob::{
|
||||||
BLOB_HEADER_LEN, Blob, BlobError, BlobLease, BlobMetadata, BlobSharedState, ContentDigest,
|
BLOB_HEADER_LEN, Blob, BlobError, BlobLease, BlobMetadata, BlobSharedState, LeaseReleaser,
|
||||||
LeaseReleaser,
|
|
||||||
};
|
};
|
||||||
use data_plane::bootstrap::{self, BootstrapSpec};
|
use data_plane::bootstrap::{self, BootstrapSpec};
|
||||||
use data_plane::data_plane::DataPlaneBootstrap;
|
use data_plane::data_plane::DataPlaneBootstrap;
|
||||||
use data_plane::host::{BlobSource, HostDataPlaneConfig, HostDataPlaneSessionActor};
|
use data_plane::host::{HostDataPlaneConfig, HostDataPlaneSessionActor};
|
||||||
use data_plane::path::{DataPath, JobContext};
|
use data_plane::path::{DataPath, JobContext};
|
||||||
use data_plane::protocol::{DataPlaneError, HostSessionIn, JobCapability, PublishedBlobInfo};
|
use data_plane::protocol::{DataPlaneError, JobCapability};
|
||||||
use futures_lite::future::{self, FutureExt};
|
use futures_lite::future::{self, FutureExt};
|
||||||
use swactor::Error;
|
use swactor::Error;
|
||||||
use swactor::actor::ActorAddress;
|
use swactor::actor::ActorAddress;
|
||||||
|
|
@ -56,24 +54,121 @@ impl RemoteSink for BlackHoleSink {
|
||||||
struct Harness {
|
struct Harness {
|
||||||
_host_engine: Engine,
|
_host_engine: Engine,
|
||||||
_child_engine: Engine,
|
_child_engine: Engine,
|
||||||
host_runtime: Runtime,
|
_temp: TempState,
|
||||||
host_session: ActorAddress,
|
|
||||||
bootstrap: DataPlaneBootstrap,
|
bootstrap: DataPlaneBootstrap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct TempState {
|
||||||
|
root: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempState {
|
||||||
|
fn new() -> Self {
|
||||||
|
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"swactor-actor-blob-{}-{sequence}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempState {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn path(value: &str) -> DataPath {
|
fn path(value: &str) -> DataPath {
|
||||||
DataPath::parse(value).expect("test path")
|
DataPath::parse(value).expect("test path")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_parts() -> (RuntimeParts, Runtime) {
|
fn runtime_parts() -> (RuntimeParts, Runtime) {
|
||||||
let mut config = RuntimeConfig::default();
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
config.worker_count = 1;
|
worker_count: 1,
|
||||||
let parts = RuntimeParts::new(config);
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
let runtime = parts.runtime().clone();
|
let runtime = parts.runtime().clone();
|
||||||
(parts, runtime)
|
(parts, runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LoopbackSender {
|
||||||
|
runtime: Runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl data_plane::blob_transfer::BlobTransferSender for LoopbackSender {
|
||||||
|
fn start_file(
|
||||||
|
&self,
|
||||||
|
request: data_plane::blob_transfer::FileTransferRequest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
let mut bytes = vec![0_u8; request.length as usize];
|
||||||
|
request
|
||||||
|
.file
|
||||||
|
.read_exact_at(&mut bytes, request.offset)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
data_plane::blob_transfer::BlobTransferEvent::Chunk {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
data_plane::blob_transfer::BlobTransferEvent::Finished {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
request.completion.complete(Ok(()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DirectReceiver;
|
||||||
|
|
||||||
|
impl data_plane::blob_transfer::BlobTransferReceiver for DirectReceiver {
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
destination: ActorAddress,
|
||||||
|
transfer_id: data_plane::blob_transfer::BlobTransferId,
|
||||||
|
) -> Result<data_plane::blob_transfer::BlobTransferOffer, String> {
|
||||||
|
Ok(data_plane::blob_transfer::BlobTransferOffer {
|
||||||
|
transfer_id,
|
||||||
|
destination,
|
||||||
|
failure_proxy: None,
|
||||||
|
transport: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&self, _offer: &data_plane::blob_transfer::BlobTransferOffer) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StaticDiscovery(ActorAddress);
|
||||||
|
|
||||||
|
impl data_plane::namespace::NamespaceDiscovery for StaticDiscovery {
|
||||||
|
fn current_directory(&self) -> Option<ActorAddress> {
|
||||||
|
Some(self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoopSourceRegistrar;
|
||||||
|
|
||||||
|
impl data_plane::source::BlobSourcePublisher for NoopSourceRegistrar {
|
||||||
|
fn publish_source(&self, _source: ActorAddress) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn harness(arena_bytes: u64) -> Harness {
|
fn harness(arena_bytes: u64) -> Harness {
|
||||||
|
let temp = TempState::new();
|
||||||
let mut arena = ArenaManager::boot(ArenaConfig {
|
let mut arena = ArenaManager::boot(ArenaConfig {
|
||||||
node_id: NodeId(1),
|
node_id: NodeId(1),
|
||||||
reservation_ceiling: arena_bytes,
|
reservation_ceiling: arena_bytes,
|
||||||
|
|
@ -97,35 +192,6 @@ fn harness(arena_bytes: u64) -> Harness {
|
||||||
child_runtime.set_remote_sink(Arc::new(DirectRuntimeSink {
|
child_runtime.set_remote_sink(Arc::new(DirectRuntimeSink {
|
||||||
destination: host_runtime.clone(),
|
destination: host_runtime.clone(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let mut blobs = BTreeMap::new();
|
|
||||||
blobs.insert(
|
|
||||||
path("/models/tiny-linear/weights"),
|
|
||||||
BlobSource::with_sha256(Arc::<[u8]>::from(WEIGHTS)),
|
|
||||||
);
|
|
||||||
blobs.insert(
|
|
||||||
path("/models/second"),
|
|
||||||
BlobSource::new(Arc::<[u8]>::from(b"second-blob".as_slice())),
|
|
||||||
);
|
|
||||||
let host_session = host_runtime
|
|
||||||
.spawn(
|
|
||||||
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
|
||||||
arena,
|
|
||||||
arena_generation: ARENA_GENERATION,
|
|
||||||
session_generation: SESSION_GENERATION,
|
|
||||||
capability: CAPABILITY,
|
|
||||||
job_context: JobContext {
|
|
||||||
run_id: "run-7".to_owned(),
|
|
||||||
read_prefixes: vec![path("/models")],
|
|
||||||
write_prefixes: vec![path("/runs/run-7/results")],
|
|
||||||
},
|
|
||||||
blobs,
|
|
||||||
route_registrar: None,
|
|
||||||
})
|
|
||||||
.expect("host session config"),
|
|
||||||
)
|
|
||||||
.expect("spawn host session");
|
|
||||||
|
|
||||||
let host_engine = Engine::new(
|
let host_engine = Engine::new(
|
||||||
host_parts,
|
host_parts,
|
||||||
TokioBackend::new(TokioConfig::default()).expect("host backend"),
|
TokioBackend::new(TokioConfig::default()).expect("host backend"),
|
||||||
|
|
@ -136,6 +202,77 @@ fn harness(arena_bytes: u64) -> Harness {
|
||||||
TokioBackend::new(TokioConfig::default()).expect("child backend"),
|
TokioBackend::new(TokioConfig::default()).expect("child backend"),
|
||||||
)
|
)
|
||||||
.expect("child engine");
|
.expect("child engine");
|
||||||
|
|
||||||
|
let sender: Arc<dyn data_plane::blob_transfer::BlobTransferSender> = Arc::new(LoopbackSender {
|
||||||
|
runtime: host_runtime.clone(),
|
||||||
|
});
|
||||||
|
let directory_actor = data_plane::namespace::DataDirectoryActor::recover(
|
||||||
|
temp.root.join("namespace.json"),
|
||||||
|
|_record, _length| {
|
||||||
|
Err(data_plane::namespace::NamespaceError::SourceRecovery(
|
||||||
|
"unexpected recovery".to_owned(),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let directory = host_runtime.spawn(directory_actor).unwrap();
|
||||||
|
let directory_client =
|
||||||
|
data_plane::namespace::DirectoryClient::new(host_runtime.clone(), directory);
|
||||||
|
for (logical, name, bytes) in [
|
||||||
|
("/models/tiny-linear/weights", "weights.bin", WEIGHTS),
|
||||||
|
("/models/second", "second.bin", b"second-blob".as_slice()),
|
||||||
|
] {
|
||||||
|
let file_path = temp.root.join(name);
|
||||||
|
std::fs::write(&file_path, bytes).unwrap();
|
||||||
|
let source = data_plane::source::FileBlobSourceActor::open(
|
||||||
|
host_runtime.clone(),
|
||||||
|
Arc::clone(&sender),
|
||||||
|
&file_path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let length = source.length();
|
||||||
|
let recovery = source.recovery();
|
||||||
|
let source = host_runtime.spawn(source).unwrap();
|
||||||
|
future::block_on(directory_client.register(
|
||||||
|
path(logical),
|
||||||
|
source,
|
||||||
|
length,
|
||||||
|
recovery,
|
||||||
|
data_plane::namespace::OperationId::from_u128(u128::from(length) + 1),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let proxy = host_runtime
|
||||||
|
.spawn(data_plane::namespace::NamespaceClientActor::new(
|
||||||
|
host_engine.handle(),
|
||||||
|
host_runtime.create_sender(),
|
||||||
|
Arc::new(StaticDiscovery(directory)),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let namespace = data_plane::namespace::NamespaceClient::new(host_runtime.clone(), proxy);
|
||||||
|
let host_session = host_runtime
|
||||||
|
.spawn(
|
||||||
|
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
||||||
|
runtime: host_runtime.clone(),
|
||||||
|
arena,
|
||||||
|
arena_generation: ARENA_GENERATION,
|
||||||
|
session_generation: SESSION_GENERATION,
|
||||||
|
capability: CAPABILITY,
|
||||||
|
job_context: JobContext {
|
||||||
|
run_id: "run-7".to_owned(),
|
||||||
|
read_prefixes: vec![path("/models"), path("/runs/run-7/results")],
|
||||||
|
write_prefixes: vec![path("/runs/run-7/results")],
|
||||||
|
},
|
||||||
|
namespace: Some(namespace),
|
||||||
|
transfer_receiver: Some(Arc::new(DirectReceiver)),
|
||||||
|
source_sender: Some(sender),
|
||||||
|
source_publisher: Some(Arc::new(NoopSourceRegistrar)),
|
||||||
|
route_registrar: None,
|
||||||
|
})
|
||||||
|
.expect("host session config"),
|
||||||
|
)
|
||||||
|
.expect("spawn host session");
|
||||||
let bootstrap = future::block_on(DataPlaneBootstrap::attach(
|
let bootstrap = future::block_on(DataPlaneBootstrap::attach(
|
||||||
handoff.arena_fd,
|
handoff.arena_fd,
|
||||||
child_runtime,
|
child_runtime,
|
||||||
|
|
@ -147,8 +284,7 @@ fn harness(arena_bytes: u64) -> Harness {
|
||||||
Harness {
|
Harness {
|
||||||
_host_engine: host_engine,
|
_host_engine: host_engine,
|
||||||
_child_engine: child_engine,
|
_child_engine: child_engine,
|
||||||
host_runtime,
|
_temp: temp,
|
||||||
host_session,
|
|
||||||
bootstrap,
|
bootstrap,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -160,21 +296,6 @@ impl LeaseReleaser for NoopReleaser {
|
||||||
fn release(&self, _lease: BlobLease) {}
|
fn release(&self, _lease: BlobLease) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_published(harness: &Harness, logical: &str) -> Option<PublishedBlobInfo> {
|
|
||||||
future::block_on(async {
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.ask::<HostSessionIn, Option<PublishedBlobInfo>>(harness.host_session, |reply_to| {
|
|
||||||
HostSessionIn::InspectPublished {
|
|
||||||
path: path(logical),
|
|
||||||
reply_to,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect("inspect ask")
|
|
||||||
.await
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attachment_without_a_host_reply_fails_on_actor_deadline() {
|
fn attachment_without_a_host_reply_fails_on_actor_deadline() {
|
||||||
let mut arena = ArenaManager::boot(ArenaConfig {
|
let mut arena = ArenaManager::boot(ArenaConfig {
|
||||||
|
|
@ -202,8 +323,10 @@ fn attachment_without_a_host_reply_fails_on_actor_deadline() {
|
||||||
ActorAddress::new_random(),
|
ActorAddress::new_random(),
|
||||||
CAPABILITY,
|
CAPABILITY,
|
||||||
None,
|
None,
|
||||||
engine.handle(),
|
data_plane::data_plane::AttachDeadline {
|
||||||
Duration::from_millis(20),
|
engine: engine.handle(),
|
||||||
|
timeout: Duration::from_millis(20),
|
||||||
|
},
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
|
|
@ -223,7 +346,7 @@ fn routed_read_blob_maps_final_sealed_lease_without_copying() {
|
||||||
.expect("read blob");
|
.expect("read blob");
|
||||||
|
|
||||||
assert_eq!(blob.length(), 24);
|
assert_eq!(blob.length(), 24);
|
||||||
assert!(blob.digest().is_some());
|
assert!(blob.digest().is_none());
|
||||||
let lease = blob.lease();
|
let lease = blob.lease();
|
||||||
let view = blob.map().expect("map sealed blob");
|
let view = blob.map().expect("map sealed blob");
|
||||||
assert_eq!(view.as_ref(), WEIGHTS);
|
assert_eq!(view.as_ref(), WEIGHTS);
|
||||||
|
|
@ -304,7 +427,7 @@ fn thirty_two_concurrent_remote_opens_complete_without_cross_wiring() {
|
||||||
) -> future::Boxed<Vec<(usize, Blob)>> {
|
) -> future::Boxed<Vec<(usize, Blob)>> {
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
return async move {
|
return async move {
|
||||||
let path = if first % 2 == 0 {
|
let path = if first.is_multiple_of(2) {
|
||||||
"/models/tiny-linear/weights"
|
"/models/tiny-linear/weights"
|
||||||
} else {
|
} else {
|
||||||
"/models/second"
|
"/models/second"
|
||||||
|
|
@ -407,7 +530,7 @@ fn path_absence_and_authorization_fail_before_blob_success() {
|
||||||
harness
|
harness
|
||||||
.bootstrap
|
.bootstrap
|
||||||
.data_plane
|
.data_plane
|
||||||
.read_blob_path("/runs/self/results/private"),
|
.read_blob_path("/runs/self/private"),
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
unauthorized,
|
unauthorized,
|
||||||
|
|
@ -463,21 +586,15 @@ fn write_blob_seals_once_and_abort_publishes_nothing() {
|
||||||
drop(view);
|
drop(view);
|
||||||
future::block_on(writer.seal()).expect("seal and publish");
|
future::block_on(writer.seal()).expect("seal and publish");
|
||||||
|
|
||||||
let published = inspect_published(&harness, "/runs/self/results/blob")
|
let published = future::block_on(
|
||||||
.expect("clean exit publishes exactly once");
|
|
||||||
assert_eq!(published.metadata.length, 6);
|
|
||||||
// SAFETY: publication validated a sealed lease wholly inside this mapping.
|
|
||||||
let bytes = unsafe {
|
|
||||||
std::slice::from_raw_parts(
|
|
||||||
harness
|
harness
|
||||||
.bootstrap
|
.bootstrap
|
||||||
.arena
|
.data_plane
|
||||||
.base_ptr()
|
.read_blob_path("/runs/self/results/blob"),
|
||||||
.add((published.lease.offset + BLOB_HEADER_LEN) as usize),
|
|
||||||
6,
|
|
||||||
)
|
)
|
||||||
};
|
.expect("clean exit publishes exactly once");
|
||||||
assert_eq!(bytes, b"result");
|
assert_eq!(published.length(), 6);
|
||||||
|
assert_eq!(published.map().unwrap().as_ref(), b"result");
|
||||||
assert!(future::block_on(writer.seal()).is_err());
|
assert!(future::block_on(writer.seal()).is_err());
|
||||||
|
|
||||||
let mut aborted = future::block_on(
|
let mut aborted = future::block_on(
|
||||||
|
|
@ -491,209 +608,13 @@ fn write_blob_seals_once_and_abort_publishes_nothing() {
|
||||||
assert!(future::block_on(aborted.abort()).is_err());
|
assert!(future::block_on(aborted.abort()).is_err());
|
||||||
drop(active);
|
drop(active);
|
||||||
future::block_on(aborted.abort()).expect("abort after view closes");
|
future::block_on(aborted.abort()).expect("abort after view closes");
|
||||||
assert_eq!(
|
assert!(matches!(
|
||||||
inspect_published(&harness, "/runs/self/results/aborted"),
|
future::block_on(
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn streamed_blob_chunks_fill_the_final_lease_before_waiting_open_completes() {
|
|
||||||
let harness = harness(4096);
|
|
||||||
let streamed = path("/models/streamed");
|
|
||||||
let payload = b"direct-final-lease";
|
|
||||||
let metadata = BlobMetadata {
|
|
||||||
length: payload.len() as u64,
|
|
||||||
digest: Some(ContentDigest::sha256(payload)),
|
|
||||||
};
|
|
||||||
future::block_on(async {
|
|
||||||
harness
|
harness
|
||||||
.host_runtime
|
|
||||||
.ask::<HostSessionIn, Result<(), DataPlaneError>>(harness.host_session, |reply_to| {
|
|
||||||
HostSessionIn::BeginBlobSource {
|
|
||||||
path: streamed.clone(),
|
|
||||||
metadata,
|
|
||||||
reply_to,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect("begin source ask")
|
|
||||||
.await
|
|
||||||
.expect("source lease allocated");
|
|
||||||
|
|
||||||
let read = harness.bootstrap.data_plane.read_blob(&streamed);
|
|
||||||
futures_lite::pin!(read);
|
|
||||||
assert!(
|
|
||||||
future::poll_once(&mut read).await.is_none(),
|
|
||||||
"read remains pending until the source seals"
|
|
||||||
);
|
|
||||||
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.send_to(
|
|
||||||
harness.host_session,
|
|
||||||
HostSessionIn::BlobSourceChunk {
|
|
||||||
path: streamed.clone(),
|
|
||||||
bytes: payload[..6].to_vec(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.send_to(
|
|
||||||
harness.host_session,
|
|
||||||
HostSessionIn::BlobSourceChunk {
|
|
||||||
path: streamed.clone(),
|
|
||||||
bytes: payload[6..].to_vec(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.send_to(
|
|
||||||
harness.host_session,
|
|
||||||
HostSessionIn::FinishBlobSource {
|
|
||||||
path: streamed.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let blob = read.await.expect("sealed streamed blob");
|
|
||||||
assert_eq!(blob.map().unwrap().as_ref(), payload);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_opened_after_stream_seal_acquires_persistent_source_lease() {
|
|
||||||
let harness = harness(4096);
|
|
||||||
let streamed = path("/models/sealed-before-open");
|
|
||||||
let payload = b"sealed-first";
|
|
||||||
future::block_on(async {
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.ask::<HostSessionIn, Result<(), DataPlaneError>>(harness.host_session, |reply_to| {
|
|
||||||
HostSessionIn::BeginBlobSource {
|
|
||||||
path: streamed.clone(),
|
|
||||||
metadata: BlobMetadata {
|
|
||||||
length: payload.len() as u64,
|
|
||||||
digest: Some(ContentDigest::sha256(payload)),
|
|
||||||
},
|
|
||||||
reply_to,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.send_to(
|
|
||||||
harness.host_session,
|
|
||||||
HostSessionIn::BlobSourceChunk {
|
|
||||||
path: streamed.clone(),
|
|
||||||
bytes: payload.to_vec(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.send_to(
|
|
||||||
harness.host_session,
|
|
||||||
HostSessionIn::FinishBlobSource {
|
|
||||||
path: streamed.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
|
||||||
|
|
||||||
let blob = harness
|
|
||||||
.bootstrap
|
.bootstrap
|
||||||
.data_plane
|
.data_plane
|
||||||
.read_blob(&streamed)
|
.read_blob_path("/runs/self/results/aborted")
|
||||||
.await
|
),
|
||||||
.unwrap();
|
Err(DataPlaneError::PathNotFound(_))
|
||||||
assert_eq!(blob.map().unwrap().as_ref(), payload);
|
));
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn partial_overlength_and_digest_mismatch_sources_fail_waiting_opens() {
|
|
||||||
let harness = harness(4096);
|
|
||||||
future::block_on(async {
|
|
||||||
let cases = [
|
|
||||||
(
|
|
||||||
"/models/partial",
|
|
||||||
BlobMetadata {
|
|
||||||
length: 4,
|
|
||||||
digest: None,
|
|
||||||
},
|
|
||||||
b"ab".as_slice(),
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/models/overlength",
|
|
||||||
BlobMetadata {
|
|
||||||
length: 4,
|
|
||||||
digest: None,
|
|
||||||
},
|
|
||||||
b"abcde".as_slice(),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/models/bad-digest",
|
|
||||||
BlobMetadata {
|
|
||||||
length: 4,
|
|
||||||
digest: Some(ContentDigest::sha256(b"good")),
|
|
||||||
},
|
|
||||||
b"evil".as_slice(),
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (name, metadata, bytes, finish) in cases {
|
|
||||||
let source = path(name);
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.ask::<HostSessionIn, Result<(), DataPlaneError>>(
|
|
||||||
harness.host_session,
|
|
||||||
|reply_to| HostSessionIn::BeginBlobSource {
|
|
||||||
path: source.clone(),
|
|
||||||
metadata,
|
|
||||||
reply_to,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let read = harness.bootstrap.data_plane.read_blob(&source);
|
|
||||||
futures_lite::pin!(read);
|
|
||||||
assert!(future::poll_once(&mut read).await.is_none());
|
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.send_to(
|
|
||||||
harness.host_session,
|
|
||||||
HostSessionIn::BlobSourceChunk {
|
|
||||||
path: source.clone(),
|
|
||||||
bytes: bytes.to_vec(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
if finish {
|
|
||||||
harness
|
|
||||||
.host_runtime
|
|
||||||
.send_to(
|
|
||||||
harness.host_session,
|
|
||||||
HostSessionIn::FinishBlobSource {
|
|
||||||
path: source.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let result = read.await;
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(DataPlaneError::Blob(_))),
|
|
||||||
"unexpected source failure for {name}: {result:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId};
|
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId};
|
||||||
use data_plane::byte_ring::{
|
use data_plane::byte_ring::{
|
||||||
self, attach, install, ByteRingSpec, FlowError, HeaderError, RecordKind, Role,
|
self, ByteRingSpec, FlowError, HeaderError, RecordKind, Role, attach, install,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn arena() -> ArenaManager {
|
fn arena() -> ArenaManager {
|
||||||
|
|
@ -45,10 +45,7 @@ fn scribble_u64(arena: &ArenaManager, handle: &byte_ring::RingHandle, off: u64,
|
||||||
#[test]
|
#[test]
|
||||||
fn endpoints_are_send() {
|
fn endpoints_are_send() {
|
||||||
fn assert_send<T: Send>() {}
|
fn assert_send<T: Send>() {}
|
||||||
let (arena, handle) = installed(64, 1);
|
|
||||||
let producer = attach(&arena, handle, Role::Producer).expect("attach");
|
|
||||||
assert_send::<byte_ring::Endpoint>();
|
assert_send::<byte_ring::Endpoint>();
|
||||||
drop(producer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── install ─────────────────────────────────────────────────────────────────
|
// ─── install ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -57,8 +54,8 @@ fn endpoints_are_send() {
|
||||||
#[test]
|
#[test]
|
||||||
fn install_writes_valid_header_and_zeroed_data() {
|
fn install_writes_valid_header_and_zeroed_data() {
|
||||||
use data_plane::byte_ring::{
|
use data_plane::byte_ring::{
|
||||||
OFF_CAPACITY, OFF_COMMIT, OFF_CONSUME, OFF_GENERATION, OFF_MAGIC, OFF_VERSION,
|
OFF_CAPACITY, OFF_COMMIT, OFF_CONSUME, OFF_GENERATION, OFF_MAGIC, OFF_VERSION, RING_MAGIC,
|
||||||
RING_MAGIC, RING_VERSION,
|
RING_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (arena, handle) = installed(4096, 7);
|
let (arena, handle) = installed(4096, 7);
|
||||||
|
|
@ -83,6 +80,7 @@ fn install_writes_valid_header_and_zeroed_data() {
|
||||||
assert!(data.iter().all(|&b| b == 0), "fresh data region is zero");
|
assert!(data.iter().all(|&b| b == 0), "fresh data region is zero");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
fn install_rejects_bad_specs() {
|
fn install_rejects_bad_specs() {
|
||||||
let mut arena = arena();
|
let mut arena = arena();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -101,9 +99,11 @@ fn install_rejects_bad_specs() {
|
||||||
|
|
||||||
// ─── attach: untrusted-header containment (P5) ──────────────────────────────
|
// ─── attach: untrusted-header containment (P5) ──────────────────────────────
|
||||||
|
|
||||||
|
type CorruptHeaderCase = (Option<(u64, u64)>, (u64, u64), HeaderError);
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_rejects_corrupt_headers_without_trusting_them() {
|
fn attach_rejects_corrupt_headers_without_trusting_them() {
|
||||||
let cases: Vec<(Option<(u64, u64)>, (u64, u64), HeaderError)> = vec![
|
let cases: Vec<CorruptHeaderCase> = vec![
|
||||||
// (optional pre-scribble, (field, value), expected)
|
// (optional pre-scribble, (field, value), expected)
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
|
|
@ -113,27 +113,43 @@ fn attach_rejects_corrupt_headers_without_trusting_them() {
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
(byte_ring::OFF_VERSION, 2),
|
(byte_ring::OFF_VERSION, 2),
|
||||||
HeaderError::UnsupportedVersion { found: 2, supported: 1 },
|
HeaderError::UnsupportedVersion {
|
||||||
|
found: 2,
|
||||||
|
supported: 1,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
(byte_ring::OFF_CAPACITY, 999),
|
(byte_ring::OFF_CAPACITY, 999),
|
||||||
HeaderError::CapacityMismatch { header: 999, handle: 512 },
|
HeaderError::CapacityMismatch {
|
||||||
|
header: 999,
|
||||||
|
handle: 512,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
(byte_ring::OFF_GENERATION, 4),
|
(byte_ring::OFF_GENERATION, 4),
|
||||||
HeaderError::GenerationMismatch { header: 4, handle: 3 },
|
HeaderError::GenerationMismatch {
|
||||||
|
header: 4,
|
||||||
|
handle: 3,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
Some((byte_ring::OFF_CONSUME, 10)),
|
Some((byte_ring::OFF_CONSUME, 10)),
|
||||||
(byte_ring::OFF_COMMIT, 5),
|
(byte_ring::OFF_COMMIT, 5),
|
||||||
HeaderError::CommitBelowConsume { commit: 5, consume: 10 },
|
HeaderError::CommitBelowConsume {
|
||||||
|
commit: 5,
|
||||||
|
consume: 10,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
(byte_ring::OFF_COMMIT, 600),
|
(byte_ring::OFF_COMMIT, 600),
|
||||||
HeaderError::ReadableExceedsCapacity { commit: 600, consume: 0, capacity: 512 },
|
HeaderError::ReadableExceedsCapacity {
|
||||||
|
commit: 600,
|
||||||
|
consume: 0,
|
||||||
|
capacity: 512,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
for (pre, (field, value), expected) in cases {
|
for (pre, (field, value), expected) in cases {
|
||||||
|
|
@ -164,7 +180,10 @@ fn basic_round_trip_preserves_bytes() {
|
||||||
.send_record(RecordKind::Data, b"weights-bytes")
|
.send_record(RecordKind::Data, b"weights-bytes")
|
||||||
.expect("send");
|
.expect("send");
|
||||||
let received = consumer.recv_record().expect("recv");
|
let received = consumer.recv_record().expect("recv");
|
||||||
assert_eq!(received, Some((RecordKind::Data, b"weights-bytes".to_vec())));
|
assert_eq!(
|
||||||
|
received,
|
||||||
|
Some((RecordKind::Data, b"weights-bytes".to_vec()))
|
||||||
|
);
|
||||||
assert_eq!(consumer.recv_record().expect("recv empty"), None);
|
assert_eq!(consumer.recv_record().expect("recv empty"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,7 +296,10 @@ fn reserve_reports_exact_free_space_and_recovers() {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
producer.reserve(50).unwrap_err(),
|
producer.reserve(50).unwrap_err(),
|
||||||
FlowError::InsufficientSpace { requested: 50, free: 28 }
|
FlowError::InsufficientSpace {
|
||||||
|
requested: 50,
|
||||||
|
free: 28
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bytes are readable, intact, and consuming frees the space again.
|
// Bytes are readable, intact, and consuming frees the space again.
|
||||||
|
|
@ -315,7 +337,9 @@ fn records_delimit_completion_distinctly() {
|
||||||
assert_eq!(consumer.recv_record().expect("post-eof"), None);
|
assert_eq!(consumer.recv_record().expect("post-eof"), None);
|
||||||
|
|
||||||
// Fault is a distinct terminal, not a second EOF.
|
// Fault is a distinct terminal, not a second EOF.
|
||||||
producer.send_record(RecordKind::Fault, b"reason").expect("send");
|
producer
|
||||||
|
.send_record(RecordKind::Fault, b"reason")
|
||||||
|
.expect("send");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
consumer.recv_record().expect("recv fault"),
|
consumer.recv_record().expect("recv fault"),
|
||||||
Some((RecordKind::Fault, b"reason".to_vec()))
|
Some((RecordKind::Fault, b"reason".to_vec()))
|
||||||
|
|
@ -358,7 +382,10 @@ fn stale_reservations_are_rejected_after_generation_change() {
|
||||||
scribble_u64(&arena, &handle, byte_ring::OFF_GENERATION, 2);
|
scribble_u64(&arena, &handle, byte_ring::OFF_GENERATION, 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
producer.commit(reservation).unwrap_err(),
|
producer.commit(reservation).unwrap_err(),
|
||||||
FlowError::StaleReservation { reservation: 1, ring: 2 }
|
FlowError::StaleReservation {
|
||||||
|
reservation: 1,
|
||||||
|
ring: 2
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,7 +399,10 @@ fn wrong_role_operations_are_rejected() {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
consumer.reserve(8).unwrap_err(),
|
consumer.reserve(8).unwrap_err(),
|
||||||
FlowError::RoleViolation { operation: "reserve", role: Role::Consumer }
|
FlowError::RoleViolation {
|
||||||
|
operation: "reserve",
|
||||||
|
role: Role::Consumer
|
||||||
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
consumer
|
consumer
|
||||||
|
|
@ -382,15 +412,24 @@ fn wrong_role_operations_are_rejected() {
|
||||||
generation: 1,
|
generation: 1,
|
||||||
})
|
})
|
||||||
.unwrap_err(),
|
.unwrap_err(),
|
||||||
FlowError::RoleViolation { operation: "commit", role: Role::Consumer }
|
FlowError::RoleViolation {
|
||||||
|
operation: "commit",
|
||||||
|
role: Role::Consumer
|
||||||
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
producer.consume(8).unwrap_err(),
|
producer.consume(8).unwrap_err(),
|
||||||
FlowError::RoleViolation { operation: "consume", role: Role::Producer }
|
FlowError::RoleViolation {
|
||||||
|
operation: "consume",
|
||||||
|
role: Role::Producer
|
||||||
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
producer.readable().unwrap_err(),
|
producer.readable().unwrap_err(),
|
||||||
FlowError::RoleViolation { operation: "readable", role: Role::Producer }
|
FlowError::RoleViolation {
|
||||||
|
operation: "readable",
|
||||||
|
role: Role::Producer
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,18 +438,24 @@ fn wrong_role_operations_are_rejected() {
|
||||||
#[test]
|
#[test]
|
||||||
fn operations_revalidate_cursors_and_never_panic() {
|
fn operations_revalidate_cursors_and_never_panic() {
|
||||||
let (arena, handle) = installed(512, 1);
|
let (arena, handle) = installed(512, 1);
|
||||||
let mut producer = attach(&arena, handle, Role::Producer).expect("producer");
|
let producer = attach(&arena, handle, Role::Producer).expect("producer");
|
||||||
let mut consumer = attach(&arena, handle, Role::Consumer).expect("consumer");
|
let consumer = attach(&arena, handle, Role::Consumer).expect("consumer");
|
||||||
|
|
||||||
// Corrupt the cursors mid-protocol (consume beyond commit).
|
// Corrupt the cursors mid-protocol (consume beyond commit).
|
||||||
scribble_u64(&arena, &handle, byte_ring::OFF_CONSUME, 5);
|
scribble_u64(&arena, &handle, byte_ring::OFF_CONSUME, 5);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
producer.reserve(8).unwrap_err(),
|
producer.reserve(8).unwrap_err(),
|
||||||
FlowError::Corrupt(HeaderError::CommitBelowConsume { commit: 0, consume: 5 })
|
FlowError::Corrupt(HeaderError::CommitBelowConsume {
|
||||||
|
commit: 0,
|
||||||
|
consume: 5
|
||||||
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
consumer.readable().unwrap_err(),
|
consumer.readable().unwrap_err(),
|
||||||
FlowError::Corrupt(HeaderError::CommitBelowConsume { commit: 0, consume: 5 })
|
FlowError::Corrupt(HeaderError::CommitBelowConsume {
|
||||||
|
commit: 0,
|
||||||
|
consume: 5
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// An impossible readable span is rejected at attach, not crashed on.
|
// An impossible readable span is rejected at attach, not crashed on.
|
||||||
|
|
|
||||||
|
|
@ -187,10 +187,10 @@ fn arena_runtime(
|
||||||
(runtime, transport, boot_arena(), MockWorker::default())
|
(runtime, transport, boot_arena(), MockWorker::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_observation<'a>(
|
fn find_observation(
|
||||||
observations: &'a [Observation],
|
observations: &[Observation],
|
||||||
predicate: impl Fn(&Observation) -> bool,
|
predicate: impl Fn(&Observation) -> bool,
|
||||||
) -> Option<&'a Observation> {
|
) -> Option<&Observation> {
|
||||||
observations.iter().find(|obs| predicate(obs))
|
observations.iter().find(|obs| predicate(obs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,13 +267,13 @@ fn outbound_edge_opens_writer_and_allocates_object_ids() {
|
||||||
.expect("poll");
|
.expect("poll");
|
||||||
let observations = runtime.take_observations();
|
let observations = runtime.take_observations();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(
|
||||||
find_observation(&observations, |obs| matches!(
|
find_observation(&observations, |obs| matches!(
|
||||||
obs,
|
obs,
|
||||||
Observation::EdgeReady { .. }
|
Observation::EdgeReady { .. }
|
||||||
)),
|
))
|
||||||
Some(_)
|
.is_some()
|
||||||
));
|
);
|
||||||
assert_eq!(transport.opened, vec![EdgeId(7002)]);
|
assert_eq!(transport.opened, vec![EdgeId(7002)]);
|
||||||
assert_eq!(runtime.outbound_ring_id().map(|ring| ring.0), Some(1));
|
assert_eq!(runtime.outbound_ring_id().map(|ring| ring.0), Some(1));
|
||||||
assert!(runtime.outbound_writer().is_some());
|
assert!(runtime.outbound_writer().is_some());
|
||||||
|
|
@ -296,13 +296,13 @@ fn early_stream_waits_for_recv_establishment() {
|
||||||
.poll(&mut transport, &mut arena, &mut worker)
|
.poll(&mut transport, &mut arena, &mut worker)
|
||||||
.expect("poll");
|
.expect("poll");
|
||||||
let observations = runtime.take_observations();
|
let observations = runtime.take_observations();
|
||||||
assert!(matches!(
|
assert!(
|
||||||
find_observation(&observations, |obs| matches!(
|
find_observation(&observations, |obs| matches!(
|
||||||
obs,
|
obs,
|
||||||
Observation::EdgeReady { .. }
|
Observation::EdgeReady { .. }
|
||||||
)),
|
))
|
||||||
Some(_)
|
.is_some()
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A malformed ingress record must fault: ObjectFailed observation with no
|
// A malformed ingress record must fault: ObjectFailed observation with no
|
||||||
|
|
@ -321,7 +321,7 @@ fn malformed_ingress_record_is_fatal_and_reported() {
|
||||||
let result = runtime.poll(&mut transport, &mut arena, &mut worker);
|
let result = runtime.poll(&mut transport, &mut arena, &mut worker);
|
||||||
assert!(result.is_err(), "malformed record must be fatal");
|
assert!(result.is_err(), "malformed record must be fatal");
|
||||||
let observations = runtime.take_observations();
|
let observations = runtime.take_observations();
|
||||||
assert!(matches!(
|
assert!(
|
||||||
find_observation(&observations, |obs| {
|
find_observation(&observations, |obs| {
|
||||||
matches!(
|
matches!(
|
||||||
obs,
|
obs,
|
||||||
|
|
@ -330,9 +330,9 @@ fn malformed_ingress_record_is_fatal_and_reported() {
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}),
|
})
|
||||||
Some(_)
|
.is_some()
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A worker load failure must surface ObjectFailed with the object id and
|
// A worker load failure must surface ObjectFailed with the object id and
|
||||||
|
|
@ -351,7 +351,7 @@ fn worker_load_failure_reports_object_and_is_fatal() {
|
||||||
let result = runtime.poll(&mut transport, &mut arena, &mut worker);
|
let result = runtime.poll(&mut transport, &mut arena, &mut worker);
|
||||||
assert!(result.is_err(), "load failure must be fatal");
|
assert!(result.is_err(), "load failure must be fatal");
|
||||||
let observations = runtime.take_observations();
|
let observations = runtime.take_observations();
|
||||||
assert!(matches!(
|
assert!(
|
||||||
find_observation(&observations, |obs| {
|
find_observation(&observations, |obs| {
|
||||||
matches!(
|
matches!(
|
||||||
obs,
|
obs,
|
||||||
|
|
@ -360,7 +360,7 @@ fn worker_load_failure_reports_object_and_is_fatal() {
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}),
|
})
|
||||||
Some(_)
|
.is_some()
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
199
crates/data-plane/tests/file_source_guarantees.rs
Normal file
199
crates/data-plane/tests/file_source_guarantees.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use data_plane::blob_transfer::{
|
||||||
|
BlobTransferEvent, BlobTransferId, BlobTransferOffer, BlobTransferSender, FileTransferRequest,
|
||||||
|
};
|
||||||
|
use data_plane::namespace::NamespaceError;
|
||||||
|
use data_plane::source::{BlobSourceIn, FileBlobSourceActor};
|
||||||
|
use futures_lite::future;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::{Runtime, RuntimeParts};
|
||||||
|
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
||||||
|
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct TestFile {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestFile {
|
||||||
|
fn new(bytes: &[u8]) -> Self {
|
||||||
|
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"swactor-file-source-{}-{sequence}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::write(&path, bytes).expect("write source fixture");
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime() -> (Engine, Runtime) {
|
||||||
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
|
worker_count: 1,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
|
let runtime = parts.runtime().clone();
|
||||||
|
let engine = Engine::new(
|
||||||
|
parts,
|
||||||
|
TokioBackend::new(TokioConfig::default()).expect("tokio backend"),
|
||||||
|
)
|
||||||
|
.expect("engine");
|
||||||
|
(engine, runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoopbackSender {
|
||||||
|
runtime: Runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobTransferSender for LoopbackSender {
|
||||||
|
fn start_file(&self, mut request: FileTransferRequest) -> Result<(), String> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
request
|
||||||
|
.file
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if bytes.len() as u64 != request.length {
|
||||||
|
return Err("source length changed".to_owned());
|
||||||
|
}
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
BlobTransferEvent::Chunk {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
BlobTransferEvent::Finished {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
request.completion.complete(Ok(()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_source_owns_fixed_length_and_transfers_opened_file() {
|
||||||
|
let fixture = TestFile::new(b"fixed-source");
|
||||||
|
let (_engine, runtime) = runtime();
|
||||||
|
let sender = Arc::new(LoopbackSender {
|
||||||
|
runtime: runtime.clone(),
|
||||||
|
});
|
||||||
|
let source = FileBlobSourceActor::open(runtime.clone(), sender, &fixture.path)
|
||||||
|
.expect("open file source");
|
||||||
|
assert_eq!(source.length(), 12);
|
||||||
|
let destination = runtime
|
||||||
|
.new_inbox::<BlobTransferEvent>()
|
||||||
|
.expect("destination inbox");
|
||||||
|
let source = runtime.spawn(source).expect("spawn file source");
|
||||||
|
let transfer_id = BlobTransferId(41);
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.send_to(
|
||||||
|
source,
|
||||||
|
BlobSourceIn::BeginTransfer {
|
||||||
|
offer: BlobTransferOffer {
|
||||||
|
transfer_id,
|
||||||
|
destination: *destination.addr(),
|
||||||
|
failure_proxy: None,
|
||||||
|
transport: Vec::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("begin source transfer");
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
assert_eq!(
|
||||||
|
destination.recv().await,
|
||||||
|
BlobTransferEvent::Chunk {
|
||||||
|
transfer_id,
|
||||||
|
bytes: b"fixed-source".to_vec(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
destination.recv().await,
|
||||||
|
BlobTransferEvent::Finished { transfer_id }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeldSender {
|
||||||
|
request: Mutex<Option<FileTransferRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobTransferSender for HeldSender {
|
||||||
|
fn start_file(&self, request: FileTransferRequest) -> Result<(), String> {
|
||||||
|
*self.request.lock() = Some(request);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retirement_does_not_cancel_an_accepted_transfer() {
|
||||||
|
let fixture = TestFile::new(b"retained");
|
||||||
|
let (_engine, runtime) = runtime();
|
||||||
|
let sender = Arc::new(HeldSender {
|
||||||
|
request: Mutex::new(None),
|
||||||
|
});
|
||||||
|
let source_actor = FileBlobSourceActor::open(runtime.clone(), sender.clone(), &fixture.path)
|
||||||
|
.expect("open file source");
|
||||||
|
let destination = runtime
|
||||||
|
.new_inbox::<BlobTransferEvent>()
|
||||||
|
.expect("destination inbox");
|
||||||
|
let source = runtime.spawn(source_actor).expect("spawn source");
|
||||||
|
let transfer_id = BlobTransferId(9);
|
||||||
|
runtime
|
||||||
|
.send_to(
|
||||||
|
source,
|
||||||
|
BlobSourceIn::BeginTransfer {
|
||||||
|
offer: BlobTransferOffer {
|
||||||
|
transfer_id,
|
||||||
|
destination: *destination.addr(),
|
||||||
|
failure_proxy: None,
|
||||||
|
transport: Vec::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
runtime.send_to(source, BlobSourceIn::Retire).unwrap();
|
||||||
|
|
||||||
|
let mut request = loop {
|
||||||
|
if let Some(request) = sender.request.lock().take() {
|
||||||
|
break request;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
};
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
request.file.read_to_end(&mut bytes).unwrap();
|
||||||
|
assert_eq!(bytes, b"retained");
|
||||||
|
request.completion.complete(Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_rejects_a_changed_file_length() {
|
||||||
|
let fixture = TestFile::new(b"changed");
|
||||||
|
let (_engine, runtime) = runtime();
|
||||||
|
let sender = Arc::new(LoopbackSender {
|
||||||
|
runtime: runtime.clone(),
|
||||||
|
});
|
||||||
|
let recovered = FileBlobSourceActor::recover(runtime, sender, &fixture.path, 99);
|
||||||
|
assert!(
|
||||||
|
matches!(recovered, Err(NamespaceError::SourceRecovery(reason)) if reason.contains("length"))
|
||||||
|
);
|
||||||
|
}
|
||||||
442
crates/data-plane/tests/namespace_guarantees.rs
Normal file
442
crates/data-plane/tests/namespace_guarantees.rs
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use data_plane::namespace::{
|
||||||
|
DataDirectoryActor, DirectoryClient, NamespaceClient, NamespaceClientActor, NamespaceClientIn,
|
||||||
|
NamespaceDiscovery, NamespaceError, OperationId, SourceRecovery,
|
||||||
|
};
|
||||||
|
use data_plane::path::DataPath;
|
||||||
|
use futures_lite::future;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::{Runtime, RuntimeParts};
|
||||||
|
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
||||||
|
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct TempState {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempState {
|
||||||
|
fn new(label: &str) -> Self {
|
||||||
|
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"swactor-namespace-{label}-{}-{sequence}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&root).expect("create temporary namespace directory");
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(&self) -> PathBuf {
|
||||||
|
self.root.join("namespace.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempState {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DirectoryHarness {
|
||||||
|
engine: Engine,
|
||||||
|
runtime: Runtime,
|
||||||
|
client: DirectoryClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(value: &str) -> DataPath {
|
||||||
|
DataPath::parse(value).expect("test path")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(byte: u8) -> ActorAddress {
|
||||||
|
ActorAddress([byte; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recovery(actor: ActorAddress) -> SourceRecovery {
|
||||||
|
SourceRecovery::Actor { actor }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_directory(store: &Path) -> DirectoryHarness {
|
||||||
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
|
worker_count: 1,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
|
let runtime = parts.runtime().clone();
|
||||||
|
let actor = DataDirectoryActor::recover(store, |record, _length| match record {
|
||||||
|
SourceRecovery::Actor { actor } => Ok(*actor),
|
||||||
|
SourceRecovery::File { path } => Err(NamespaceError::SourceRecovery(format!(
|
||||||
|
"test cannot recover file source {}",
|
||||||
|
path.display()
|
||||||
|
))),
|
||||||
|
})
|
||||||
|
.expect("recover directory");
|
||||||
|
let directory = runtime.spawn(actor).expect("spawn directory actor");
|
||||||
|
let engine = Engine::new(
|
||||||
|
parts,
|
||||||
|
TokioBackend::new(TokioConfig::default()).expect("tokio backend"),
|
||||||
|
)
|
||||||
|
.expect("directory engine");
|
||||||
|
DirectoryHarness {
|
||||||
|
engine,
|
||||||
|
runtime: runtime.clone(),
|
||||||
|
client: DirectoryClient::new(runtime, directory),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_mutations_are_linearizable_and_durable() {
|
||||||
|
let state = TempState::new("linearizable");
|
||||||
|
let directory = spawn_directory(&state.store());
|
||||||
|
let logical = path("/models/tiny-linear/weights");
|
||||||
|
let first = source(1);
|
||||||
|
let second = source(2);
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let registered = directory
|
||||||
|
.client
|
||||||
|
.register(
|
||||||
|
logical.clone(),
|
||||||
|
first,
|
||||||
|
24,
|
||||||
|
recovery(first),
|
||||||
|
OperationId::from_u128(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("register first source");
|
||||||
|
assert_eq!(registered.revision, 1);
|
||||||
|
|
||||||
|
let selected_first = directory
|
||||||
|
.client
|
||||||
|
.resolve(logical.clone())
|
||||||
|
.await
|
||||||
|
.expect("resolve first source");
|
||||||
|
assert_eq!(selected_first.source, first);
|
||||||
|
assert_eq!(selected_first.length, 24);
|
||||||
|
assert_eq!(selected_first.revision, 1);
|
||||||
|
|
||||||
|
let replaced = directory
|
||||||
|
.client
|
||||||
|
.register(
|
||||||
|
logical.clone(),
|
||||||
|
second,
|
||||||
|
32,
|
||||||
|
recovery(second),
|
||||||
|
OperationId::from_u128(2),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("replace source");
|
||||||
|
assert_eq!(replaced.revision, 2);
|
||||||
|
|
||||||
|
let selected_second = directory
|
||||||
|
.client
|
||||||
|
.resolve(logical.clone())
|
||||||
|
.await
|
||||||
|
.expect("resolve replacement");
|
||||||
|
assert_eq!(selected_second.source, second);
|
||||||
|
assert_eq!(selected_second.length, 32);
|
||||||
|
assert_eq!(selected_second.revision, 2);
|
||||||
|
|
||||||
|
// A completed resolve is a binding snapshot. Later replacement cannot
|
||||||
|
// mutate the source selected by the earlier logical read.
|
||||||
|
assert_eq!(selected_first.source, first);
|
||||||
|
assert_eq!(selected_first.revision, 1);
|
||||||
|
|
||||||
|
let removed = directory
|
||||||
|
.client
|
||||||
|
.unregister(logical.clone(), OperationId::from_u128(3))
|
||||||
|
.await
|
||||||
|
.expect("unregister source");
|
||||||
|
assert_eq!(removed.revision, 3);
|
||||||
|
assert!(matches!(
|
||||||
|
directory.client.resolve(logical.clone()).await,
|
||||||
|
Err(NamespaceError::PathNotFound(found)) if found == logical
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The acknowledged removal is recovered from a fresh actor instance.
|
||||||
|
let recovered = spawn_directory(&state.store());
|
||||||
|
assert!(matches!(
|
||||||
|
future::block_on(recovered.client.resolve(logical.clone())),
|
||||||
|
Err(NamespaceError::PathNotFound(found)) if found == logical
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_mutation_retry_has_at_most_once_effect() {
|
||||||
|
let state = TempState::new("idempotent");
|
||||||
|
let logical = path("/models/a");
|
||||||
|
let actor = source(7);
|
||||||
|
let operation = OperationId::from_u128(99);
|
||||||
|
|
||||||
|
let first_runtime = spawn_directory(&state.store());
|
||||||
|
let first_receipt = future::block_on(first_runtime.client.register(
|
||||||
|
logical.clone(),
|
||||||
|
actor,
|
||||||
|
8,
|
||||||
|
recovery(actor),
|
||||||
|
operation,
|
||||||
|
))
|
||||||
|
.expect("initial registration");
|
||||||
|
assert_eq!(first_receipt.revision, 1);
|
||||||
|
drop(first_runtime);
|
||||||
|
|
||||||
|
let recovered = spawn_directory(&state.store());
|
||||||
|
let retried = future::block_on(recovered.client.register(
|
||||||
|
logical.clone(),
|
||||||
|
actor,
|
||||||
|
8,
|
||||||
|
recovery(actor),
|
||||||
|
operation,
|
||||||
|
))
|
||||||
|
.expect("retry committed registration");
|
||||||
|
assert_eq!(retried, first_receipt);
|
||||||
|
assert_eq!(
|
||||||
|
future::block_on(recovered.client.resolve(logical.clone()))
|
||||||
|
.expect("resolve recovered binding")
|
||||||
|
.revision,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
let conflict = future::block_on(recovered.client.register(
|
||||||
|
logical,
|
||||||
|
source(8),
|
||||||
|
9,
|
||||||
|
recovery(source(8)),
|
||||||
|
operation,
|
||||||
|
));
|
||||||
|
assert!(matches!(conflict, Err(NamespaceError::OperationConflict(id)) if id == operation));
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StaticDiscovery {
|
||||||
|
directory: Arc<RwLock<Option<ActorAddress>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamespaceDiscovery for StaticDiscovery {
|
||||||
|
fn current_directory(&self) -> Option<ActorAddress> {
|
||||||
|
*self.directory.read()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unresolved_request_waits_for_recovered_authority() {
|
||||||
|
let state = TempState::new("restart");
|
||||||
|
let harness = spawn_directory(&state.store());
|
||||||
|
let logical = path("/models/restartable");
|
||||||
|
let source = source(44);
|
||||||
|
future::block_on(harness.client.register(
|
||||||
|
logical.clone(),
|
||||||
|
source,
|
||||||
|
16,
|
||||||
|
recovery(source),
|
||||||
|
OperationId::from_u128(500),
|
||||||
|
))
|
||||||
|
.expect("seed durable binding");
|
||||||
|
let old_directory = harness.client.directory();
|
||||||
|
harness.runtime.stop_actor(old_directory).unwrap();
|
||||||
|
|
||||||
|
let discovered = Arc::new(RwLock::new(None));
|
||||||
|
let proxy = harness
|
||||||
|
.runtime
|
||||||
|
.spawn(NamespaceClientActor::new(
|
||||||
|
harness.engine.handle(),
|
||||||
|
harness.runtime.create_sender(),
|
||||||
|
Arc::new(StaticDiscovery {
|
||||||
|
directory: Arc::clone(&discovered),
|
||||||
|
}),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
))
|
||||||
|
.expect("spawn namespace client");
|
||||||
|
let client = NamespaceClient::new(harness.runtime.clone(), proxy);
|
||||||
|
|
||||||
|
future::block_on(async {
|
||||||
|
let mut resolving = Box::pin(client.resolve(logical.clone()));
|
||||||
|
assert!(
|
||||||
|
future::poll_once(resolving.as_mut()).await.is_none(),
|
||||||
|
"resolve must remain pending while authority is absent"
|
||||||
|
);
|
||||||
|
|
||||||
|
let recovered =
|
||||||
|
DataDirectoryActor::recover(state.store(), |record, _length| match record {
|
||||||
|
SourceRecovery::Actor { actor } => Ok(*actor),
|
||||||
|
SourceRecovery::File { path } => Err(NamespaceError::SourceRecovery(format!(
|
||||||
|
"test cannot recover file source {}",
|
||||||
|
path.display()
|
||||||
|
))),
|
||||||
|
})
|
||||||
|
.expect("recover directory state");
|
||||||
|
let recovered = harness
|
||||||
|
.runtime
|
||||||
|
.spawn(recovered)
|
||||||
|
.expect("spawn recovered directory");
|
||||||
|
*discovered.write() = Some(recovered);
|
||||||
|
harness
|
||||||
|
.runtime
|
||||||
|
.send_to(proxy, NamespaceClientIn::Retry)
|
||||||
|
.expect("trigger rediscovery retry");
|
||||||
|
|
||||||
|
let binding = resolving.await.expect("resolve after authority recovery");
|
||||||
|
assert_eq!(binding.source, source);
|
||||||
|
assert_eq!(binding.length, 16);
|
||||||
|
assert_eq!(binding.revision, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_process_restart_helper() {
|
||||||
|
let Ok(mode) = std::env::var("SWACTOR_NAMESPACE_HELPER_MODE") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let store = PathBuf::from(
|
||||||
|
std::env::var_os("SWACTOR_NAMESPACE_HELPER_STORE").expect("helper store path"),
|
||||||
|
);
|
||||||
|
let directory = spawn_directory(&store);
|
||||||
|
let logical = path("/models/process-restart");
|
||||||
|
match mode.as_str() {
|
||||||
|
"write" => {
|
||||||
|
let actor = source(55);
|
||||||
|
let receipt = future::block_on(directory.client.register(
|
||||||
|
logical,
|
||||||
|
actor,
|
||||||
|
32,
|
||||||
|
recovery(actor),
|
||||||
|
OperationId::from_u128(900),
|
||||||
|
))
|
||||||
|
.expect("helper durable registration");
|
||||||
|
assert_eq!(receipt.revision, 1);
|
||||||
|
}
|
||||||
|
"read" => {
|
||||||
|
let binding =
|
||||||
|
future::block_on(directory.client.resolve(logical)).expect("helper recovery");
|
||||||
|
assert_eq!(binding.source, source(55));
|
||||||
|
assert_eq!(binding.length, 32);
|
||||||
|
assert_eq!(binding.revision, 1);
|
||||||
|
}
|
||||||
|
other => panic!("unknown namespace helper mode {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_client_routes_remote_transfer_failures_to_local_binding() {
|
||||||
|
let state = TempState::new("transfer-failure");
|
||||||
|
let harness = spawn_directory(&state.store());
|
||||||
|
let proxy = harness
|
||||||
|
.runtime
|
||||||
|
.spawn(NamespaceClientActor::new(
|
||||||
|
harness.engine.handle(),
|
||||||
|
harness.runtime.create_sender(),
|
||||||
|
Arc::new(StaticDiscovery {
|
||||||
|
directory: Arc::new(RwLock::new(Some(harness.client.directory()))),
|
||||||
|
}),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let destination = harness
|
||||||
|
.runtime
|
||||||
|
.new_inbox::<data_plane::blob_transfer::BlobTransferEvent>()
|
||||||
|
.unwrap();
|
||||||
|
let transfer_id = data_plane::blob_transfer::BlobTransferId(71);
|
||||||
|
harness
|
||||||
|
.runtime
|
||||||
|
.send_to(
|
||||||
|
proxy,
|
||||||
|
NamespaceClientIn::TransferFailed {
|
||||||
|
destination: *destination.addr(),
|
||||||
|
transfer_id,
|
||||||
|
reason: "injected source failure".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
future::block_on(destination.recv()),
|
||||||
|
data_plane::blob_transfer::BlobTransferEvent::Failed {
|
||||||
|
transfer_id,
|
||||||
|
reason: "injected source failure".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ModelBinding {
|
||||||
|
source: ActorAddress,
|
||||||
|
length: u64,
|
||||||
|
revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig {
|
||||||
|
cases: 16,
|
||||||
|
max_shrink_iters: 128,
|
||||||
|
..ProptestConfig::default()
|
||||||
|
})]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legal_action_sequences_preserve_namespace_guarantees(actions in prop::collection::vec(any::<u8>(), 1..48)) {
|
||||||
|
let state = TempState::new("stateful");
|
||||||
|
let directory = spawn_directory(&state.store());
|
||||||
|
let paths = [path("/models/a"), path("/models/b"), path("/runs/7/result")];
|
||||||
|
let mut model = BTreeMap::<DataPath, ModelBinding>::new();
|
||||||
|
let mut next_revision = 1_u64;
|
||||||
|
let mut next_operation = 1_u128;
|
||||||
|
|
||||||
|
for (step, action) in actions.into_iter().enumerate() {
|
||||||
|
let logical = paths[usize::from(action) % paths.len()].clone();
|
||||||
|
match action % 4 {
|
||||||
|
0 | 1 => {
|
||||||
|
let actor = source(action.wrapping_add(step as u8).wrapping_add(1));
|
||||||
|
let length = u64::from(action) + 1;
|
||||||
|
let operation = OperationId::from_u128(next_operation);
|
||||||
|
next_operation += 1;
|
||||||
|
let receipt = future::block_on(directory.client.register(
|
||||||
|
logical.clone(),
|
||||||
|
actor,
|
||||||
|
length,
|
||||||
|
recovery(actor),
|
||||||
|
operation,
|
||||||
|
)).expect("model registration");
|
||||||
|
prop_assert_eq!(receipt.revision, next_revision);
|
||||||
|
model.insert(logical, ModelBinding { source: actor, length, revision: next_revision });
|
||||||
|
next_revision += 1;
|
||||||
|
}
|
||||||
|
2 if model.contains_key(&logical) => {
|
||||||
|
let operation = OperationId::from_u128(next_operation);
|
||||||
|
next_operation += 1;
|
||||||
|
let receipt = future::block_on(directory.client.unregister(logical.clone(), operation))
|
||||||
|
.expect("model unregistration");
|
||||||
|
prop_assert_eq!(receipt.revision, next_revision);
|
||||||
|
model.remove(&logical);
|
||||||
|
next_revision += 1;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let observed = future::block_on(directory.client.resolve(logical.clone()));
|
||||||
|
match model.get(&logical) {
|
||||||
|
Some(expected) => {
|
||||||
|
let observed = observed.expect("model binding must resolve");
|
||||||
|
prop_assert_eq!(observed.source, expected.source);
|
||||||
|
prop_assert_eq!(observed.length, expected.length);
|
||||||
|
prop_assert_eq!(observed.revision, expected.revision);
|
||||||
|
}
|
||||||
|
None => prop_assert!(matches!(
|
||||||
|
observed,
|
||||||
|
Err(NamespaceError::PathNotFound(found)) if found == logical
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, expected) in &model {
|
||||||
|
let observed = future::block_on(directory.client.resolve(path.clone()))
|
||||||
|
.expect("all model bindings remain resolvable");
|
||||||
|
prop_assert_eq!(observed.source, expected.source);
|
||||||
|
prop_assert_eq!(observed.length, expected.length);
|
||||||
|
prop_assert_eq!(observed.revision, expected.revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
522
crates/data-plane/tests/namespace_host_read_guarantees.rs
Normal file
522
crates/data-plane/tests/namespace_host_read_guarantees.rs
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use data_plane::arena::{ArenaConfig, ArenaManager, NodeId};
|
||||||
|
use data_plane::blob;
|
||||||
|
use data_plane::blob_transfer::{
|
||||||
|
BlobTransferEvent, BlobTransferId, BlobTransferOffer, BlobTransferReceiver, BlobTransferSender,
|
||||||
|
FileTransferRequest,
|
||||||
|
};
|
||||||
|
use data_plane::bootstrap::{self, BootstrapSpec};
|
||||||
|
use data_plane::control::DataNamespaceService;
|
||||||
|
use data_plane::data_plane::DataPlaneBootstrap;
|
||||||
|
use data_plane::host::{HostDataPlaneConfig, HostDataPlaneSessionActor};
|
||||||
|
use data_plane::namespace::{
|
||||||
|
DirectoryClient, NamespaceClient, NamespaceClientActor, NamespaceDiscovery, NamespaceError,
|
||||||
|
OperationId,
|
||||||
|
};
|
||||||
|
use data_plane::path::{DataPath, JobContext};
|
||||||
|
use data_plane::protocol::JobCapability;
|
||||||
|
use data_plane::source::{BlobSourcePublisher, FileBlobSourceActor};
|
||||||
|
use futures_lite::future;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use swactor::Error;
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::{RemoteSink, Runtime, RuntimeParts};
|
||||||
|
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
||||||
|
|
||||||
|
const CAPABILITY: JobCapability = JobCapability::new([0x33; 32]);
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct DirectRuntimeSink {
|
||||||
|
destination: Runtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteSink for DirectRuntimeSink {
|
||||||
|
fn send(
|
||||||
|
&self,
|
||||||
|
address: ActorAddress,
|
||||||
|
message: Box<dyn std::any::Any + Send>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.destination.deliver_raw(address, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StaticDiscovery(Arc<RwLock<Option<ActorAddress>>>);
|
||||||
|
|
||||||
|
impl NamespaceDiscovery for StaticDiscovery {
|
||||||
|
fn current_directory(&self) -> Option<ActorAddress> {
|
||||||
|
*self.0.read()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoopbackSender {
|
||||||
|
runtime: Runtime,
|
||||||
|
behavior: Arc<AtomicU8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobTransferSender for LoopbackSender {
|
||||||
|
fn start_file(&self, request: FileTransferRequest) -> Result<(), String> {
|
||||||
|
let mut bytes = vec![0_u8; request.length as usize];
|
||||||
|
request
|
||||||
|
.file
|
||||||
|
.read_exact_at(&mut bytes, request.offset)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
match self.behavior.load(Ordering::Acquire) {
|
||||||
|
1 => {
|
||||||
|
bytes.pop();
|
||||||
|
}
|
||||||
|
2 => bytes.push(0xff),
|
||||||
|
3 => return Err("injected source start failure".to_owned()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
BlobTransferEvent::Chunk {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
self.runtime
|
||||||
|
.send_to(
|
||||||
|
request.offer.destination,
|
||||||
|
BlobTransferEvent::Finished {
|
||||||
|
transfer_id: request.offer.transfer_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
request.completion.complete(Ok(()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DirectReceiver;
|
||||||
|
|
||||||
|
impl BlobTransferReceiver for DirectReceiver {
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
destination: ActorAddress,
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
) -> Result<BlobTransferOffer, String> {
|
||||||
|
Ok(BlobTransferOffer {
|
||||||
|
transfer_id,
|
||||||
|
destination,
|
||||||
|
failure_proxy: None,
|
||||||
|
transport: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&self, _offer: &BlobTransferOffer) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoopSourceRegistrar;
|
||||||
|
|
||||||
|
impl BlobSourcePublisher for NoopSourceRegistrar {
|
||||||
|
fn publish_source(&self, _source: ActorAddress) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TempState {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempState {
|
||||||
|
fn new() -> Self {
|
||||||
|
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"swactor-host-namespace-{}-{sequence}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempState {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_parts() -> (RuntimeParts, Runtime) {
|
||||||
|
let parts = RuntimeParts::new(RuntimeConfig {
|
||||||
|
worker_count: 1,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
});
|
||||||
|
let runtime = parts.runtime().clone();
|
||||||
|
(parts, runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_read_resolves_file_source_and_seals_final_arena_lease() {
|
||||||
|
let state = TempState::new();
|
||||||
|
let weights = b"namespace-backed-weights";
|
||||||
|
let source_path = state.root.join("weights.bin");
|
||||||
|
std::fs::write(&source_path, weights).unwrap();
|
||||||
|
let store_path = state.root.join("namespace.json");
|
||||||
|
|
||||||
|
let mut arena = ArenaManager::boot(ArenaConfig {
|
||||||
|
node_id: NodeId(1),
|
||||||
|
reservation_ceiling: 4096,
|
||||||
|
base_alignment: 64,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let handoff = bootstrap::write_bootstrap(
|
||||||
|
&mut arena,
|
||||||
|
BootstrapSpec {
|
||||||
|
arena_generation: 1,
|
||||||
|
alignment: 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut consumer_arena = ArenaManager::boot(ArenaConfig {
|
||||||
|
node_id: NodeId(2),
|
||||||
|
reservation_ceiling: 4096,
|
||||||
|
base_alignment: 64,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let consumer_handoff = bootstrap::write_bootstrap(
|
||||||
|
&mut consumer_arena,
|
||||||
|
BootstrapSpec {
|
||||||
|
arena_generation: 2,
|
||||||
|
alignment: 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (host_parts, host_runtime) = runtime_parts();
|
||||||
|
let (child_parts, child_runtime) = runtime_parts();
|
||||||
|
host_runtime.set_remote_sink(Arc::new(DirectRuntimeSink {
|
||||||
|
destination: child_runtime.clone(),
|
||||||
|
}));
|
||||||
|
child_runtime.set_remote_sink(Arc::new(DirectRuntimeSink {
|
||||||
|
destination: host_runtime.clone(),
|
||||||
|
}));
|
||||||
|
let host_engine = Engine::new(
|
||||||
|
host_parts,
|
||||||
|
TokioBackend::new(TokioConfig::default()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let child_engine = Engine::new(
|
||||||
|
child_parts,
|
||||||
|
TokioBackend::new(TokioConfig::default()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let transfer_behavior = Arc::new(AtomicU8::new(0));
|
||||||
|
let sender: Arc<dyn BlobTransferSender> = Arc::new(LoopbackSender {
|
||||||
|
runtime: host_runtime.clone(),
|
||||||
|
behavior: Arc::clone(&transfer_behavior),
|
||||||
|
});
|
||||||
|
let source_publisher: Arc<dyn BlobSourcePublisher> = Arc::new(NoopSourceRegistrar);
|
||||||
|
let service = DataNamespaceService::recover(
|
||||||
|
host_runtime.clone(),
|
||||||
|
&store_path,
|
||||||
|
Arc::clone(&sender),
|
||||||
|
Arc::clone(&source_publisher),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let directory = service.directory();
|
||||||
|
let direct = DirectoryClient::new(host_runtime.clone(), directory);
|
||||||
|
let logical = DataPath::parse("/models/weights").unwrap();
|
||||||
|
future::block_on(
|
||||||
|
service
|
||||||
|
.control()
|
||||||
|
.register(logical.clone(), blob::file(&source_path)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let discovered = Arc::new(RwLock::new(Some(directory)));
|
||||||
|
let proxy = host_runtime
|
||||||
|
.spawn(NamespaceClientActor::new(
|
||||||
|
host_engine.handle(),
|
||||||
|
host_runtime.create_sender(),
|
||||||
|
Arc::new(StaticDiscovery(Arc::clone(&discovered))),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let namespace = NamespaceClient::new(host_runtime.clone(), proxy);
|
||||||
|
let host_session = host_runtime
|
||||||
|
.spawn(
|
||||||
|
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
||||||
|
runtime: host_runtime.clone(),
|
||||||
|
arena,
|
||||||
|
arena_generation: 1,
|
||||||
|
session_generation: 1,
|
||||||
|
capability: CAPABILITY,
|
||||||
|
job_context: JobContext {
|
||||||
|
run_id: "run-1".to_owned(),
|
||||||
|
read_prefixes: vec![
|
||||||
|
DataPath::parse("/models").unwrap(),
|
||||||
|
DataPath::parse("/runs/run-1").unwrap(),
|
||||||
|
],
|
||||||
|
write_prefixes: vec![DataPath::parse("/runs/run-1").unwrap()],
|
||||||
|
},
|
||||||
|
namespace: Some(namespace.clone()),
|
||||||
|
transfer_receiver: Some(Arc::new(DirectReceiver)),
|
||||||
|
source_sender: Some(Arc::clone(&sender)),
|
||||||
|
source_publisher: Some(Arc::clone(&source_publisher)),
|
||||||
|
route_registrar: None,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bootstrap = future::block_on(DataPlaneBootstrap::attach(
|
||||||
|
handoff.arena_fd,
|
||||||
|
child_runtime.clone(),
|
||||||
|
host_session,
|
||||||
|
CAPABILITY,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let consumer_host_session = host_runtime
|
||||||
|
.spawn(
|
||||||
|
HostDataPlaneSessionActor::new(HostDataPlaneConfig {
|
||||||
|
runtime: host_runtime.clone(),
|
||||||
|
arena: consumer_arena,
|
||||||
|
arena_generation: 2,
|
||||||
|
session_generation: 2,
|
||||||
|
capability: CAPABILITY,
|
||||||
|
job_context: JobContext {
|
||||||
|
run_id: "run-1".to_owned(),
|
||||||
|
read_prefixes: vec![
|
||||||
|
DataPath::parse("/models").unwrap(),
|
||||||
|
DataPath::parse("/runs/run-1").unwrap(),
|
||||||
|
],
|
||||||
|
write_prefixes: vec![],
|
||||||
|
},
|
||||||
|
namespace: Some(namespace),
|
||||||
|
transfer_receiver: Some(Arc::new(DirectReceiver)),
|
||||||
|
source_sender: Some(Arc::clone(&sender)),
|
||||||
|
source_publisher: Some(source_publisher),
|
||||||
|
route_registrar: None,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let consumer = future::block_on(DataPlaneBootstrap::attach(
|
||||||
|
consumer_handoff.arena_fd,
|
||||||
|
child_runtime,
|
||||||
|
consumer_host_session,
|
||||||
|
CAPABILITY,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let blob = future::block_on(bootstrap.data_plane.read_blob(&logical)).unwrap();
|
||||||
|
|
||||||
|
for (mode, name) in [(1_u8, "short"), (2, "oversized"), (3, "start-failure")] {
|
||||||
|
let fault_path = DataPath::parse(format!("/models/{name}")).unwrap();
|
||||||
|
let source =
|
||||||
|
FileBlobSourceActor::open(host_runtime.clone(), Arc::clone(&sender), &source_path)
|
||||||
|
.unwrap();
|
||||||
|
let length = source.length();
|
||||||
|
let recovery = source.recovery();
|
||||||
|
let source = host_runtime.spawn(source).unwrap();
|
||||||
|
future::block_on(direct.register(
|
||||||
|
fault_path.clone(),
|
||||||
|
source,
|
||||||
|
length,
|
||||||
|
recovery,
|
||||||
|
OperationId::from_u128(10 + u128::from(mode)),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
transfer_behavior.store(mode, Ordering::Release);
|
||||||
|
let result = future::block_on(bootstrap.data_plane.read_blob(&fault_path));
|
||||||
|
match mode {
|
||||||
|
1 | 2 => assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(data_plane::protocol::DataPlaneError::Blob(
|
||||||
|
data_plane::protocol::BlobFailure::Length { .. }
|
||||||
|
))
|
||||||
|
)),
|
||||||
|
3 => assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(data_plane::protocol::DataPlaneError::SourceFailure(_))
|
||||||
|
)),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transfer_behavior.store(0, Ordering::Release);
|
||||||
|
let recovered_after_failures =
|
||||||
|
future::block_on(bootstrap.data_plane.read_blob(&logical)).unwrap();
|
||||||
|
assert_eq!(recovered_after_failures.map().unwrap().as_ref(), weights);
|
||||||
|
drop(recovered_after_failures);
|
||||||
|
assert_eq!(blob.length(), weights.len() as u64);
|
||||||
|
|
||||||
|
let result_path = DataPath::parse("/runs/run-1/result").unwrap();
|
||||||
|
let mut writer = future::block_on(
|
||||||
|
bootstrap
|
||||||
|
.data_plane
|
||||||
|
.write_blob(&result_path, b"first!".len() as u64),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writer.map().unwrap().as_mut().copy_from_slice(b"first!");
|
||||||
|
future::block_on(writer.seal()).unwrap();
|
||||||
|
let first = future::block_on(bootstrap.data_plane.read_blob(&result_path)).unwrap();
|
||||||
|
assert_eq!(first.map().unwrap().as_ref(), b"first!");
|
||||||
|
|
||||||
|
let mut replacement = future::block_on(
|
||||||
|
bootstrap
|
||||||
|
.data_plane
|
||||||
|
.write_blob(&result_path, b"second".len() as u64),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
replacement
|
||||||
|
.map()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.copy_from_slice(b"second");
|
||||||
|
future::block_on(replacement.seal()).unwrap();
|
||||||
|
let second = future::block_on(bootstrap.data_plane.read_blob(&result_path)).unwrap();
|
||||||
|
assert_eq!(second.map().unwrap().as_ref(), b"second");
|
||||||
|
assert_eq!(first.map().unwrap().as_ref(), b"first!");
|
||||||
|
|
||||||
|
let mut aborted = future::block_on(bootstrap.data_plane.write_blob(&result_path, 5)).unwrap();
|
||||||
|
aborted.map().unwrap().as_mut().copy_from_slice(b"abort");
|
||||||
|
future::block_on(aborted.abort()).unwrap();
|
||||||
|
let after_abort = future::block_on(bootstrap.data_plane.read_blob(&result_path)).unwrap();
|
||||||
|
assert_eq!(after_abort.map().unwrap().as_ref(), b"second");
|
||||||
|
assert_eq!(blob.map().unwrap().as_ref(), weights);
|
||||||
|
|
||||||
|
let cancelled_path = DataPath::parse("/runs/run-1/cancelled").unwrap();
|
||||||
|
let mut cancelled =
|
||||||
|
future::block_on(bootstrap.data_plane.write_blob(&cancelled_path, 6)).unwrap();
|
||||||
|
cancelled.map().unwrap().as_mut().copy_from_slice(b"cancel");
|
||||||
|
*discovered.write() = None;
|
||||||
|
let mut sealing = Box::pin(cancelled.seal());
|
||||||
|
assert!(
|
||||||
|
future::block_on(future::poll_once(sealing.as_mut())).is_none(),
|
||||||
|
"seal must wait while namespace authority is absent"
|
||||||
|
);
|
||||||
|
drop(sealing);
|
||||||
|
drop(cancelled);
|
||||||
|
*discovered.write() = Some(directory);
|
||||||
|
host_runtime
|
||||||
|
.send_to(proxy, data_plane::namespace::NamespaceClientIn::Retry)
|
||||||
|
.unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
assert!(matches!(
|
||||||
|
future::block_on(direct.resolve(cancelled_path)),
|
||||||
|
Err(NamespaceError::PathNotFound(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
*discovered.write() = None;
|
||||||
|
let mut cancelled_read = Box::pin(bootstrap.data_plane.read_blob(&logical));
|
||||||
|
assert!(
|
||||||
|
future::block_on(future::poll_once(cancelled_read.as_mut())).is_none(),
|
||||||
|
"read must wait while namespace authority is absent"
|
||||||
|
);
|
||||||
|
drop(cancelled_read);
|
||||||
|
*discovered.write() = Some(directory);
|
||||||
|
host_runtime
|
||||||
|
.send_to(proxy, data_plane::namespace::NamespaceClientIn::Retry)
|
||||||
|
.unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
let after_cancel = future::block_on(bootstrap.data_plane.read_blob(&logical)).unwrap();
|
||||||
|
assert_eq!(after_cancel.map().unwrap().as_ref(), weights);
|
||||||
|
drop(after_cancel);
|
||||||
|
|
||||||
|
drop(after_abort);
|
||||||
|
drop(second);
|
||||||
|
drop(first);
|
||||||
|
drop(blob);
|
||||||
|
let reclaim_path = DataPath::parse("/runs/run-1/reclaim").unwrap();
|
||||||
|
let replacement_path = DataPath::parse("/runs/run-1/reclaimed").unwrap();
|
||||||
|
let reclaim_bytes = vec![0x5a; 3_000];
|
||||||
|
let mut reclaiming = future::block_on(
|
||||||
|
bootstrap
|
||||||
|
.data_plane
|
||||||
|
.write_blob(&reclaim_path, reclaim_bytes.len() as u64),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
reclaiming
|
||||||
|
.map()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.copy_from_slice(&reclaim_bytes);
|
||||||
|
future::block_on(reclaiming.seal()).unwrap();
|
||||||
|
let reclaim_source = future::block_on(direct.resolve(reclaim_path.clone()))
|
||||||
|
.unwrap()
|
||||||
|
.source;
|
||||||
|
assert!(matches!(
|
||||||
|
future::block_on(
|
||||||
|
bootstrap
|
||||||
|
.data_plane
|
||||||
|
.write_blob(&replacement_path, reclaim_bytes.len() as u64)
|
||||||
|
),
|
||||||
|
Err(data_plane::protocol::DataPlaneError::ArenaExhausted)
|
||||||
|
));
|
||||||
|
future::block_on(direct.unregister(reclaim_path, OperationId::from_u128(101))).unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(2);
|
||||||
|
let mut replacement = loop {
|
||||||
|
match future::block_on(
|
||||||
|
bootstrap
|
||||||
|
.data_plane
|
||||||
|
.write_blob(&replacement_path, reclaim_bytes.len() as u64),
|
||||||
|
) {
|
||||||
|
Ok(writer) => break writer,
|
||||||
|
Err(data_plane::protocol::DataPlaneError::ArenaExhausted)
|
||||||
|
if Instant::now() < deadline =>
|
||||||
|
{
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
Err(error) => panic!("unregister did not reclaim the published lease: {error}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
host_runtime
|
||||||
|
.stats()
|
||||||
|
.actors
|
||||||
|
.iter()
|
||||||
|
.all(|(actor, _)| *actor != reclaim_source),
|
||||||
|
"unregister did not reclaim the published source actor"
|
||||||
|
);
|
||||||
|
replacement
|
||||||
|
.map()
|
||||||
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.copy_from_slice(&reclaim_bytes);
|
||||||
|
future::block_on(replacement.abort()).unwrap();
|
||||||
|
let published_source = future::block_on(direct.resolve(result_path.clone()))
|
||||||
|
.unwrap()
|
||||||
|
.source;
|
||||||
|
bootstrap.data_plane.close().unwrap();
|
||||||
|
|
||||||
|
let after_producer_close =
|
||||||
|
future::block_on(consumer.data_plane.read_blob(&result_path)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
after_producer_close.map().unwrap().as_ref(),
|
||||||
|
b"second",
|
||||||
|
"a committed publication must outlive its producer session"
|
||||||
|
);
|
||||||
|
drop(after_producer_close);
|
||||||
|
|
||||||
|
future::block_on(direct.unregister(result_path.clone(), OperationId::from_u128(100))).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
future::block_on(direct.resolve(result_path)),
|
||||||
|
Err(NamespaceError::PathNotFound(_))
|
||||||
|
));
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(2);
|
||||||
|
while host_runtime
|
||||||
|
.stats()
|
||||||
|
.actors
|
||||||
|
.iter()
|
||||||
|
.any(|(actor, _)| *actor == published_source)
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"unregister did not reclaim the published source actor"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
consumer.data_plane.close().unwrap();
|
||||||
|
|
||||||
|
drop(bootstrap);
|
||||||
|
drop(child_engine);
|
||||||
|
drop(host_engine);
|
||||||
|
}
|
||||||
|
|
@ -22,3 +22,6 @@ libc = "0.2"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
swactor-engine = { path = "../engine", default-features = false }
|
swactor-engine = { path = "../engine", default-features = false }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ const BATCH: usize = 16;
|
||||||
/// claims stay cached so a false-dead peer can become routable again as soon as
|
/// claims stay cached so a false-dead peer can become routable again as soon as
|
||||||
/// SWIM reports it Alive. Claim deletion requires a future explicit tombstone or
|
/// SWIM reports it Alive. Claim deletion requires a future explicit tombstone or
|
||||||
/// owner-side lifecycle signal; raw `Tick` cadence is not a safe GC clock.
|
/// owner-side lifecycle signal; raw `Tick` cadence is not a safe GC clock.
|
||||||
|
|
||||||
/// Everything the `DirectoryActor` receives, as one enum. Only [`Gossip`](DirectoryIn::Gossip)
|
/// Everything the `DirectoryActor` receives, as one enum. Only [`Gossip`](DirectoryIn::Gossip)
|
||||||
/// crosses the wire (it carries the registered `DirectoryGossip` tag); the rest
|
/// crosses the wire (it carries the registered `DirectoryGossip` tag); the rest
|
||||||
/// are local control — see [`crate::messages::actor_codec_registry`].
|
/// are local control — see [`crate::messages::actor_codec_registry`].
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ impl PeerAllowList {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&file).map_err(|e| io::Error::other(e))?;
|
let json = serde_json::to_string_pretty(&file).map_err(io::Error::other)?;
|
||||||
std::fs::write(path, json)
|
std::fs::write(path, json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,10 @@ impl ClusterRegistry {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
/// Number of tombstones.
|
/// Number of tombstones.
|
||||||
pub fn tombstone_count(&self) -> usize {
|
pub fn tombstone_count(&self) -> usize {
|
||||||
self.entries.values().filter(|e| e.tombstone).count()
|
self.entries.values().filter(|e| e.tombstone).count()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
//!
|
//!
|
||||||
//! Priority ordering: Dead > Suspect > Alive (most urgent first).
|
//! Priority ordering: Dead > Suspect > Alive (most urgent first).
|
||||||
|
|
||||||
|
use std::cmp::Reverse;
|
||||||
|
|
||||||
use crate::messages::MembershipUpdate;
|
use crate::messages::MembershipUpdate;
|
||||||
use crate::types::{MemberState, NodeId};
|
use crate::types::{MemberState, NodeId};
|
||||||
|
|
||||||
|
|
@ -69,7 +71,7 @@ impl DisseminationQueue {
|
||||||
pub fn take(&mut self, max_count: usize) -> Vec<MembershipUpdate> {
|
pub fn take(&mut self, max_count: usize) -> Vec<MembershipUpdate> {
|
||||||
// Sort by priority: Dead (2) > Suspect (1) > Alive (0), descending
|
// Sort by priority: Dead (2) > Suspect (1) > Alive (0), descending
|
||||||
self.entries
|
self.entries
|
||||||
.sort_by(|a, b| b.update.state.priority().cmp(&a.update.state.priority()));
|
.sort_by_key(|entry| Reverse(entry.update.state.priority()));
|
||||||
|
|
||||||
let count = max_count.min(self.entries.len());
|
let count = max_count.min(self.entries.len());
|
||||||
let mut result = Vec::with_capacity(count);
|
let mut result = Vec::with_capacity(count);
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,10 @@ impl MemberList {
|
||||||
self.members.len()
|
self.members.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.members.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply an update. Returns `true` if the state changed (for dissemination).
|
/// Apply an update. Returns `true` if the state changed (for dissemination).
|
||||||
///
|
///
|
||||||
/// SWIM merge semantics:
|
/// SWIM merge semantics:
|
||||||
|
|
|
||||||
|
|
@ -190,13 +190,13 @@ impl SwimNode {
|
||||||
/// Alive at the same incarnation when the local entry is Dead. Removing
|
/// Alive at the same incarnation when the local entry is Dead. Removing
|
||||||
/// the stale Dead entry lets the fresh Alive record take effect.
|
/// the stale Dead entry lets the fresh Alive record take effect.
|
||||||
pub fn clear_dead_member(&mut self, node_id: NodeId) {
|
pub fn clear_dead_member(&mut self, node_id: NodeId) {
|
||||||
if let Some(entry) = self.members.get(&node_id) {
|
if let Some(entry) = self.members.get(&node_id)
|
||||||
if entry.state == MemberState::Dead {
|
&& entry.state == MemberState::Dead
|
||||||
|
{
|
||||||
self.members.remove(&node_id);
|
self.members.remove(&node_id);
|
||||||
self.dissemination.purge_node(&node_id);
|
self.dissemination.purge_node(&node_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Recent probe targets from the SWIM probe cycle.
|
/// Recent probe targets from the SWIM probe cycle.
|
||||||
pub fn recent_probe_targets(&self) -> &std::collections::VecDeque<NodeId> {
|
pub fn recent_probe_targets(&self) -> &std::collections::VecDeque<NodeId> {
|
||||||
|
|
|
||||||
|
|
@ -402,11 +402,11 @@ impl SwimProbe {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_start_probe(&mut self, members: &MemberList, actions: &mut Vec<SwimAction>) {
|
fn maybe_start_probe(&mut self, members: &MemberList, actions: &mut Vec<SwimAction>) {
|
||||||
if let Some(at) = self.next_probe_at {
|
if let Some(at) = self.next_probe_at
|
||||||
if self.now < at {
|
&& self.now < at
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if !matches!(self.phase, ProbePhase::Idle) {
|
if !matches!(self.phase, ProbePhase::Idle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -639,11 +639,11 @@ impl SwimProbe {
|
||||||
if self.config.dead_reprobe_interval == Duration::ZERO {
|
if self.config.dead_reprobe_interval == Duration::ZERO {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(at) = self.next_reprobe_at {
|
if let Some(at) = self.next_reprobe_at
|
||||||
if self.now < at {
|
&& self.now < at
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
self.next_reprobe_at = Some(self.now + self.config.dead_reprobe_interval);
|
self.next_reprobe_at = Some(self.now + self.config.dead_reprobe_interval);
|
||||||
|
|
||||||
|
|
@ -750,11 +750,11 @@ impl SwimProbe {
|
||||||
|
|
||||||
/// At safety_sweep_interval, probe one random alive member.
|
/// At safety_sweep_interval, probe one random alive member.
|
||||||
fn maybe_safety_sweep(&mut self, members: &MemberList, actions: &mut Vec<SwimAction>) {
|
fn maybe_safety_sweep(&mut self, members: &MemberList, actions: &mut Vec<SwimAction>) {
|
||||||
if let Some(at) = self.next_sweep_at {
|
if let Some(at) = self.next_sweep_at
|
||||||
if self.now < at {
|
&& self.now < at
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let interval = match &self.config.probe_mode {
|
let interval = match &self.config.probe_mode {
|
||||||
ProbeMode::Reactive {
|
ProbeMode::Reactive {
|
||||||
safety_sweep_interval,
|
safety_sweep_interval,
|
||||||
|
|
|
||||||
|
|
@ -804,15 +804,14 @@ mod directory_route_path {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every node considers the others alive.
|
// Every node considers the others alive.
|
||||||
for i in 0..n {
|
for (i, node) in nodes.iter().enumerate() {
|
||||||
for j in 0..n {
|
for (j, node_id) in ids.iter().copied().enumerate() {
|
||||||
if i != j {
|
if i != j {
|
||||||
nodes[i]
|
node.rt
|
||||||
.rt
|
|
||||||
.send_to(
|
.send_to(
|
||||||
nodes[i].directory,
|
node.directory,
|
||||||
DirectoryIn::Membership(MembershipChanged {
|
DirectoryIn::Membership(MembershipChanged {
|
||||||
node_id: ids[j],
|
node_id,
|
||||||
state: MemberState::Alive,
|
state: MemberState::Alive,
|
||||||
incarnation: 1,
|
incarnation: 1,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1871,69 +1871,74 @@ mod node_behavior {
|
||||||
sequence,
|
sequence,
|
||||||
piggyback,
|
piggyback,
|
||||||
} => {
|
} => {
|
||||||
if let Some(t) = self.index_of(to) {
|
if let Some(t) = self.index_of(to)
|
||||||
if t != origin && !excluded.contains(&t) {
|
&& t != origin
|
||||||
|
&& !excluded.contains(&t)
|
||||||
|
{
|
||||||
let from = self.ids[origin];
|
let from = self.ids[origin];
|
||||||
let resp = self.nodes[t].handle_ping(from, sequence, &piggyback);
|
let resp = self.nodes[t].handle_ping(from, sequence, &piggyback);
|
||||||
self.record(t, &resp);
|
self.record(t, &resp);
|
||||||
self.route(t, resp, excluded);
|
self.route(t, resp, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
NodeAction::SendAck {
|
NodeAction::SendAck {
|
||||||
to,
|
to,
|
||||||
sequence,
|
sequence,
|
||||||
piggyback,
|
piggyback,
|
||||||
} => {
|
} => {
|
||||||
if let Some(t) = self.index_of(to) {
|
if let Some(t) = self.index_of(to)
|
||||||
if t != origin && !excluded.contains(&t) {
|
&& t != origin
|
||||||
|
&& !excluded.contains(&t)
|
||||||
|
{
|
||||||
let from = self.ids[origin];
|
let from = self.ids[origin];
|
||||||
let resp = self.nodes[t].handle_ack(from, sequence, &piggyback);
|
let resp = self.nodes[t].handle_ack(from, sequence, &piggyback);
|
||||||
self.record(t, &resp);
|
self.record(t, &resp);
|
||||||
self.route(t, resp, excluded);
|
self.route(t, resp, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
NodeAction::SendPingReq {
|
NodeAction::SendPingReq {
|
||||||
relay,
|
relay,
|
||||||
target,
|
target,
|
||||||
sequence,
|
sequence,
|
||||||
piggyback,
|
piggyback,
|
||||||
} => {
|
} => {
|
||||||
if let Some(t) = self.index_of(relay) {
|
if let Some(t) = self.index_of(relay)
|
||||||
if t != origin && !excluded.contains(&t) {
|
&& t != origin
|
||||||
|
&& !excluded.contains(&t)
|
||||||
|
{
|
||||||
let from = self.ids[origin];
|
let from = self.ids[origin];
|
||||||
let resp = self.nodes[t]
|
let resp =
|
||||||
.handle_ping_req(from, target, sequence, &piggyback);
|
self.nodes[t].handle_ping_req(from, target, sequence, &piggyback);
|
||||||
self.record(t, &resp);
|
self.record(t, &resp);
|
||||||
self.route(t, resp, excluded);
|
self.route(t, resp, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
NodeAction::ForwardAck {
|
NodeAction::ForwardAck {
|
||||||
to,
|
to,
|
||||||
target,
|
target,
|
||||||
sequence,
|
sequence,
|
||||||
piggyback,
|
piggyback,
|
||||||
} => {
|
} => {
|
||||||
if let Some(t) = self.index_of(to) {
|
if let Some(t) = self.index_of(to)
|
||||||
if t != origin && !excluded.contains(&t) {
|
&& t != origin
|
||||||
|
&& !excluded.contains(&t)
|
||||||
|
{
|
||||||
let resp =
|
let resp =
|
||||||
self.nodes[t].handle_indirect_ack(target, sequence, &piggyback);
|
self.nodes[t].handle_indirect_ack(target, sequence, &piggyback);
|
||||||
self.record(t, &resp);
|
self.record(t, &resp);
|
||||||
self.route(t, resp, excluded);
|
self.route(t, resp, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
NodeAction::SendJoinResponse { to, members } => {
|
NodeAction::SendJoinResponse { to, members } => {
|
||||||
if let Some(t) = self.index_of(to) {
|
if let Some(t) = self.index_of(to)
|
||||||
if t != origin && !excluded.contains(&t) {
|
&& t != origin
|
||||||
|
&& !excluded.contains(&t)
|
||||||
|
{
|
||||||
let resp = self.nodes[t].handle_join_response(members);
|
let resp = self.nodes[t].handle_join_response(members);
|
||||||
self.record(t, &resp);
|
self.record(t, &resp);
|
||||||
self.route(t, resp, excluded);
|
self.route(t, resp, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
NodeAction::MembershipChanged { .. } => {}
|
NodeAction::MembershipChanged { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,6 @@ tokio = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ use swactor::runtime::{ExternalSender, Runtime, RuntimeParts};
|
||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
/// Retained so the engine owns the runtime handle it drives for its full
|
/// Retained so the engine owns the runtime handle it drives for its full
|
||||||
/// lifetime. Core workers are moved into substrate tasks at construction.
|
/// lifetime. Core workers are moved into substrate tasks at construction.
|
||||||
#[allow(dead_code)]
|
_runtime: Runtime,
|
||||||
runtime: Runtime,
|
|
||||||
backend: Arc<dyn ExecutionBackend>,
|
backend: Arc<dyn ExecutionBackend>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +40,10 @@ impl Engine {
|
||||||
// no Tokio feature gate — so core progression does not silently
|
// no Tokio feature gate — so core progression does not silently
|
||||||
// disappear when an alternate backend is used (ENGINE_SPEC.md).
|
// disappear when an alternate backend is used (ENGINE_SPEC.md).
|
||||||
crate::core_driver::install(workers, &backend);
|
crate::core_driver::install(workers, &backend);
|
||||||
Ok(Engine { runtime, backend })
|
Ok(Engine {
|
||||||
|
_runtime: runtime,
|
||||||
|
backend,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a clonable handle for scheduling engine work.
|
/// Return a clonable handle for scheduling engine work.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
//! Imports only public `swactor` APIs and exposes no private engine state. See
|
//! Imports only public `swactor` APIs and exposes no private engine state. See
|
||||||
//! `ENGINE_SPEC.md`.
|
//! `ENGINE_SPEC.md`.
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
@ -23,9 +21,10 @@ pub fn default_runtime_parts() -> (RuntimeParts, Runtime) {
|
||||||
runtime_parts(RuntimeConfig::default())
|
runtime_parts(RuntimeConfig::default())
|
||||||
}
|
}
|
||||||
pub fn runtime_parts_with_workers(worker_count: usize) -> (RuntimeParts, Runtime) {
|
pub fn runtime_parts_with_workers(worker_count: usize) -> (RuntimeParts, Runtime) {
|
||||||
let mut config = RuntimeConfig::default();
|
runtime_parts(RuntimeConfig {
|
||||||
config.worker_count = worker_count;
|
worker_count,
|
||||||
runtime_parts(config)
|
..RuntimeConfig::default()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_parts() -> RuntimeParts {
|
pub fn default_parts() -> RuntimeParts {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
//! These tests exercise the native Tokio backend specifically; the
|
//! These tests exercise the native Tokio backend specifically; the
|
||||||
//! non-Tokio portability proof lives in `engine_unit.rs`.
|
//! non-Tokio portability proof lives in `engine_unit.rs`.
|
||||||
|
|
||||||
mod common;
|
pub mod common;
|
||||||
use common::*;
|
use common::*;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
//! These tests exercise internal logic directly and use the [`SteppingBackend`]
|
//! These tests exercise internal logic directly and use the [`SteppingBackend`]
|
||||||
//! to prove substrate independence without Tokio (ENGINE_SPEC.md).
|
//! to prove substrate independence without Tokio (ENGINE_SPEC.md).
|
||||||
|
|
||||||
mod common;
|
pub mod common;
|
||||||
use common::*;
|
use common::*;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -1318,8 +1318,7 @@ fn engine_instant_is_ordered() {
|
||||||
/// A tasks-only probe backend that shares a sentinel `Arc<()>` so the test can
|
/// A tasks-only probe backend that shares a sentinel `Arc<()>` so the test can
|
||||||
/// observe exactly when the engine's strong backend reference is released.
|
/// observe exactly when the engine's strong backend reference is released.
|
||||||
struct SentinelBackend {
|
struct SentinelBackend {
|
||||||
#[allow(dead_code)]
|
_sentinel: Arc<()>,
|
||||||
sentinel: Arc<()>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExecutionBackend for SentinelBackend {
|
impl ExecutionBackend for SentinelBackend {
|
||||||
|
|
@ -1347,7 +1346,7 @@ fn dropping_engine_releases_backend_even_with_live_handles() {
|
||||||
let engine = Engine::new(
|
let engine = Engine::new(
|
||||||
parts,
|
parts,
|
||||||
SentinelBackend {
|
SentinelBackend {
|
||||||
sentinel: sentinel.clone(),
|
_sentinel: sentinel.clone(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("tasks capability present");
|
.expect("tasks capability present");
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,6 @@ parking_lot = "0.12"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
iroh-relay = { version = "0.98", features = ["server", "test-utils"] }
|
iroh-relay = { version = "0.98", features = ["server", "test-utils"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
232
crates/iroh-driver/src/blob_transfer.rs
Normal file
232
crates/iroh-driver/src/blob_transfer.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
//! Iroh implementation of one-shot fixed-length blob transfer ports.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use data_plane::blob_transfer::{
|
||||||
|
BlobTransferEvent, BlobTransferId, BlobTransferOffer, BlobTransferReceiver, BlobTransferSender,
|
||||||
|
FileTransferRequest,
|
||||||
|
};
|
||||||
|
use data_plane::edge_wire::WireEvent;
|
||||||
|
use iroh::EndpointAddr;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::runtime::Runtime;
|
||||||
|
use swactor_engine::{BlockingWorkSender, EngineHandle};
|
||||||
|
|
||||||
|
use crate::iroh_driver::EdgeConnector;
|
||||||
|
|
||||||
|
const FIRST_BLOB_EDGE_ID: u64 = 1 << 63;
|
||||||
|
const FILE_CHUNK_BYTES: usize = 64 * 1024;
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
struct IrohBlobOffer {
|
||||||
|
endpoint: EndpointAddr,
|
||||||
|
edge_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct IrohBlobTransferSender {
|
||||||
|
connector: EdgeConnector,
|
||||||
|
blocking: BlockingWorkSender,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IrohBlobTransferSender {
|
||||||
|
pub fn new(connector: EdgeConnector, engine: &EngineHandle) -> Self {
|
||||||
|
Self {
|
||||||
|
connector,
|
||||||
|
blocking: engine.blocking_work_sender(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobTransferSender for IrohBlobTransferSender {
|
||||||
|
fn start_file(&self, request: FileTransferRequest) -> Result<(), String> {
|
||||||
|
let connector = self.connector.clone();
|
||||||
|
let work = Box::new(move || {
|
||||||
|
let result = send_file(connector, &request);
|
||||||
|
request.completion.complete(result);
|
||||||
|
});
|
||||||
|
self.blocking
|
||||||
|
.submit(work)
|
||||||
|
.map_err(|_| "blob transfer engine has stopped".to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_file(connector: EdgeConnector, request: &FileTransferRequest) -> Result<(), String> {
|
||||||
|
let wire: IrohBlobOffer = serde_json::from_slice(&request.offer.transport)
|
||||||
|
.map_err(|error| format!("decode Iroh blob offer: {error}"))?;
|
||||||
|
let sender = connector.connect(wire.endpoint, wire.edge_id, CONNECT_TIMEOUT)?;
|
||||||
|
let mut transferred = 0_u64;
|
||||||
|
let mut chunk = vec![0_u8; FILE_CHUNK_BYTES];
|
||||||
|
while transferred < request.length {
|
||||||
|
let remaining = request.length - transferred;
|
||||||
|
let count = usize::try_from(remaining.min(FILE_CHUNK_BYTES as u64))
|
||||||
|
.expect("bounded blob chunk size");
|
||||||
|
let file_offset = request
|
||||||
|
.offset
|
||||||
|
.checked_add(transferred)
|
||||||
|
.ok_or_else(|| "blob source file offset overflow".to_owned())?;
|
||||||
|
let read = request
|
||||||
|
.file
|
||||||
|
.read_at(&mut chunk[..count], file_offset)
|
||||||
|
.map_err(|error| format!("read blob source at {file_offset}: {error}"))?;
|
||||||
|
if read == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"blob source ended after {transferred} bytes, expected {} bytes",
|
||||||
|
request.length
|
||||||
|
));
|
||||||
|
}
|
||||||
|
sender.send(chunk[..read].to_vec())?;
|
||||||
|
transferred = transferred
|
||||||
|
.checked_add(read as u64)
|
||||||
|
.ok_or_else(|| "blob source transfer offset overflow".to_owned())?;
|
||||||
|
}
|
||||||
|
drop(sender);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct IrohBlobTransferReceiver {
|
||||||
|
endpoint: EndpointAddr,
|
||||||
|
events: Arc<Mutex<Vec<WireEvent>>>,
|
||||||
|
destinations: Arc<Mutex<HashMap<u64, (BlobTransferId, ActorAddress)>>>,
|
||||||
|
next_edge_id: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IrohBlobTransferReceiver {
|
||||||
|
pub fn new(endpoint: EndpointAddr, events: Arc<Mutex<Vec<WireEvent>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
endpoint,
|
||||||
|
events,
|
||||||
|
destinations: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
next_edge_id: Arc::new(AtomicU64::new(FIRST_BLOB_EDGE_ID)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_pump(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
engine: &EngineHandle,
|
||||||
|
runtime: Runtime,
|
||||||
|
period: Duration,
|
||||||
|
) {
|
||||||
|
let receiver = Arc::clone(self);
|
||||||
|
let engine = engine.clone();
|
||||||
|
engine.clone().spawn(async move {
|
||||||
|
let mut interval = engine.interval(period);
|
||||||
|
loop {
|
||||||
|
(&mut interval).await;
|
||||||
|
receiver.drain(&runtime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain(&self, runtime: &Runtime) {
|
||||||
|
let mut queue = self.events.lock();
|
||||||
|
let mut remaining = Vec::with_capacity(queue.len());
|
||||||
|
for event in queue.drain(..) {
|
||||||
|
match event {
|
||||||
|
WireEvent::StreamArrived { edge_id, .. }
|
||||||
|
if self.destinations.lock().contains_key(&edge_id.0) => {}
|
||||||
|
WireEvent::BytesRead {
|
||||||
|
edge_id,
|
||||||
|
stream_id,
|
||||||
|
bytes,
|
||||||
|
} => {
|
||||||
|
let destination = self.destinations.lock().get(&edge_id.0).copied();
|
||||||
|
if let Some((transfer_id, destination)) = destination {
|
||||||
|
let _ = runtime
|
||||||
|
.send_to(destination, BlobTransferEvent::Chunk { transfer_id, bytes });
|
||||||
|
} else {
|
||||||
|
remaining.push(WireEvent::BytesRead {
|
||||||
|
edge_id,
|
||||||
|
stream_id,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WireEvent::StreamEnded { edge_id, stream_id } => {
|
||||||
|
if let Some((transfer_id, destination)) =
|
||||||
|
self.destinations.lock().remove(&edge_id.0)
|
||||||
|
{
|
||||||
|
let _ = runtime
|
||||||
|
.send_to(destination, BlobTransferEvent::Finished { transfer_id });
|
||||||
|
} else {
|
||||||
|
remaining.push(WireEvent::StreamEnded { edge_id, stream_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WireEvent::StreamFault {
|
||||||
|
edge_id: Some(edge_id),
|
||||||
|
stream_id,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
if let Some((transfer_id, destination)) =
|
||||||
|
self.destinations.lock().remove(&edge_id.0)
|
||||||
|
{
|
||||||
|
let _ = runtime.send_to(
|
||||||
|
destination,
|
||||||
|
BlobTransferEvent::Failed {
|
||||||
|
transfer_id,
|
||||||
|
reason: format!("{reason:?}"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
remaining.push(WireEvent::StreamFault {
|
||||||
|
edge_id: Some(edge_id),
|
||||||
|
stream_id,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event => remaining.push(event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue.extend(remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobTransferReceiver for IrohBlobTransferReceiver {
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
destination: ActorAddress,
|
||||||
|
transfer_id: BlobTransferId,
|
||||||
|
) -> Result<BlobTransferOffer, String> {
|
||||||
|
let edge_id = self.next_edge_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if edge_id < FIRST_BLOB_EDGE_ID {
|
||||||
|
return Err("blob transfer edge identifiers exhausted".to_owned());
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.destinations
|
||||||
|
.lock()
|
||||||
|
.insert(edge_id, (transfer_id, destination))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err("blob transfer edge identifier collision".to_owned());
|
||||||
|
}
|
||||||
|
let transport = serde_json::to_vec(&IrohBlobOffer {
|
||||||
|
endpoint: self.endpoint.clone(),
|
||||||
|
edge_id,
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
self.destinations.lock().remove(&edge_id);
|
||||||
|
format!("encode Iroh blob offer: {error}")
|
||||||
|
})?;
|
||||||
|
Ok(BlobTransferOffer {
|
||||||
|
transfer_id,
|
||||||
|
destination,
|
||||||
|
failure_proxy: None,
|
||||||
|
transport,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&self, offer: &BlobTransferOffer) {
|
||||||
|
if let Ok(wire) = serde_json::from_slice::<IrohBlobOffer>(&offer.transport) {
|
||||||
|
self.destinations.lock().remove(&wire.edge_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -108,7 +108,7 @@ pub(crate) fn spawn_edge_send_pump(
|
||||||
.map_err(|_| format!("finish edge stream {edge_id}: timed out"))?
|
.map_err(|_| format!("finish edge stream {edge_id}: timed out"))?
|
||||||
.map_err(|e| format!("finish edge stream {edge_id}: {e}"))?
|
.map_err(|e| format!("finish edge stream {edge_id}: {e}"))?
|
||||||
{
|
{
|
||||||
Some(code) => return Err(format!("peer stopped edge stream {edge_id}: {code}")),
|
Some(code) => Err(format!("peer stopped edge stream {edge_id}: {code}")),
|
||||||
None => Ok(()),
|
None => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,17 +139,16 @@ pub fn discover_lan_ips() -> Vec<IpAddr> {
|
||||||
// UDP socket trick: connect to a broadcast-ish address, read local_addr
|
// UDP socket trick: connect to a broadcast-ish address, read local_addr
|
||||||
let targets: &[&str] = &["10.255.255.255:1", "192.168.255.255:1", "172.31.255.255:1"];
|
let targets: &[&str] = &["10.255.255.255:1", "192.168.255.255:1", "172.31.255.255:1"];
|
||||||
for target in targets {
|
for target in targets {
|
||||||
if let Ok(sock) = std::net::UdpSocket::bind("0.0.0.0:0") {
|
if let Ok(sock) = std::net::UdpSocket::bind("0.0.0.0:0")
|
||||||
if sock.connect(target).is_ok() {
|
&& sock.connect(target).is_ok()
|
||||||
if let Ok(local) = sock.local_addr() {
|
&& let Ok(local) = sock.local_addr()
|
||||||
|
{
|
||||||
let ip = local.ip();
|
let ip = local.ip();
|
||||||
if !ip.is_loopback() && !ip.is_unspecified() && seen.insert(ip) {
|
if !ip.is_loopback() && !ip.is_unspecified() && seen.insert(ip) {
|
||||||
ips.push(ip);
|
ips.push(ip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse /proc/net/if_inet6 for IPv6 addresses (Linux only)
|
// Parse /proc/net/if_inet6 for IPv6 addresses (Linux only)
|
||||||
if let Ok(contents) = std::fs::read_to_string("/proc/net/if_inet6") {
|
if let Ok(contents) = std::fs::read_to_string("/proc/net/if_inet6") {
|
||||||
|
|
@ -173,11 +172,11 @@ pub fn discover_lan_ips() -> Vec<IpAddr> {
|
||||||
let ip = IpAddr::V6(std::net::Ipv6Addr::from(bytes));
|
let ip = IpAddr::V6(std::net::Ipv6Addr::from(bytes));
|
||||||
if !ip.is_loopback() && !ip.is_unspecified() {
|
if !ip.is_loopback() && !ip.is_unspecified() {
|
||||||
// Skip link-local (fe80::)
|
// Skip link-local (fe80::)
|
||||||
if let IpAddr::V6(v6) = ip {
|
if let IpAddr::V6(v6) = ip
|
||||||
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
|
&& (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if seen.insert(ip) {
|
if seen.insert(ip) {
|
||||||
ips.push(ip);
|
ips.push(ip);
|
||||||
}
|
}
|
||||||
|
|
@ -278,6 +277,35 @@ impl EdgeConnector {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cloneable capability for signing local actor-location claims.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ActorRegistrar {
|
||||||
|
keypair: Keypair,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorRegistrar {
|
||||||
|
pub fn register_actor(
|
||||||
|
&self,
|
||||||
|
actor_addr: ActorAddress,
|
||||||
|
generation: u64,
|
||||||
|
) -> distribution::types::DirectoryEntry {
|
||||||
|
self.keypair.sign_directory_entry(actor_addr, generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AcceptedConnections = Arc<Mutex<Vec<(NodeId, Connection)>>>;
|
||||||
|
type OtherAcceptedConnections = Arc<Mutex<Vec<(NodeId, Vec<u8>, Connection)>>>;
|
||||||
|
type IncomingActorFrames = Arc<Mutex<Vec<(ActorAddress, String, Vec<u8>, NodeId)>>>;
|
||||||
|
pub struct ActorBridgeConfig {
|
||||||
|
pub runtime: Runtime,
|
||||||
|
pub codec: Arc<CodecRegistry>,
|
||||||
|
pub routes: HashMap<String, ActorAddress>,
|
||||||
|
pub swim: ActorAddress,
|
||||||
|
pub relay_mirror: RelayMirror,
|
||||||
|
pub route_view: RouteView,
|
||||||
|
pub outbox: Outbox,
|
||||||
|
}
|
||||||
|
|
||||||
/// iroh P2P network transport bridge.
|
/// iroh P2P network transport bridge.
|
||||||
///
|
///
|
||||||
/// Bridges the actorized distribution protocol (running on a swactor runtime)
|
/// Bridges the actorized distribution protocol (running on a swactor runtime)
|
||||||
|
|
@ -309,9 +337,9 @@ pub struct IrohDriver {
|
||||||
/// a dead peer would otherwise freeze the whole node for the dial budget).
|
/// a dead peer would otherwise freeze the whole node for the dial budget).
|
||||||
dialing: Arc<Mutex<HashSet<NodeId>>>,
|
dialing: Arc<Mutex<HashSet<NodeId>>>,
|
||||||
/// Connections accepted by the background accept loop (SWIM ALPN).
|
/// Connections accepted by the background accept loop (SWIM ALPN).
|
||||||
accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>>,
|
accepted_conns: AcceptedConnections,
|
||||||
/// Connections accepted on non-SWIM ALPNs before driver-owned adapters claim them.
|
/// Connections accepted on non-SWIM ALPNs before driver-owned adapters claim them.
|
||||||
other_accepted_conns: Arc<Mutex<Vec<(NodeId, Vec<u8>, Connection)>>>,
|
other_accepted_conns: OtherAcceptedConnections,
|
||||||
/// Completed telemetry QUIC reads from driver-owned TELEMETRY_ALPN adapters.
|
/// Completed telemetry QUIC reads from driver-owned TELEMETRY_ALPN adapters.
|
||||||
telemetry_reads: Arc<Mutex<Vec<TelemetryQuicRead>>>,
|
telemetry_reads: Arc<Mutex<Vec<TelemetryQuicRead>>>,
|
||||||
/// Logical edge events emitted by driver-owned EDGE_ALPN byte pumps.
|
/// Logical edge events emitted by driver-owned EDGE_ALPN byte pumps.
|
||||||
|
|
@ -326,7 +354,7 @@ pub struct IrohDriver {
|
||||||
/// reads from the state machine. Each entry is `(dest, type_tag, payload,
|
/// reads from the state machine. Each entry is `(dest, type_tag, payload,
|
||||||
/// from)` — `dest` is the destination actor address carried on the wire
|
/// from)` — `dest` is the destination actor address carried on the wire
|
||||||
/// (`DIRECTORY.md` §5).
|
/// (`DIRECTORY.md` §5).
|
||||||
incoming: Arc<Mutex<Vec<(ActorAddress, String, Vec<u8>, NodeId)>>>,
|
incoming: IncomingActorFrames,
|
||||||
/// Connections whose fire-and-forget send failed; evicted (and re-dialed)
|
/// Connections whose fire-and-forget send failed; evicted (and re-dialed)
|
||||||
/// on the next `recv()`. Populated by the spawned send tasks.
|
/// on the next `recv()`. Populated by the spawned send tasks.
|
||||||
evict: Arc<Mutex<Vec<FailedConnection>>>,
|
evict: Arc<Mutex<Vec<FailedConnection>>>,
|
||||||
|
|
@ -467,11 +495,8 @@ impl IrohDriver {
|
||||||
let iroh_secret = endpoint.secret_key().to_bytes();
|
let iroh_secret = endpoint.secret_key().to_bytes();
|
||||||
let keypair = Keypair::from_bytes(&iroh_secret);
|
let keypair = Keypair::from_bytes(&iroh_secret);
|
||||||
|
|
||||||
// Spawn background accept loop so incoming connections are never missed
|
let accepted_conns: AcceptedConnections = Arc::new(Mutex::new(Vec::new()));
|
||||||
let accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>> =
|
let other_accepted_conns: OtherAcceptedConnections = Arc::new(Mutex::new(Vec::new()));
|
||||||
Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let other_accepted_conns: Arc<Mutex<Vec<(NodeId, Vec<u8>, Connection)>>> =
|
|
||||||
Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let telemetry_reads: Arc<Mutex<Vec<TelemetryQuicRead>>> = Arc::new(Mutex::new(Vec::new()));
|
let telemetry_reads: Arc<Mutex<Vec<TelemetryQuicRead>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let edge_events: Arc<Mutex<Vec<WireEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
let edge_events: Arc<Mutex<Vec<WireEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
{
|
{
|
||||||
|
|
@ -480,10 +505,8 @@ impl IrohDriver {
|
||||||
let swim_buf = Arc::clone(&accepted_conns);
|
let swim_buf = Arc::clone(&accepted_conns);
|
||||||
let other_buf = Arc::clone(&other_accepted_conns);
|
let other_buf = Arc::clone(&other_accepted_conns);
|
||||||
engine.spawn(async move {
|
engine.spawn(async move {
|
||||||
loop {
|
while let Some(incoming) = ep.accept().await {
|
||||||
match ep.accept().await {
|
if let Ok(conn) = incoming.await {
|
||||||
Some(incoming) => match incoming.await {
|
|
||||||
Ok(conn) => {
|
|
||||||
let remote_id = conn.remote_id();
|
let remote_id = conn.remote_id();
|
||||||
let node_id = NodeId(*remote_id.as_bytes());
|
let node_id = NodeId(*remote_id.as_bytes());
|
||||||
// Peer auth check
|
// Peer auth check
|
||||||
|
|
@ -503,10 +526,6 @@ impl IrohDriver {
|
||||||
other_buf.lock().push((node_id, negotiated_alpn, conn));
|
other_buf.lock().push((node_id, negotiated_alpn, conn));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {}
|
|
||||||
},
|
|
||||||
None => break, // endpoint closed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -669,11 +688,11 @@ impl IrohDriver {
|
||||||
/// directly. IPv6 unspecified is mapped to localhost.
|
/// directly. IPv6 unspecified is mapped to localhost.
|
||||||
pub fn endpoint_addr(&self) -> EndpointAddr {
|
pub fn endpoint_addr(&self) -> EndpointAddr {
|
||||||
let mut addr = self.endpoint.addr();
|
let mut addr = self.endpoint.addr();
|
||||||
if addr.relay_urls().next().is_none() {
|
if addr.relay_urls().next().is_none()
|
||||||
if let Some(relay) = self.relay_url.clone() {
|
&& let Some(relay) = self.relay_url.clone()
|
||||||
|
{
|
||||||
addr = addr.with_relay_url(relay);
|
addr = addr.with_relay_url(relay);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for sa in self.direct_addresses() {
|
for sa in self.direct_addresses() {
|
||||||
addr = addr.with_ip_addr(sa);
|
addr = addr.with_ip_addr(sa);
|
||||||
}
|
}
|
||||||
|
|
@ -729,6 +748,12 @@ impl IrohDriver {
|
||||||
self.keypair.sign_directory_entry(actor_addr, generation)
|
self.keypair.sign_directory_entry(actor_addr, generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn actor_registrar(&self) -> ActorRegistrar {
|
||||||
|
ActorRegistrar {
|
||||||
|
keypair: self.keypair.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Capture the driver-owned slice of the node's observable state: identity,
|
/// Capture the driver-owned slice of the node's observable state: identity,
|
||||||
/// listen address, and the directory route-view extent. The core node no
|
/// listen address, and the directory route-view extent. The core node no
|
||||||
/// longer polls this (its telemetry flows over the telemetry), so this is
|
/// longer polls this (its telemetry flows over the telemetry), so this is
|
||||||
|
|
@ -776,7 +801,7 @@ impl IrohDriver {
|
||||||
.map(|(actor, host)| (*actor, *host))
|
.map(|(actor, host)| (*actor, *host))
|
||||||
.collect();
|
.collect();
|
||||||
// Stable order so observers don't reshuffle each tick.
|
// Stable order so observers don't reshuffle each tick.
|
||||||
entries.sort_by(|a, b| a.0.0.cmp(&b.0.0));
|
entries.sort_by_key(|a| a.0.0);
|
||||||
entries
|
entries
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1123,22 +1148,22 @@ impl IrohDriver {
|
||||||
/// and the swactor runtime — the seam by which the protocol actors send and
|
/// and the swactor runtime — the seam by which the protocol actors send and
|
||||||
/// receive over iroh. Frame progression is driven by the engine-hosted
|
/// receive over iroh. Frame progression is driven by the engine-hosted
|
||||||
/// adapter pump ([`Self::install_actor_bridge_pump`]).
|
/// adapter pump ([`Self::install_actor_bridge_pump`]).
|
||||||
pub fn enable_actor_bridge(
|
pub fn enable_actor_bridge(&mut self, config: ActorBridgeConfig) {
|
||||||
&mut self,
|
let ActorBridgeConfig {
|
||||||
rt: Runtime,
|
runtime,
|
||||||
codec: Arc<CodecRegistry>,
|
|
||||||
routes: HashMap<String, ActorAddress>,
|
|
||||||
swim_addr: ActorAddress,
|
|
||||||
relay_mirror: RelayMirror,
|
|
||||||
route_view: RouteView,
|
|
||||||
outbox: Outbox,
|
|
||||||
) {
|
|
||||||
let self_peer_addr = peer_addr(self.node_id());
|
|
||||||
self.actor_bridge = Some(Arc::new(ActorBridge {
|
|
||||||
rt,
|
|
||||||
codec,
|
codec,
|
||||||
routes,
|
routes,
|
||||||
swim_addr,
|
swim,
|
||||||
|
relay_mirror,
|
||||||
|
route_view,
|
||||||
|
outbox,
|
||||||
|
} = config;
|
||||||
|
let self_peer_addr = peer_addr(self.node_id());
|
||||||
|
self.actor_bridge = Some(Arc::new(ActorBridge {
|
||||||
|
rt: runtime,
|
||||||
|
codec,
|
||||||
|
routes,
|
||||||
|
swim_addr: swim,
|
||||||
self_peer_addr,
|
self_peer_addr,
|
||||||
relay_mirror,
|
relay_mirror,
|
||||||
route_view,
|
route_view,
|
||||||
|
|
@ -1243,11 +1268,11 @@ struct AdapterPump {
|
||||||
engine: EngineHandle,
|
engine: EngineHandle,
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
conns: Arc<Mutex<ConnCache>>,
|
conns: Arc<Mutex<ConnCache>>,
|
||||||
incoming: Arc<Mutex<Vec<(ActorAddress, String, Vec<u8>, NodeId)>>>,
|
incoming: IncomingActorFrames,
|
||||||
evict: Arc<Mutex<Vec<FailedConnection>>>,
|
evict: Arc<Mutex<Vec<FailedConnection>>>,
|
||||||
pending_joins: Arc<Mutex<Vec<JoinResult>>>,
|
pending_joins: Arc<Mutex<Vec<JoinResult>>>,
|
||||||
accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>>,
|
accepted_conns: AcceptedConnections,
|
||||||
other_accepted_conns: Arc<Mutex<Vec<(NodeId, Vec<u8>, Connection)>>>,
|
other_accepted_conns: OtherAcceptedConnections,
|
||||||
telemetry_reads: Arc<Mutex<Vec<TelemetryQuicRead>>>,
|
telemetry_reads: Arc<Mutex<Vec<TelemetryQuicRead>>>,
|
||||||
edge_events: Arc<Mutex<Vec<WireEvent>>>,
|
edge_events: Arc<Mutex<Vec<WireEvent>>>,
|
||||||
retain_telemetry_conns: Arc<std::sync::atomic::AtomicBool>,
|
retain_telemetry_conns: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
|
@ -1293,13 +1318,11 @@ impl AdapterPump {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if should_redial {
|
if should_redial && let Ok(key) = PublicKey::from_bytes(&failed.node_id.0) {
|
||||||
if let Ok(key) = PublicKey::from_bytes(&failed.node_id.0) {
|
|
||||||
let _ = self.get_or_connect(failed.node_id, key);
|
let _ = self.get_or_connect(failed.node_id, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Cache a connection and start reading from it.
|
/// Cache a connection and start reading from it.
|
||||||
fn cache_connection(&self, node_id: NodeId, conn: Connection) {
|
fn cache_connection(&self, node_id: NodeId, conn: Connection) {
|
||||||
|
|
@ -1455,10 +1478,8 @@ impl AdapterPump {
|
||||||
.and_then(|s| s.parse::<iroh::RelayUrl>().ok())
|
.and_then(|s| s.parse::<iroh::RelayUrl>().ok())
|
||||||
{
|
{
|
||||||
Some(r)
|
Some(r)
|
||||||
} else if let Some(r) = self.home_relay_url() {
|
|
||||||
Some(r)
|
|
||||||
} else {
|
} else {
|
||||||
None
|
self.home_relay_url()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,24 +9,26 @@
|
||||||
// work goes through `EngineHandle`.
|
// work goes through `EngineHandle`.
|
||||||
#![deny(clippy::disallowed_methods)]
|
#![deny(clippy::disallowed_methods)]
|
||||||
|
|
||||||
|
pub mod blob_transfer;
|
||||||
pub mod edge_transport;
|
pub mod edge_transport;
|
||||||
pub mod endpoint_advertisement;
|
pub mod endpoint_advertisement;
|
||||||
pub mod iroh_driver;
|
pub mod iroh_driver;
|
||||||
pub mod telemetry_transport;
|
pub mod telemetry_transport;
|
||||||
|
|
||||||
|
pub use blob_transfer::{IrohBlobTransferReceiver, IrohBlobTransferSender};
|
||||||
pub use endpoint_advertisement::{
|
pub use endpoint_advertisement::{
|
||||||
EndpointAddrMask, MVP_IROH_ENDPOINT_ADDR_MASK_ENV, advertised_endpoint,
|
EndpointAddrMask, MVP_IROH_ENDPOINT_ADDR_MASK_ENV, advertised_endpoint,
|
||||||
};
|
};
|
||||||
pub use iroh_driver::{
|
pub use iroh_driver::{
|
||||||
ConnType, EdgeConnector, IrohDriver, IrohDriverConfig, JoinPhase, JoinStatus,
|
ActorBridgeConfig, ActorRegistrar, ConnType, EdgeConnector, IrohDriver, IrohDriverConfig,
|
||||||
TelemetryPublishHandle, conn_type_of, discover_lan_ips,
|
JoinPhase, JoinStatus, TelemetryPublishHandle, conn_type_of, discover_lan_ips,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use edge_transport::{EDGE_ALPN, EdgeSendHandle};
|
pub use edge_transport::{EDGE_ALPN, EdgeSendHandle};
|
||||||
|
|
||||||
pub use telemetry_transport::{
|
pub use telemetry_transport::{
|
||||||
PullCollectorHandle, TELEMETRY_ALPN, TelemetryQuicHeader, TelemetryQuicRead,
|
PullCollectorConfig, PullCollectorHandle, TELEMETRY_ALPN, TelemetryQuicHeader,
|
||||||
TelemetryQuicWriteStats, read_events_from_stream, read_next_event,
|
TelemetryQuicRead, TelemetryQuicWriteStats, read_events_from_stream, read_next_event,
|
||||||
read_next_uni_from_connection, read_pull_request, read_stream_header, read_stream_into_fanout,
|
read_next_uni_from_connection, read_pull_request, read_stream_header, read_stream_into_fanout,
|
||||||
spawn_connection_reader, spawn_pull_collector, spawn_pull_collector_to_actor,
|
spawn_connection_reader, spawn_pull_collector, spawn_pull_collector_to_actor,
|
||||||
spawn_pull_server, spawn_subscription_writer, write_available_subscription, write_event,
|
spawn_pull_server, spawn_subscription_writer, write_available_subscription, write_event,
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,15 @@ impl Drop for PullCollectorCompletion {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PullCollectorConfig {
|
||||||
|
pub endpoint: Endpoint,
|
||||||
|
pub peer: EndpointAddr,
|
||||||
|
pub flow_id: [u8; 16],
|
||||||
|
pub token: Vec<u8>,
|
||||||
|
pub request: telemetry::SubscriptionRequest,
|
||||||
|
pub fanout: std::sync::Arc<telemetry::DeliveryFanout>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Supervisor side: retain a pull subscription to a node on `TELEMETRY_ALPN`.
|
/// Supervisor side: retain a pull subscription to a node on `TELEMETRY_ALPN`.
|
||||||
///
|
///
|
||||||
/// A transport interruption reconnects with bounded backoff. Returning after
|
/// A transport interruption reconnects with bounded backoff. Returning after
|
||||||
|
|
@ -151,24 +160,10 @@ impl Drop for PullCollectorCompletion {
|
||||||
/// easy to trigger while several freshly-bootstrapped nodes answer at once.
|
/// easy to trigger while several freshly-bootstrapped nodes answer at once.
|
||||||
pub fn spawn_pull_collector(
|
pub fn spawn_pull_collector(
|
||||||
engine: &EngineHandle,
|
engine: &EngineHandle,
|
||||||
endpoint: Endpoint,
|
config: PullCollectorConfig,
|
||||||
peer: EndpointAddr,
|
|
||||||
flow_id: [u8; 16],
|
|
||||||
token: Vec<u8>,
|
|
||||||
request: telemetry::SubscriptionRequest,
|
|
||||||
fanout: std::sync::Arc<telemetry::DeliveryFanout>,
|
|
||||||
on_header: std::sync::mpsc::Sender<TelemetryQuicHeader>,
|
on_header: std::sync::mpsc::Sender<TelemetryQuicHeader>,
|
||||||
) -> PullCollectorHandle {
|
) -> PullCollectorHandle {
|
||||||
spawn_pull_collector_with_sink(
|
spawn_pull_collector_with_sink(engine, config, PullHeaderSink::Channel(on_header))
|
||||||
engine,
|
|
||||||
endpoint,
|
|
||||||
peer,
|
|
||||||
flow_id,
|
|
||||||
token,
|
|
||||||
request,
|
|
||||||
fanout,
|
|
||||||
PullHeaderSink::Channel(on_header),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supervisor side variant that delivers each connection header directly to
|
/// Supervisor side variant that delivers each connection header directly to
|
||||||
|
|
@ -176,25 +171,11 @@ pub fn spawn_pull_collector(
|
||||||
/// stream identity changes domain state.
|
/// stream identity changes domain state.
|
||||||
pub fn spawn_pull_collector_to_actor(
|
pub fn spawn_pull_collector_to_actor(
|
||||||
engine: &EngineHandle,
|
engine: &EngineHandle,
|
||||||
endpoint: Endpoint,
|
config: PullCollectorConfig,
|
||||||
peer: EndpointAddr,
|
|
||||||
flow_id: [u8; 16],
|
|
||||||
token: Vec<u8>,
|
|
||||||
request: telemetry::SubscriptionRequest,
|
|
||||||
fanout: std::sync::Arc<telemetry::DeliveryFanout>,
|
|
||||||
sender: ExternalSender,
|
sender: ExternalSender,
|
||||||
actor: ActorAddress,
|
actor: ActorAddress,
|
||||||
) -> PullCollectorHandle {
|
) -> PullCollectorHandle {
|
||||||
spawn_pull_collector_with_sink(
|
spawn_pull_collector_with_sink(engine, config, PullHeaderSink::Actor { sender, actor })
|
||||||
engine,
|
|
||||||
endpoint,
|
|
||||||
peer,
|
|
||||||
flow_id,
|
|
||||||
token,
|
|
||||||
request,
|
|
||||||
fanout,
|
|
||||||
PullHeaderSink::Actor { sender, actor },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PullHeaderSink {
|
enum PullHeaderSink {
|
||||||
|
|
@ -214,17 +195,19 @@ impl PullHeaderSink {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn spawn_pull_collector_with_sink(
|
fn spawn_pull_collector_with_sink(
|
||||||
engine: &EngineHandle,
|
engine: &EngineHandle,
|
||||||
endpoint: Endpoint,
|
config: PullCollectorConfig,
|
||||||
peer: EndpointAddr,
|
|
||||||
flow_id: [u8; 16],
|
|
||||||
token: Vec<u8>,
|
|
||||||
request: telemetry::SubscriptionRequest,
|
|
||||||
fanout: std::sync::Arc<telemetry::DeliveryFanout>,
|
|
||||||
on_header: PullHeaderSink,
|
on_header: PullHeaderSink,
|
||||||
) -> PullCollectorHandle {
|
) -> PullCollectorHandle {
|
||||||
|
let PullCollectorConfig {
|
||||||
|
endpoint,
|
||||||
|
peer,
|
||||||
|
flow_id,
|
||||||
|
token,
|
||||||
|
request,
|
||||||
|
fanout,
|
||||||
|
} = config;
|
||||||
let (cancellation, mut cancellation_rx) = tokio::sync::watch::channel(false);
|
let (cancellation, mut cancellation_rx) = tokio::sync::watch::channel(false);
|
||||||
let (completion, completion_rx) = tokio::sync::watch::channel(false);
|
let (completion, completion_rx) = tokio::sync::watch::channel(false);
|
||||||
let engine_handle = engine.clone();
|
let engine_handle = engine.clone();
|
||||||
|
|
@ -486,11 +469,8 @@ pub async fn read_stream_header(recv: &mut RecvStream) -> Result<TelemetryQuicHe
|
||||||
pub async fn read_events_from_stream(mut recv: RecvStream) -> Result<TelemetryQuicRead, BoxError> {
|
pub async fn read_events_from_stream(mut recv: RecvStream) -> Result<TelemetryQuicRead, BoxError> {
|
||||||
let header = read_header(&mut recv).await?;
|
let header = read_header(&mut recv).await?;
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
loop {
|
while let Some(event) = read_next_event(&mut recv, &header.stream).await? {
|
||||||
match read_next_event(&mut recv, &header.stream).await? {
|
events.push(event);
|
||||||
Some(event) => events.push(event),
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(TelemetryQuicRead { header, events })
|
Ok(TelemetryQuicRead { header, events })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
153
crates/iroh-driver/tests/blob_transfer.rs
Normal file
153
crates/iroh-driver/tests/blob_transfer.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use data_plane::blob_transfer::{
|
||||||
|
BlobTransferCompletion, BlobTransferEvent, BlobTransferId, BlobTransferReceiver,
|
||||||
|
BlobTransferSender, FileTransferRequest,
|
||||||
|
};
|
||||||
|
use iroh::RelayMode;
|
||||||
|
use iroh_driver::{
|
||||||
|
EDGE_ALPN, IrohBlobTransferReceiver, IrohBlobTransferSender, IrohDriver, IrohDriverConfig,
|
||||||
|
};
|
||||||
|
use swactor::actor::ActorAddress;
|
||||||
|
use swactor::config::RuntimeConfig;
|
||||||
|
use swactor::runtime::{Runtime, RuntimeParts};
|
||||||
|
use swactor_engine::{Engine, TokioBackend, TokioConfig};
|
||||||
|
use swactor_transport::{CodecRegistry, CodecRemoteSink, TransportRouter};
|
||||||
|
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
struct Completion(std::sync::mpsc::Sender<Result<(), String>>);
|
||||||
|
|
||||||
|
impl BlobTransferCompletion for Completion {
|
||||||
|
fn complete(self: Box<Self>, result: Result<(), String>) {
|
||||||
|
let _ = self.0.send(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Node {
|
||||||
|
engine: Engine,
|
||||||
|
runtime: Runtime,
|
||||||
|
driver: IrohDriver,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node() -> Node {
|
||||||
|
let parts = RuntimeParts::new(RuntimeConfig::default());
|
||||||
|
let runtime = parts.runtime().clone();
|
||||||
|
let engine = Engine::new(
|
||||||
|
parts,
|
||||||
|
TokioBackend::new(TokioConfig::default()).expect("tokio backend"),
|
||||||
|
)
|
||||||
|
.expect("engine");
|
||||||
|
let mut driver = IrohDriver::with_engine(
|
||||||
|
engine.handle(),
|
||||||
|
IrohDriverConfig {
|
||||||
|
secret_key: None,
|
||||||
|
relay_mode: RelayMode::Disabled,
|
||||||
|
node: distribution::node::DistributedNodeConfig::default(),
|
||||||
|
peer_auth: None,
|
||||||
|
additional_alpns: vec![EDGE_ALPN.to_vec()],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("Iroh driver");
|
||||||
|
let codecs = Arc::new(CodecRegistry::new());
|
||||||
|
let router = Arc::new(TransportRouter::new());
|
||||||
|
runtime.set_remote_sink(Arc::new(CodecRemoteSink::new(Arc::clone(&codecs), router)));
|
||||||
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
|
runtime: runtime.clone(),
|
||||||
|
codec: codecs,
|
||||||
|
routes: HashMap::new(),
|
||||||
|
swim: ActorAddress::default(),
|
||||||
|
relay_mirror: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
route_view: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
outbox: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
});
|
||||||
|
driver.install_actor_bridge_pump(Duration::from_millis(5));
|
||||||
|
Node {
|
||||||
|
engine,
|
||||||
|
runtime,
|
||||||
|
driver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_iroh_transfer_delivers_exact_file_bytes() {
|
||||||
|
let source = node();
|
||||||
|
let destination = node();
|
||||||
|
let receiver = Arc::new(IrohBlobTransferReceiver::new(
|
||||||
|
destination.driver.endpoint_addr(),
|
||||||
|
destination.driver.edge_events_handle(),
|
||||||
|
));
|
||||||
|
receiver.install_pump(
|
||||||
|
&destination.engine.handle(),
|
||||||
|
destination.runtime.clone(),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
);
|
||||||
|
let sender =
|
||||||
|
IrohBlobTransferSender::new(source.driver.edge_connector(), &source.engine.handle());
|
||||||
|
let inbox = destination
|
||||||
|
.runtime
|
||||||
|
.new_inbox::<BlobTransferEvent>()
|
||||||
|
.expect("destination inbox");
|
||||||
|
let transfer_id = BlobTransferId(77);
|
||||||
|
let offer = receiver
|
||||||
|
.open(*inbox.addr(), transfer_id)
|
||||||
|
.expect("open transfer receiver");
|
||||||
|
|
||||||
|
let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"swactor-iroh-blob-{}-{sequence}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let expected = b"iroh-file-blob";
|
||||||
|
std::fs::write(&path, expected).expect("write fixture");
|
||||||
|
let (completion_tx, completion_rx) = std::sync::mpsc::channel();
|
||||||
|
sender
|
||||||
|
.start_file(FileTransferRequest {
|
||||||
|
offer,
|
||||||
|
file: File::open(&path).expect("open fixture"),
|
||||||
|
offset: 0,
|
||||||
|
length: expected.len() as u64,
|
||||||
|
completion: Box::new(Completion(completion_tx)),
|
||||||
|
})
|
||||||
|
.expect("start Iroh transfer");
|
||||||
|
completion_rx
|
||||||
|
.recv_timeout(Duration::from_secs(10))
|
||||||
|
.expect("source completion")
|
||||||
|
.expect("source transfer");
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
let mut received = Vec::new();
|
||||||
|
loop {
|
||||||
|
if let Some(event) = inbox.try_recv() {
|
||||||
|
match event {
|
||||||
|
BlobTransferEvent::Chunk {
|
||||||
|
transfer_id: found,
|
||||||
|
bytes,
|
||||||
|
} => {
|
||||||
|
assert_eq!(found, transfer_id);
|
||||||
|
received.extend_from_slice(&bytes);
|
||||||
|
}
|
||||||
|
BlobTransferEvent::Finished { transfer_id: found } => {
|
||||||
|
assert_eq!(found, transfer_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
BlobTransferEvent::Failed { reason, .. } => {
|
||||||
|
panic!("Iroh blob transfer failed: {reason}")
|
||||||
|
}
|
||||||
|
event => panic!("unexpected local blob transfer event: {event:?}"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"destination transfer deadline elapsed"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(received, expected);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
//! Membership is observed through the harness `membership_mirror` (a
|
//! Membership is observed through the harness `membership_mirror` (a
|
||||||
//! `MemberList` filled by the [`MembershipFanout`] from SWIM's
|
//! `MemberList` filled by the [`MembershipFanout`] from SWIM's
|
||||||
//! `MembershipChanged` stream). The driver snapshot no longer carries members.
|
//! `MembershipChanged` stream). The driver snapshot no longer carries members.
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::ops::{Index, IndexMut};
|
use std::ops::{Index, IndexMut};
|
||||||
|
|
@ -80,15 +79,15 @@ impl ActorInterface for MembershipFanout {
|
||||||
/// the four protocol actors. Owns everything that must stay alive and be pumped.
|
/// the four protocol actors. Owns everything that must stay alive and be pumped.
|
||||||
pub struct IrohNode {
|
pub struct IrohNode {
|
||||||
pub driver: IrohDriver,
|
pub driver: IrohDriver,
|
||||||
rt: Runtime,
|
_rt: Runtime,
|
||||||
outbox: Outbox,
|
_outbox: Outbox,
|
||||||
swim_addr: ActorAddress,
|
_swim_addr: ActorAddress,
|
||||||
registry_addr: ActorAddress,
|
_registry_addr: ActorAddress,
|
||||||
metadata_addr: ActorAddress,
|
_metadata_addr: ActorAddress,
|
||||||
directory_addr: ActorAddress,
|
_directory_addr: ActorAddress,
|
||||||
membership_mirror: Arc<Mutex<MemberList>>,
|
membership_mirror: Arc<Mutex<MemberList>>,
|
||||||
relay_mirror: RelayMirror,
|
_relay_mirror: RelayMirror,
|
||||||
route_view: RouteView,
|
_route_view: RouteView,
|
||||||
/// The engine that owns this node's Tokio substrate and drives the core
|
/// The engine that owns this node's Tokio substrate and drives the core
|
||||||
/// runtime. Declared last so it drops after the driver on teardown.
|
/// runtime. Declared last so it drops after the driver on teardown.
|
||||||
_engine: Engine,
|
_engine: Engine,
|
||||||
|
|
@ -215,15 +214,15 @@ impl IrohNode {
|
||||||
routes.insert("swactor_dist::RegistryGossip".to_string(), registry_addr);
|
routes.insert("swactor_dist::RegistryGossip".to_string(), registry_addr);
|
||||||
routes.insert("swactor_dist::MetadataGossip".to_string(), metadata_addr);
|
routes.insert("swactor_dist::MetadataGossip".to_string(), metadata_addr);
|
||||||
routes.insert("swactor_dist::DirectoryGossip".to_string(), directory_addr);
|
routes.insert("swactor_dist::DirectoryGossip".to_string(), directory_addr);
|
||||||
driver.enable_actor_bridge(
|
driver.enable_actor_bridge(iroh_driver::ActorBridgeConfig {
|
||||||
rt.clone(),
|
runtime: rt.clone(),
|
||||||
Arc::clone(&actor_codec),
|
codec: Arc::clone(&actor_codec),
|
||||||
routes,
|
routes,
|
||||||
swim_addr,
|
swim: swim_addr,
|
||||||
Arc::clone(&relay_mirror),
|
relay_mirror: Arc::clone(&relay_mirror),
|
||||||
Arc::clone(&route_view),
|
route_view: Arc::clone(&route_view),
|
||||||
Arc::clone(&outbox),
|
outbox: Arc::clone(&outbox),
|
||||||
);
|
});
|
||||||
// Engine-hosted adapter pump: drains ingress/egress/telemetry/edge on
|
// Engine-hosted adapter pump: drains ingress/egress/telemetry/edge on
|
||||||
// a timer so the synchronous test loop no longer pumps these by hand.
|
// a timer so the synchronous test loop no longer pumps these by hand.
|
||||||
driver.install_actor_bridge_pump(Duration::from_millis(10));
|
driver.install_actor_bridge_pump(Duration::from_millis(10));
|
||||||
|
|
@ -248,15 +247,15 @@ impl IrohNode {
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
driver,
|
driver,
|
||||||
rt,
|
_rt: rt,
|
||||||
outbox,
|
_outbox: outbox,
|
||||||
swim_addr,
|
_swim_addr: swim_addr,
|
||||||
registry_addr,
|
_registry_addr: registry_addr,
|
||||||
metadata_addr,
|
_metadata_addr: metadata_addr,
|
||||||
directory_addr,
|
_directory_addr: directory_addr,
|
||||||
membership_mirror,
|
membership_mirror,
|
||||||
relay_mirror,
|
_relay_mirror: relay_mirror,
|
||||||
route_view,
|
_route_view: route_view,
|
||||||
_engine: engine,
|
_engine: engine,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -465,8 +464,8 @@ impl IrohTestCluster {
|
||||||
let mut nodes: Vec<IrohNode> = (0..n).map(|_| make_driver()).collect();
|
let mut nodes: Vec<IrohNode> = (0..n).map(|_| make_driver()).collect();
|
||||||
|
|
||||||
let addr_0 = nodes[0].endpoint_addr();
|
let addr_0 = nodes[0].endpoint_addr();
|
||||||
for i in 1..n {
|
for node in nodes.iter_mut().skip(1) {
|
||||||
nodes[i].join(&[addr_0.clone()]);
|
node.join(std::slice::from_ref(&addr_0));
|
||||||
}
|
}
|
||||||
|
|
||||||
Self { nodes }
|
Self { nodes }
|
||||||
|
|
@ -477,6 +476,10 @@ impl IrohTestCluster {
|
||||||
self.nodes.len()
|
self.nodes.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.nodes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
/// The public key (SWIM/gossip identity) of node `idx`.
|
/// The public key (SWIM/gossip identity) of node `idx`.
|
||||||
pub fn key(&self, idx: usize) -> PublicKey {
|
pub fn key(&self, idx: usize) -> PublicKey {
|
||||||
self.nodes[idx].key()
|
self.nodes[idx].key()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
//! Shared test config for the iroh-driver integration tests.
|
//! Shared test config for the iroh-driver integration tests.
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use distribution::node::DistributedNodeConfig;
|
use distribution::node::DistributedNodeConfig;
|
||||||
use distribution::registry::RegistryConfig;
|
use distribution::registry::RegistryConfig;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue