Skip to content

Commit 96ca326

Browse files
committed
Add cascade delete from provider to models via Reconciler
- Create AiProviderReconciler using Halo's Finalizer pattern - On provider deletion, automatically delete all associated AiModel entries - Remove redundant "has associated models" guard from console API - Add unit tests for cascade delete behavior
1 parent bc9e070 commit 96ca326

4 files changed

Lines changed: 213 additions & 26 deletions

File tree

app/src/main/java/run/halo/aifoundation/endpoint/ProviderConsoleEndpoint.java

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
import run.halo.app.extension.ListOptions;
3232
import run.halo.app.extension.Metadata;
3333
import run.halo.app.extension.ReactiveExtensionClient;
34-
import run.halo.app.extension.index.query.Queries;
3534

3635
@Slf4j
3736
@Component
@@ -204,27 +203,12 @@ private Mono<AiProvider> validateAndSaveProvider(AiProvider provider, String exi
204203

205204
private Mono<ServerResponse> deleteProvider(ServerRequest request) {
206205
var name = request.pathVariable("name");
207-
var listOptions = ListOptions.builder()
208-
.fieldQuery(Queries.equal("spec.providerName", name))
209-
.build();
210-
return client.listAll(AiModel.class, listOptions, null)
211-
.hasElements()
212-
.flatMap(hasModels -> {
213-
if (Boolean.TRUE.equals(hasModels)) {
214-
return Mono.error(
215-
new ResponseStatusException(
216-
HttpStatus.BAD_REQUEST,
217-
"Cannot delete provider '" + name
218-
+ "': it has associated AI models. "
219-
+ "Please delete all models first."));
220-
}
221-
return client.fetch(AiProvider.class, name)
222-
.switchIfEmpty(Mono.error(
223-
new ResponseStatusException(HttpStatus.NOT_FOUND,
224-
"Provider not found: " + name)))
225-
.flatMap(client::delete)
226-
.then(ServerResponse.noContent().build());
227-
});
206+
return client.fetch(AiProvider.class, name)
207+
.switchIfEmpty(Mono.error(
208+
new ResponseStatusException(HttpStatus.NOT_FOUND,
209+
"Provider not found: " + name)))
210+
.flatMap(client::delete)
211+
.then(ServerResponse.noContent().build());
228212
}
229213

230214
private Mono<ServerResponse> discoverModels(ServerRequest request) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package run.halo.aifoundation.provider;
2+
3+
import static run.halo.app.extension.ExtensionUtil.addFinalizers;
4+
import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
5+
6+
import java.util.Set;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.data.domain.Sort;
9+
import org.springframework.stereotype.Component;
10+
import run.halo.aifoundation.extension.AiModel;
11+
import run.halo.aifoundation.extension.AiProvider;
12+
import run.halo.app.extension.ExtensionClient;
13+
import run.halo.app.extension.ExtensionUtil;
14+
import run.halo.app.extension.ListOptions;
15+
import run.halo.app.extension.controller.Controller;
16+
import run.halo.app.extension.controller.ControllerBuilder;
17+
import run.halo.app.extension.controller.Reconciler;
18+
import run.halo.app.extension.index.query.Queries;
19+
20+
@Slf4j
21+
@Component
22+
public class AiProviderReconciler implements Reconciler<Reconciler.Request> {
23+
24+
static final String FINALIZER_NAME = "aifoundation.halo.run/cascade-delete-models";
25+
26+
private final ExtensionClient client;
27+
28+
public AiProviderReconciler(ExtensionClient client) {
29+
this.client = client;
30+
}
31+
32+
@Override
33+
public Result reconcile(Request request) {
34+
client.fetch(AiProvider.class, request.name()).ifPresent(provider -> {
35+
if (ExtensionUtil.isDeleted(provider)) {
36+
if (removeFinalizers(provider.getMetadata(), Set.of(FINALIZER_NAME))) {
37+
deleteAssociatedModels(request.name());
38+
client.update(provider);
39+
log.info("Cascade deleted models for provider: {}", request.name());
40+
}
41+
return;
42+
}
43+
addFinalizers(provider.getMetadata(), Set.of(FINALIZER_NAME));
44+
client.update(provider);
45+
});
46+
return Result.doNotRetry();
47+
}
48+
49+
private void deleteAssociatedModels(String providerName) {
50+
var listOptions = ListOptions.builder()
51+
.fieldQuery(Queries.equal("spec.providerName", providerName))
52+
.build();
53+
var models = client.listAll(AiModel.class, listOptions, Sort.unsorted());
54+
for (AiModel model : models) {
55+
try {
56+
client.delete(model);
57+
log.debug("Deleted model {} for provider {}",
58+
model.getMetadata().getName(), providerName);
59+
} catch (Exception e) {
60+
log.error("Failed to delete model {} for provider {}: {}",
61+
model.getMetadata().getName(), providerName, e.getMessage());
62+
throw e;
63+
}
64+
}
65+
}
66+
67+
@Override
68+
public Controller setupWith(ControllerBuilder builder) {
69+
return builder.extension(new AiProvider()).build();
70+
}
71+
}

app/src/test/java/run/halo/aifoundation/endpoint/ProviderConsoleEndpointTest.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,14 @@ void delete_noAssociatedModels_returns204() {
133133
}
134134

135135
@Test
136-
void delete_withAssociatedModels_returns400() {
137-
var model = model("openai-prod", "gpt-4");
138-
when(client.listAll(eq(AiModel.class), any(), isNull())).thenReturn(Flux.just(model));
136+
void delete_withAssociatedModels_returns204() {
137+
var p = provider("openai-prod", "openai");
138+
when(client.fetch(AiProvider.class, "openai-prod")).thenReturn(Mono.just(p));
139+
when(client.delete(p)).thenReturn(Mono.just(p));
139140

140141
webTestClient.delete().uri("/providers/openai-prod")
141142
.exchange()
142-
.expectStatus().isBadRequest();
143+
.expectStatus().isNoContent();
143144
}
144145

145146
@Test
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package run.halo.aifoundation.provider;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.ArgumentMatchers.eq;
6+
import static org.mockito.Mockito.mock;
7+
import static org.mockito.Mockito.never;
8+
import static org.mockito.Mockito.verify;
9+
import static org.mockito.Mockito.when;
10+
11+
import java.time.Instant;
12+
import java.util.List;
13+
import java.util.Optional;
14+
import java.util.Set;
15+
import org.junit.jupiter.api.Test;
16+
import org.springframework.data.domain.Sort;
17+
import run.halo.aifoundation.extension.AiModel;
18+
import run.halo.aifoundation.extension.AiProvider;
19+
import run.halo.app.extension.ExtensionClient;
20+
import run.halo.app.extension.ListOptions;
21+
import run.halo.app.extension.Metadata;
22+
import run.halo.app.extension.controller.ControllerBuilder;
23+
import run.halo.app.extension.controller.Reconciler;
24+
25+
class AiProviderReconcilerTest {
26+
27+
private final ExtensionClient client = mock(ExtensionClient.class);
28+
private final AiProviderReconciler reconciler = new AiProviderReconciler(client);
29+
30+
@Test
31+
void reconcile_deletedProvider_deletesAssociatedModels() {
32+
var provider = deletedProvider("openai-prod");
33+
provider.getMetadata().setFinalizers(Set.of(AiProviderReconciler.FINALIZER_NAME));
34+
35+
var model = model("openai-prod-gpt-4", "openai-prod", "gpt-4");
36+
37+
when(client.fetch(AiProvider.class, "openai-prod")).thenReturn(Optional.of(provider));
38+
when(client.listAll(eq(AiModel.class), any(ListOptions.class), any(Sort.class)))
39+
.thenReturn(List.of(model));
40+
41+
var result = reconciler.reconcile(new Reconciler.Request("openai-prod"));
42+
43+
assertThat(result.reEnqueue()).isFalse();
44+
verify(client).delete(model);
45+
verify(client).update(provider);
46+
assertThat(provider.getMetadata().getFinalizers()).isEmpty();
47+
}
48+
49+
@Test
50+
void reconcile_deletedProvider_noModels_completesGracefully() {
51+
var provider = deletedProvider("openai-prod");
52+
provider.getMetadata().setFinalizers(Set.of(AiProviderReconciler.FINALIZER_NAME));
53+
54+
when(client.fetch(AiProvider.class, "openai-prod")).thenReturn(Optional.of(provider));
55+
when(client.listAll(eq(AiModel.class), any(ListOptions.class), any(Sort.class)))
56+
.thenReturn(List.of());
57+
58+
var result = reconciler.reconcile(new Reconciler.Request("openai-prod"));
59+
60+
assertThat(result.reEnqueue()).isFalse();
61+
verify(client, never()).delete(any(AiModel.class));
62+
verify(client).update(provider);
63+
assertThat(provider.getMetadata().getFinalizers()).isEmpty();
64+
}
65+
66+
@Test
67+
void reconcile_existingProvider_addsFinalizer() {
68+
var provider = provider("openai-prod");
69+
provider.getMetadata().setFinalizers(null);
70+
71+
when(client.fetch(AiProvider.class, "openai-prod")).thenReturn(Optional.of(provider));
72+
73+
var result = reconciler.reconcile(new Reconciler.Request("openai-prod"));
74+
75+
assertThat(result.reEnqueue()).isFalse();
76+
verify(client, never()).delete(any(AiModel.class));
77+
verify(client).update(provider);
78+
assertThat(provider.getMetadata().getFinalizers())
79+
.contains(AiProviderReconciler.FINALIZER_NAME);
80+
}
81+
82+
@Test
83+
void reconcile_providerNotFound_returnsDoNotRetry() {
84+
when(client.fetch(AiProvider.class, "missing")).thenReturn(Optional.empty());
85+
86+
var result = reconciler.reconcile(new Reconciler.Request("missing"));
87+
88+
assertThat(result.reEnqueue()).isFalse();
89+
verify(client, never()).delete(any());
90+
verify(client, never()).update(any());
91+
}
92+
93+
@Test
94+
void setupWith_buildsController() {
95+
var builder = new ControllerBuilder(reconciler, client);
96+
97+
var controller = reconciler.setupWith(builder);
98+
99+
assertThat(controller).isNotNull();
100+
}
101+
102+
private AiProvider provider(String name) {
103+
var p = new AiProvider();
104+
var metadata = new Metadata();
105+
metadata.setName(name);
106+
p.setMetadata(metadata);
107+
var spec = new AiProvider.AiProviderSpec();
108+
spec.setDisplayName(name);
109+
spec.setProviderType("openai");
110+
p.setSpec(spec);
111+
return p;
112+
}
113+
114+
private AiProvider deletedProvider(String name) {
115+
var p = provider(name);
116+
p.getMetadata().setDeletionTimestamp(Instant.now());
117+
return p;
118+
}
119+
120+
private AiModel model(String name, String providerName, String modelId) {
121+
var m = new AiModel();
122+
var metadata = new Metadata();
123+
metadata.setName(name);
124+
m.setMetadata(metadata);
125+
var spec = new AiModel.AiModelSpec();
126+
spec.setProviderName(providerName);
127+
spec.setModelId(modelId);
128+
m.setSpec(spec);
129+
return m;
130+
}
131+
}

0 commit comments

Comments
 (0)