-
Notifications
You must be signed in to change notification settings - Fork 127
fix(agents): wire the deployment-wait machinery that only the ZIP importer ever used #651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
09f5cc8
05dfa0c
7c21cb0
2abd3a0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -214,6 +214,18 @@ private void enforceAgentQuota(Deployment.Environment environment, String agentI | |
| } | ||
|
|
||
| private Future<Void> deploy(final Deployment.Environment environment, final String agentId, final Integer version, final Boolean autoDeploy) { | ||
| // Register BEFORE the deployment starts, so a concurrent getAgent that finds | ||
| // the agent IN_PROGRESS has a future to await. | ||
| // | ||
| // This is what makes AgentFactory's wait machinery reachable at all. Only | ||
| // RestImportService ever registered, so for every ordinary deploy | ||
| // getRegisteredDeploymentEvent returned null, waitForDeploymentCompletion had | ||
| // nothing to await, and a caller racing a deployment simply got a null agent. | ||
| // Registration must precede agentFactory.deployAgent: that call is what | ||
| // publishes the IN_PROGRESS placeholder a waiter can observe, so registering | ||
| // afterwards would leave exactly the window this closes. | ||
| deploymentListener.registerAgentDeployment(agentId, version); | ||
|
Comment on lines
+217
to
+227
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Do not let an observer request complete the active deployment. A concurrent deploy request reuses this registration. If it sees Only publish READY and enable schedules when this callable started the deployment or when the status was already READY. Leave an Proposed fix- if (EnumSet.of(NOT_FOUND, ERROR).contains(checkDeploymentStatus(environment, agentId, version))) {
+ Status status = checkDeploymentStatus(environment, agentId, version);
+ if (EnumSet.of(NOT_FOUND, ERROR).contains(status)) {
agentFactory.deployAgent(environment, agentId, version, status -> {
if (status == READY && autoDeploy) {
deploymentStore.setDeploymentInfo(environment.toString(), agentId, version, DeploymentInfo.DeploymentStatus.deployed);
}
});
+ status = READY;
}
- deploymentListener.onDeploymentEvent(new DeploymentEvent(agentId, version, environment, READY));
-
- // Lifecycle hook: auto-enable schedules for this agent
- enableSchedulesForAgent(agentId);
+ if (status == READY) {
+ deploymentListener.onDeploymentEvent(new DeploymentEvent(agentId, version, environment, READY));
+ enableSchedulesForAgent(agentId);
+ }🤖 Prompt for AI Agents |
||
|
|
||
| Callable<Void> deployAgentCallable = () -> { | ||
| try { | ||
| if (EnumSet.of(NOT_FOUND, ERROR).contains(checkDeploymentStatus(environment, agentId, version))) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| /* | ||
| * Copyright EDDI contributors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
| package ai.labs.eddi.engine.runtime.internal; | ||
|
|
||
| import ai.labs.eddi.engine.model.Deployment; | ||
| import ai.labs.eddi.engine.runtime.model.DeploymentEvent; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.util.concurrent.CompletableFuture; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.TimeoutException; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertNotNull; | ||
| import static org.junit.jupiter.api.Assertions.assertNotSame; | ||
| import static org.junit.jupiter.api.Assertions.assertNull; | ||
| import static org.junit.jupiter.api.Assertions.assertSame; | ||
| import static org.junit.jupiter.api.Assertions.assertThrows; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| /** | ||
| * Registration lifecycle in {@link DeploymentListener}. | ||
| * <p> | ||
| * Two properties matter here. A registration must be <em>discoverable</em> | ||
| * while the deployment is in flight — that is what makes | ||
| * {@code AgentFactory#waitForDeploymentCompletion} able to wait at all — and it | ||
| * must not outlive the deployment it describes: the map was only ever pruned by | ||
| * an arriving {@link DeploymentEvent}, so a registration whose event never came | ||
| * (a rejected callable, a process that died mid-deploy) stayed for the lifetime | ||
| * of the JVM. | ||
| * | ||
| * @author ginccc | ||
| */ | ||
| @DisplayName("DeploymentListener — registration lifecycle") | ||
| class DeploymentListenerRegistrationTest { | ||
|
|
||
| private DeploymentListener listener; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| listener = new DeploymentListener(); | ||
| } | ||
|
|
||
| private static DeploymentEvent event(String agentId, Integer version, Deployment.Status status) { | ||
| return new DeploymentEvent(agentId, version, Deployment.Environment.production, status); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("a registered deployment is discoverable while it is in flight") | ||
| void registeredDeploymentIsDiscoverable() { | ||
| CompletableFuture<Void> registered = listener.registerAgentDeployment("agent-1", 1); | ||
|
|
||
| assertNotNull(registered); | ||
| assertSame(registered, listener.getRegisteredDeploymentEvent("agent-1", 1), | ||
| "a waiter must find the same future the deployer registered"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("an unregistered deployment is not discoverable") | ||
| void unregisteredDeploymentIsNotDiscoverable() { | ||
| assertNull(listener.getRegisteredDeploymentEvent("never-registered", 1)); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("registering twice for the same agent+version hands back the same future") | ||
| void repeatedRegistrationIsIdempotent() { | ||
| var first = listener.registerAgentDeployment("agent-1", 1); | ||
| var second = listener.registerAgentDeployment("agent-1", 1); | ||
|
|
||
| assertSame(first, second); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("different versions register independently") | ||
| void versionsAreIndependent() { | ||
| var v1 = listener.registerAgentDeployment("agent-1", 1); | ||
| var v2 = listener.registerAgentDeployment("agent-1", 2); | ||
|
|
||
| assertNotSame(v1, v2); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("a READY event completes the registration and removes it") | ||
| void readyEventCompletesAndRemoves() { | ||
| var registered = listener.registerAgentDeployment("agent-1", 1); | ||
|
|
||
| listener.onDeploymentEvent(event("agent-1", 1, Deployment.Status.READY)); | ||
|
|
||
| assertTrue(registered.isDone()); | ||
| assertNull(listener.getRegisteredDeploymentEvent("agent-1", 1), "a settled registration must not linger"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("an ERROR event completes it exceptionally and removes it") | ||
| void errorEventCompletesExceptionallyAndRemoves() { | ||
| var registered = listener.registerAgentDeployment("agent-1", 1); | ||
|
|
||
| listener.onDeploymentEvent(event("agent-1", 1, Deployment.Status.ERROR)); | ||
|
|
||
| assertTrue(registered.isCompletedExceptionally()); | ||
| assertNull(listener.getRegisteredDeploymentEvent("agent-1", 1)); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("a registration whose event never arrives expires instead of leaking") | ||
| void unclaimedRegistrationExpires() { | ||
| var registered = listener.registerAgentDeployment("agent-1", 1); | ||
|
|
||
| // Stand in for the TTL firing: any exceptional completion must evict the | ||
| // entry. Asserting the real 5-minute timeout would mean a 5-minute test. | ||
| registered.completeExceptionally(new TimeoutException("simulated TTL")); | ||
|
|
||
| assertNull(listener.getRegisteredDeploymentEvent("agent-1", 1), | ||
| "the map was only ever pruned by an arriving event, so an unclaimed registration stayed for the " | ||
| + "lifetime of the JVM"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("the registration carries a timeout, so it cannot wait forever") | ||
| void registrationCarriesATimeout() { | ||
| assertTrue(DeploymentListener.REGISTRATION_TTL.toSeconds() > 0, | ||
| "an unbounded registration is exactly the leak this bounds"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("an expired registration does not evict a later one for the same agent") | ||
| void expiryDoesNotEvictALaterRegistration() { | ||
| var first = listener.registerAgentDeployment("agent-1", 1); | ||
| listener.onDeploymentEvent(event("agent-1", 1, Deployment.Status.READY)); | ||
|
|
||
| var second = listener.registerAgentDeployment("agent-1", 1); | ||
| assertNotSame(first, second, "precondition: the first was evicted, so this is a fresh registration"); | ||
|
|
||
| // The first future's completion callback must not remove the second entry. | ||
| first.complete(null); | ||
|
|
||
| assertSame(second, listener.getRegisteredDeploymentEvent("agent-1", 1), | ||
| "remove(key, future) — a stale completion must not evict a live registration"); | ||
| } | ||
|
Comment on lines
+128
to
+142
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline src/test/java/ai/labs/eddi/engine/runtime/internal/DeploymentListenerRegistrationTest.java \
--items all --match 'expiryDoesNotEvictALaterRegistration'
sed -n '128,142p' src/test/java/ai/labs/eddi/engine/runtime/internal/DeploymentListenerRegistrationTest.java
sed -n '59,92p' src/main/java/ai/labs/eddi/engine/runtime/internal/DeploymentListener.javaRepository: labsai/EDDI Length of output: 2875 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- test structure and helpers ---'
ast-grep outline src/test/java/ai/labs/eddi/engine/runtime/internal/DeploymentListenerRegistrationTest.java
sed -n '1,190p' src/test/java/ai/labs/eddi/engine/runtime/internal/DeploymentListenerRegistrationTest.java
printf '%s\n' '--- production declarations and usages ---'
ast-grep outline src/main/java/ai/labs/eddi/engine/runtime/internal/DeploymentListener.java
rg -n -C 4 'registerAgentDeployment|onDeploymentEvent|deploymentFutures|whenComplete|orTimeout' \
src/main/java/ai/labs/eddi/engine/runtime/internal/DeploymentListener.java \
src/test/java/ai/labs/eddi/engine/runtime/internal
printf '%s\n' '--- repository diff summary ---'
git diff --statRepository: labsai/EDDI Length of output: 41105 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
test = Path("src/test/java/ai/labs/eddi/engine/runtime/internal/DeploymentListenerRegistrationTest.java")
source = test.read_text()
start = source.index("void expiryDoesNotEvictALaterRegistration")
end = source.find("\n `@Test`", start)
print(source[start:end])
listener = Path("src/main/java/ai/labs/eddi/engine/runtime/internal/DeploymentListener.java").read_text()
start = listener.index("public CompletableFuture<Void> registerAgentDeployment")
end = listener.index("\n }", start) + 6
print("\n--- registration method ---")
print(listener[start:end])
PY
printf '%s\n' '--- all relevant call sites ---'
rg -n -C 3 'registerAgentDeployment\(|onDeploymentEvent\(' src/main src/testRepository: labsai/EDDI Length of output: 33298 Exercise the stale-completion interleaving.
🤖 Prompt for AI Agents |
||
|
|
||
| @Test | ||
| @DisplayName("an event for an unregistered deployment is a no-op, not an error") | ||
| void eventForUnregisteredDeploymentIsANoOp() { | ||
| listener.onDeploymentEvent(event("ghost", 7, Deployment.Status.READY)); | ||
| listener.onDeploymentEvent(event("ghost", 7, Deployment.Status.ERROR)); | ||
|
|
||
| assertNull(listener.getRegisteredDeploymentEvent("ghost", 7)); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("a completed registration is awaitable by the waiter that holds it") | ||
| void completedRegistrationIsAwaitable() throws Exception { | ||
| var registered = listener.registerAgentDeployment("agent-1", 1); | ||
| listener.onDeploymentEvent(event("agent-1", 1, Deployment.Status.READY)); | ||
|
|
||
| registered.get(5, TimeUnit.SECONDS); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("a failed registration surfaces the failure to its waiter") | ||
| void failedRegistrationSurfacesToWaiter() { | ||
| var registered = listener.registerAgentDeployment("agent-1", 1); | ||
| listener.onDeploymentEvent(event("agent-1", 1, Deployment.Status.ERROR)); | ||
|
|
||
| assertThrows(Exception.class, () -> registered.get(5, TimeUnit.SECONDS)); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Remove the five-minute blocking wait.
join()blocks the caller until every deployment settles. A missing event now delays this path for up toREGISTRATION_TTL, which is five minutes. Run deployment observation asynchronously and expose deployment progress separately if callers need it.As per coding guidelines, “Backend code must be thread-safe and non-blocking; use AsyncResponse for REST endpoints and avoid extended blocking in tasks.”
🤖 Prompt for AI Agents
Source: Coding guidelines