Repository navigation
node-mgr: let tests inject the node-ctl dirty-mark sink - #325
Conversation
The network pool told node-ctl that node status had changed by calling a free function, node_state_sync::mark_node_status_dirty, from 29 places: allocation, release, configuration updates, draining, repair, resets, slot creation and destruction, and startup and lease-health recovery. On a Tokio runtime that function spawns a detached task that dials node-ctl's fixed socket and retries with backoff. Nothing could substitute it, so every unit test that changed pool state on a runtime reached for the host's node-ctl socket. The pool now owns an Arc<dyn NodeStatusDirtySink>, passed to NetworkPool::open, and each of the 29 sites calls it through the pool. ServiceSettings::from_env supplies NodeCtlNodeStatusDirtySink, which calls the same function as before: the same process-wide coalescing flag, detached task, node-ctl client, retry schedule and log lines, and the same no-op off a runtime. Every site keeps its position, so marks fire on the same paths, in the same order and under the same locks. Building the unit struct earlier in startup does nothing. The firewall-reply-fixture example, the one other caller of NetworkPool::open, passes the production sink and keeps its behavior. NetworkPool::open_in_memory_for_test, and so the unit-test service, records marks in the process instead. open_in_memory_with_node_status_sink_for_test hands a test the recorder to assert on. It takes the recorder itself rather than any sink, so no test pool can be given the production one. - A new table test runs allocation, release and a configuration update on a Tokio runtime and requires each applied change to mark node status. Each refused change must not: allocation while draining or with no ready slot, release of a sandbox with no lease, and an update whose ready-slot target exceeds max_slots. - allocate_grants_a_lease_then_returns_it_without_new_admission used to dial node-ctl. It now requires the grant to mark node status and the idempotent retry, which returns the existing lease, not to. - allocate_test_lease no longer claims its lease on a separate thread to keep allocation off the runtime and its notification from dialing node-ctl. The comments that described that workaround now say the pool records its marks. Across the package's tests, the production dirty mark was called by 76 tests, 45 of them on a runtime, where 43 reached the node-ctl client's connect. It is now called by none.
3c14d44 to
e0ceccd
Compare
Review: 2 findingsLow
What I checked: all 29 call sites map 1:1 between base and head (12 in Verified against |
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. 2 non-blocking findings in the review comment above.
What this changes
The network pool tells node-ctl that node status changed by calling
node_state_sync::mark_node_status_dirty, a free function. On a Tokio runtime it spawns a detached task that dials node-ctl's fixed socket and retries with backoff, so nothing could substitute it. Every unit test that changed pool state on a runtime reached for the host's node-ctl socket.After this change, the pool owns the destination as a trait object,
Arc<dyn NodeStatusDirtySink>. Production passes a sink that calls the same function; tests pass one that records marks in the process.The seam and its production wiring
crate::node_state_sync::mark_node_status_dirty()NetworkPool.node_status_dirty, set at construction; the 29 sites callself/pool.mark_node_status_dirty(), which forwards to itNodeCtlNodeStatusDirtySink, built inServiceSettings::from_env, moved into the service constructor and passed toNetworkPool::openmark_node_status_dirty()NodeCtlNodeStatusDirtySink::mark_node_status_dirty→ the same, now private,mark_node_status_dirty()firewall-reply-fixtureexample (the otherNetworkPool::opencaller)NodeCtlNodeStatusDirtySinkNetworkPool::open_in_memory_for_test, soservice_for_unit_testsRecordingNodeStatusDirtySinkThe production behavior is the same:
set_drainingstill marks while holding the store lock).ServiceSettings::from_envbuilds the unit struct before the pool opens. That allocates anArcand nothing else: no I/O, no log line.There is no change to wire formats, schema, metrics, configuration or log lines.
NetworkPool::openis public, and the example calls it, soslot_poolre-exportsNodeStatusDirtySinkandNodeCtlNodeStatusDirtySink.Call-site inventory
Every production path from node-mgr to node-ctl in
pplx-space-node-mgrgoes through the dirty mark. That was true before this change and still is.slot_pool.rsupdate_config(1),allocate_pooled_slot(2),claim_ephemeral_slot_for_setup(2),mark_ephemeral_slot_leased(1),persist_ephemeral_setup_failure_state(1),mark_ephemeral_for_cleanup(1),release(2),set_draining(1),repair(1)slot_pool/db.rsapply_startup_lease_updates(1)slot_pool/scheduler.rsrecover_abandoned_work(1)slot_pool/reset.rsreset_slot(9)slot_pool/worker.rsrun_validation_pump(1),claim_missing_for_create(1),claim_ready_for_destroy(1),create_slot(1),destroy_slot_with(1),reprobe_startup_leasing_with(1)Before: these 29 sites called
crate::node_state_sync::mark_node_status_dirty(). After: the same 29 sites call the pool's sink, andrg 'node_state_sync::mark_node_status_dirty'finds nothing.NodeCtlNodeStatusDirtySinkis constructed only inServiceSettings::from_envand the example. The node-ctl client (pplx_space_node_ctl_client) is called only fromnode_state_sync.rs.Tests
NetworkPool::open_in_memory_for_test, and soservice_for_unit_tests, now records marks instead of notifying node-ctl.open_in_memory_with_node_status_sink_for_testgives a test the recorder to assert on. It takesArc<RecordingNodeStatusDirtySink>and coerces it internally, rather than any sink, so a test pool cannot be handedNodeCtlNodeStatusDirtySink.RecordingNodeStatusDirtySinklives innode_state_sync::tests, beside the trait.The exact inventory. The #298 tripwire panicked at a test's first host contact, so its lists were lower bounds. This one records every contact and lets the test continue. It logs each call to the production dirty mark, each detached node-ctl dial it spawns, each dial attempt, each node-ctl client
connect, and each read of a fixed run-directory pid link. It ran over the whole package's tests at the base commit and at this branch:connectThe 9 ignored tests, run separately with
--run-ignored only, made no contact either.At the base commit, the 45 tests that spawned a dial were:
service::runtime::tests:allocate_grants_a_lease_then_returns_it_without_new_admission;allocate_refusal_grants_no_lease(3 cases);release_refusal_keeps_the_lease(4);stop_process_forwards_an_owned_process_to_the_supervisor(5);stop_process_refuses_without_signalling(5);stop_process_reports_an_already_gone_process_as_not_stopped(2)service::execution_fence::tests:execution_preflight_waits_for_cleanup_and_revalidates(4);execution_preflight_rejects_legacy_leases_without_releasing_them;stopped_unbound_snapshot_reaches_filesystem_without_allocatingservice::launch_prepare::tests:cancelled_preparation_retains_exclusion_capacity_and_disk_accounting;preparation_rechecks_lease_after_waiting_for_cleanup_ownershipservice::cleanup_inspect::tests:every_failed_stop_preserves_the_active_lease_and_reservation;cleanup_does_not_stop_a_lease_replaced_while_it_waited_for_the_gateservice::tests:concurrent_new_lease_admission_allows_only_one_reserved_cap;startup_rpc_admission_shares_work_and_waits_for_cleanupservice::network_pool_metrics::shutdown_stops_a_bounded_pass_of_wedged_probesslot_pool::tests:cancelled_ephemeral_waiter_retains_ownership_until_durable_completion,ephemeral_completion_storage_failure_retains_and_retries_cleanup_intent,fill_failure_waits_for_launched_setup_and_repair_work_before_retry,reset_retry_accounting_db_failure_schedules_retry_without_quarantine,reset_slot_keeps_dirty_when_retained_cache_is_still_owned_by_lease_uid,reset_slot_quarantines_after_cleanup_retry_exhaustion,retirement_progresses_while_drained_without_rebuilding_capacity,startup_health_recovery_does_not_reclaim_fresh_allocations,startup_health_recovery_retries_uncertainty_without_releasing_ownership,startup_leasing_reprobe_retries_while_draining_and_stops_after_release,startup_reconcile_preserves_interrupted_destroy_cleanup_obligation,unrelated_fill_wakeups_do_not_multiply_pending_retry_tasksAll of them now record their marks in the process. Their assertions are unchanged except where noted below.
Tests that assert the mark
slot_pool::node_status_tests::node_status_changes_mark_node_ctl_dirty(new module, wired by a#[cfg(test)] modline).ResourceExhausted); release of a sandbox with no lease; an update whose ready-slot target is abovemax_slots.service::runtime::tests::allocate_grants_a_lease_then_returns_it_without_new_admission(converted; it dialed node-ctl at the base commit).Nodefixture now owns its pool's recorder. ItsNoReadySlotrefusal case swaps in a fresh pool through the same recorder.service::tests::allocate_test_leaseno longer claims its lease on a separate thread to keep allocation off the runtime and its notification from dialing node-ctl. It claims on the caller's thread, and the mark goes to the recorder. Its callers (cleanup_inspect,launch_prepareandsnapshotstests) are unchanged. TheCleanupNodefixture comment that described the workaround now says the pool records node-status marks, and node-ctl notification is gone from the host-cleanup list inclaim_contention_tests.Mutation proofs
Each mutation ran with the tripwire build, which recorded 0 contacts on every regression path.
allocationfails ("Allocate changed node status without marking it");allocate_grants_…fails ("a granted lease must reach node-ctl's node status")releasefailsupdate_configdrops its markconfig_updatefailsreleasemarks on every callrelease_without_leasefails (1 ≠ 0)claim_allocationmarks on every callallocation_while_drainingandallocation_without_ready_slotfail;allocate_grants_…fails on the retry ("returning the existing lease changes no node status",1 ≠ 0)allocation_without_ready_slotfails (1 ≠ 0);allocation_while_drainingpasses, because it stops at the drain check firstallocation,release,config_updateandallocate_grants_…failNodefixture keeps the default pool, whose recorder it does not holdallocate_grants_…failsThe seam mutations replace the sink with another in-process recorder, never the production sink, so their regression paths cannot dial node-ctl either.
Call sites left unrouted
RUN_DIR) are business logic and stay unrouted. The 14 tests that read them, identical at the base commit and on this branch:service::cleanup_inspect::tests::cleanup_does_not_stop_a_lease_replaced_while_it_waited_for_the_gateservice::cleanup_inspect::tests::every_failed_stop_preserves_the_active_lease_and_reservationservice::execution_fence::tests::execution_authority_survives_slot_release_and_rejects_stale_mutationsservice::execution_fence::tests::stopped_unbound_snapshot_reaches_filesystem_without_allocatingservice::tests::service_tests::inspect_sandbox_remains_available_when_diagnostics_are_saturatedslot_pool::tests::slot_pool_tests::dynamic_claim_keeps_live_lease_when_pod_namespace_mismatchesslot_pool::tests::slot_pool_tests::dynamic_claim_retires_dead_lease_when_pod_namespace_disappearedslot_pool::tests::slot_pool_tests::reset_retry_accounting_db_failure_schedules_retry_without_quarantineslot_pool::tests::slot_pool_tests::reset_slot_keeps_dirty_when_retained_cache_is_still_owned_by_lease_uidslot_pool::tests::slot_pool_tests::reset_slot_quarantines_after_cleanup_retry_exhaustionslot_pool::tests::slot_pool_tests::runtime_reconcile_dirties_interrupted_leasing_slotslot_pool::tests::slot_pool_tests::runtime_reconcile_keeps_dead_lease_uid_reserved_while_allowing_new_leaseslot_pool::tests::slot_pool_tests::startup_health_recovery_retries_uncertainty_without_releasing_ownershipslot_pool::tests::slot_pool_tests::startup_leasing_reprobe_retries_while_draining_and_stops_after_releasepplx-space-node-mgr-cli(crates/apps/node/mgr/cli) dials node-ctl throughpplx_space_node_api_client::connect_node_ctlto warn when inbound reconciliation would override a setting. That is a separate operator binary with no unit test reaching it, outside this package and this seam.SPACE_NODECTLinservice.rsandnetwork_helpers.rsauthorizes node-ctl calling into node-manager. It is not a node-mgr → node-ctl path.NetworkPool::open), so unit tests cannot build it. The Kind runs below prove the production wiring.Host safety
allocate_grants_…is among the run-directory readers.Overlaps with open PRs
service/snapshots.rsandservice/snapshots/tests.rs;service/snapshots.rs;service/encrypted_volumes.rsand its tests.Cargo.tomland theencrypted_volumes,gateway_isolationandpermission_treetest files. It shares no file with this PR.allocate_test_lease, whichsnapshots/tests.rscalls, keeps its signature and behavior.Validation
Head
e0ceccdb1efac5d73c622c0d6db7c0e604314745.The Kind rows below ran at
3c14d4427d621f1cb9e50ba37fbf34f2159ec324and were not re-run for this head. Since then the change is test code and comments, plus one production line: droppingNodeCtlNodeStatusDirtySink's unusedDefaultderive. No production call path, wiring or behavior differs. Every other row ran at this head.cargo fmt --all -- --checkcargo clippy --workspace --locked --all-targets --all-features -- -D warningsrustfmt --edition 2024 --check scripts/generate-env-contract.rs,generate-env-contract.sh --check,check-rust-error-policy.shscripts/source_guards/*.pyenv_test_hook_boundary,node_selector_extras_smokeandobservability_vendor_boundarypass.thin_service_mainsexits 0 but reportscrates/apps/vol/mgr/svc/src/main.rs, which this PR does not touch (pre-existing)check_no_inline_rust_test_modules.pycrates/libs/observabilityfiles this PR does not touch (pre-existing); nothing in node-mgr--all-filesSPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-node-mgr --retries 0×3.config/nextest.tomlscopes Redis topplx-space-api; node-mgr has no Redis-backed tests--run-ignored only, at3c14d44; untouched since)clone_tree::tests::private_clone_blocks_foreign_uid_grafts_and_shared_clone_is_heldfails its "run in a disposable root container" assertion, as designed when not root; it is in the nextestprivilegedgroup. 0 host contactsconnect; branch: 0 and 03c14d44): fresh cluster~/.kind-dataexisted beforehandspace-nodemgr-svcsha25674b08de2…357e3cis identical in the staged artifact, inspace-rust-build-kind:localand in all four node-manager pods (two sandbox workers, two builders). The artifact build's syncedcrates/,proto/,Cargo.tomlandCargo.lockare identical to this checkout, and the binary containsNodeCtlNodeStatusDirtySink. The pods kept 0 restarts and the same digest through the typed runokand 4 not applicable, none pending or in progress, before and after the typed smokesmoke-kind-e2e.sh(bootstrapping the fresh cluster,BOOTSTRAP_TIMEOUT=1800,DATA_PLANE_URLset, as CI does)smoke-kind-typed.sh all, ECW pass (SPACE_SMOKE_KIND_COLD_RECEIPTSand the admin token file set, as CI does)ecw.cold_create_environmentandlocal.ecw_template_build_from_oci_bootssmoke-kind-typed.sh all, native passnative_contract.command_replay_credentialsIGNORED by designfailed to mark node-status facet dirtyretries in either sandbox node-manager across the run🤖 Generated with Claude Code