feat(worker): terminate a deleted station's worker once it hosts zero elements (#2027) (#2038)
* feat(worker): terminate a deleted station's worker once it hosts zero elements (#2027)
Add an element-granular removeElement primitive to the worker abstraction and
wire it into the station-delete path so a deleted charging station's hosting
worker thread is terminated once it no longer hosts any station, without
disrupting sibling stations that share the worker (elementsPerWorker > 1).
- WorkerAbstract: new abstract removeElement(elementKey: PropertyKey).
- WorkerSet: internal PropertyKey -> WorkerSetElement map fed by an injected
generic elementKey selector; decrement numberOfWorkerElements and reuse a
factored terminateWorker helper (shared with stop()) to terminate only at
zero elements with no in-flight addition; purge the map on element removal.
- WorkerFixedPool/WorkerDynamicPool: documented no-op (poolifier exposes no safe
single-element eviction without destroying the worker and its siblings).
- Bootstrap: pass stationInfo => stationInfo.hashId; call removeElement from
workerEventDeleted.
Set-vs-pool decision: element-granular termination is workerSet-only; pool
worker threads are a bounded, reused resource and are not reclaimed per delete.
Cleanup/timeout contract: termination reuses the existing worker exit handler
for pending-promise rejection and set/map cleanup; any in-flight broadcast
request still resolves via the existing 60s aggregation timeout backstop.
Part A (#2031 cancellable reset) and bulk stop() are untouched.
* fix(worker): harden element-granular termination against concurrency races
Cross-validated review of the removeElement primitive surfaced two reachable
defects and one latent robustness issue, now fixed:
- Phantom-count leak on re-adding the same element key: numberOfWorkerElements
was incremented unconditionally, so a duplicate/re-added key inflated the
count and the worker was never terminated. Count is now key-aware (distinct
keys per worker); factored into trackAddedWorkerElement.
- New addition routed onto a terminating worker: getWorkerSetElement could
select a worker parked in terminateWorker, losing the message and rejecting
an unrelated add. Terminating workers are now flagged and excluded from
selection.
- getWorkerSetElementByWorker matched by threadId, which collapses to -1 after
terminate(); switched to worker object identity.
Tests: add coverage for the in-flight-sibling gate, drain-to-last-sibling,
add-during-terminate, same-key re-add, and unknown-key no-op; consolidate the
silent worker fixture into echoWorker via a `hold` flag. Mutation-verified.
* fix(worker): make element termination robust and tighten its types
Second cross-validated review round hardened the removeElement primitive:
- terminateWorker no longer awaits a re-attached 'exit' listener (redundant,
since worker.terminate() fulfils once the worker has exited, and it could
deadlock if 'exit' fired first). It now catches a rejected terminate()
(surfaced via the error event, not rethrown so the removal still succeeds)
and performs pending-promise rejection + set/map cleanup in a finally, closing
the zombie-on-reject leak, the orphaned-pending edge, and the stop-concurrent-
exit deadlock in one place.
- Drop the dead, semantically fictional migration branch in
trackAddedWorkerElement: duplicate live element keys cannot occur (identities
are deduplicated upstream), so a key is counted once per worker and a same-key
re-add is a no-op.
- Make WorkerSetElement.terminating a required boolean (initialized false) and
narrow the element key from PropertyKey to string, matching the sole caller
(hashId) and avoiding non-serializable keys.
Tests: add reject-path coverage (removeElement resolves and cleans up when
terminate() rejects; an in-flight addition is still rejected on stop when
terminate() rejects). Mutation-verified. All project gates green.
* test(worker): align WorkerSet internals view element key type to string
Coherence with the production narrowing of the element key from PropertyKey
to string; test-only, no runtime change.
* docs(worker): drop the worker-termination note from the README