diff --git a/src/main/java/ai/chalk/client/ChalkClient.java b/src/main/java/ai/chalk/client/ChalkClient.java index cf60165..42be569 100644 --- a/src/main/java/ai/chalk/client/ChalkClient.java +++ b/src/main/java/ai/chalk/client/ChalkClient.java @@ -259,6 +259,38 @@ default OnlineQueryMultiResult onlineQueryMulti(OnlineQueryParamsComplete... par */ UploadFeaturesResult uploadFeatures(UploadFeaturesParams params) throws ChalkException; + /** + * DeleteFeatures targets feature observation values for deletion and performs + * deletion online and offline. + *

See {@link DeleteFeaturesParams} for more details on the parameters. + * + *

+ * Example usage: + *

+     *         {@code
+     *         DeleteFeaturesParams params = DeleteFeaturesParams.builder()
+     *             .withNamespace("user")
+     *             .withFeatures(List.of("name", "email", "age"))
+     *             .withPrimaryKeys(List.of("1", "2", "3"))
+     *             .build();
+     *         DeleteFeaturesResult res = client.deleteFeatures(params);
+     *         if (res.getErrors() != null && res.getErrors().size() > 0) {
+     *             throw new Exception("delete failed");
+     *         }
+     *
+     *     
+ *

+ * + * @return {@link DeleteFeaturesResult}, which holds any errors that occurred during the + * request. Deletion of a feature may partially succeed. + */ + default DeleteFeaturesResult deleteFeatures(DeleteFeaturesParams params) throws ChalkException { + throw new ClientException( + "deleteFeatures is only supported by the HTTP client. Build one with " + + "ChalkClient.builder().build() or ChalkClient.create()." + ); + } + diff --git a/src/main/java/ai/chalk/client/ChalkClientImpl.java b/src/main/java/ai/chalk/client/ChalkClientImpl.java index 1097778..bd4ed38 100644 --- a/src/main/java/ai/chalk/client/ChalkClientImpl.java +++ b/src/main/java/ai/chalk/client/ChalkClientImpl.java @@ -10,10 +10,13 @@ import ai.chalk.internal.config.models.ProjectToken; import ai.chalk.internal.config.models.SourcedConfig; import ai.chalk.internal.request.RequestHandler; +import ai.chalk.internal.request.models.FeatureObservationDeletionRequest; import ai.chalk.internal.request.models.OnlineQueryBulkResponse; import ai.chalk.internal.request.models.PingRequest; import ai.chalk.internal.request.models.PingResponse; import ai.chalk.internal.request.models.SendRequestParams; +import ai.chalk.models.DeleteFeaturesParams; +import ai.chalk.models.DeleteFeaturesResult; import ai.chalk.models.OnlineQueryParamsComplete; import ai.chalk.models.OnlineQueryResult; import ai.chalk.models.QueryMeta; @@ -37,6 +40,7 @@ public class ChalkClientImpl implements ChalkClient { private final SourcedConfig clientId; private final SourcedConfig environmentId; private final SourcedConfig clientSecret; + private final String branch; private final RequestHandler handler; private static final System.Logger logger = System.getLogger(ChalkClientImpl.class.getName()); @@ -54,6 +58,7 @@ public ChalkClientImpl(BuilderImpl config) throws ChalkException { if (branch != null && deploymentTag != null) { throw new ClientException("Cannot set both branch and deploymentTag"); } + this.branch = branch; this.handler = new RequestHandler( config.getHttpClient(), @@ -261,6 +266,35 @@ public UploadFeaturesResult uploadFeatures(UploadFeaturesParams params) throws C return this.handler.deserializeResponseBody(response.body(), UploadFeaturesResult.class); } + @Override + public DeleteFeaturesResult deleteFeatures(DeleteFeaturesParams params) throws ChalkException { + String branch = params.getBranch() != null ? params.getBranch() : this.branch; + if (branch != null) { + throw new ClientException( + "Feature deletion is not currently supported for branch deployments. " + + "Client is currently connected to the branch '" + branch + "'." + ); + } + + SendRequestParams request = new SendRequestParams.Builder(null) + .path("/v1/features/rows") + .body(new FeatureObservationDeletionRequest( + params.getNamespace(), + params.getFeatures(), + params.getTags(), + params.getPrimaryKeys(), + params.isRetainOffline(), + params.isRetainOnline() + )) + .method("DELETE") + .environmentOverride(params.getEnvironmentId()) + .isEngineRequest(false) + .build(); + + HttpResponse response = this.handler.sendRequest(request); + return this.handler.deserializeResponseBody(response.body(), DeleteFeaturesResult.class); + } + private ResolvedConfig resolveConfig(BuilderImpl builder) throws ClientException { ProjectToken chalkYamlConfig = new ProjectToken(); String projectRoot; diff --git a/src/main/java/ai/chalk/internal/request/models/FeatureObservationDeletionRequest.java b/src/main/java/ai/chalk/internal/request/models/FeatureObservationDeletionRequest.java new file mode 100644 index 0000000..bc11df1 --- /dev/null +++ b/src/main/java/ai/chalk/internal/request/models/FeatureObservationDeletionRequest.java @@ -0,0 +1,13 @@ +package ai.chalk.internal.request.models; + +import java.util.List; + +public record FeatureObservationDeletionRequest( + String namespace, + List features, + List tags, + List primaryKeys, + boolean retainOffline, + boolean retainOnline +) { +} diff --git a/src/main/java/ai/chalk/models/DeleteFeaturesParams.java b/src/main/java/ai/chalk/models/DeleteFeaturesParams.java new file mode 100644 index 0000000..59bf1f2 --- /dev/null +++ b/src/main/java/ai/chalk/models/DeleteFeaturesParams.java @@ -0,0 +1,128 @@ +package ai.chalk.models; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.annotation.Nullable; +import java.util.List; + + +/** + * Targets feature observation values for deletion. Mirrors the parameters of + * {@code ChalkClient.delete_features} in the Python client. + */ +@AllArgsConstructor @Getter +public class DeleteFeaturesParams { + /** + * The namespace in which the target features reside. + */ + private final String namespace; + /** + * An optional list of the feature names of the features that should be deleted + * for the targeted primary keys. Not specifying this and not specifying {@code tags} + * will result in all features being targeted for deletion for the specified primary keys. + * Note that this parameter and {@code tags} are mutually exclusive. + */ + @Nullable + private final List features; + /** + * An optional list of tags that specify features that should be targeted for deletion. + * If a feature has a tag in this list, its observations for the primary keys you listed + * will be targeted for deletion. Not specifying this and not specifying {@code features} + * will result in all features being targeted for deletion for the specified primary keys. + * Note that this parameter and {@code features} are mutually exclusive. + */ + @Nullable + private final List tags; + /** + * The primary keys of the observations that should be targeted for deletion. + */ + private final List primaryKeys; + @Nullable + private final String environmentId; + /** + * Feature deletion is not currently supported for branch deployments. + */ + @Nullable + private final String branch; + /** + * If true, the given observations will not be dropped from the offline store. + */ + private final boolean retainOffline; + /** + * If true, the given observations will not be dropped from the online store. + */ + private final boolean retainOnline; + + @AllArgsConstructor + @NoArgsConstructor + public static class Builder { + protected String namespace; + protected List features; + protected List tags; + protected List primaryKeys; + protected String environmentId; + protected String branch; + protected boolean retainOffline = false; + protected boolean retainOnline = false; + + public DeleteFeaturesParams build() { + return new DeleteFeaturesParams( + this.namespace, + this.features, + this.tags, + this.primaryKeys, + this.environmentId, + this.branch, + this.retainOffline, + this.retainOnline); + } + + public Builder withNamespace(String namespace) { + this.namespace = namespace; + return this; + } + + public Builder withFeatures(List features) { + this.features = features; + return this; + } + + public Builder withTags(List tags) { + this.tags = tags; + return this; + } + + public Builder withPrimaryKeys(List primaryKeys) { + this.primaryKeys = primaryKeys; + return this; + } + + public Builder withEnvironmentId(String environmentId) { + this.environmentId = environmentId; + return this; + } + + public Builder withBranch(String branch) { + this.branch = branch; + return this; + } + + /** Do not drop the given observations from the offline store. Defaults to {@code false}. */ + public Builder withRetainOffline(boolean retainOffline) { + this.retainOffline = retainOffline; + return this; + } + + /** Do not drop the given observations from the online store. Defaults to {@code false}. */ + public Builder withRetainOnline(boolean retainOnline) { + this.retainOnline = retainOnline; + return this; + } + } + + public static Builder builder() { + return new Builder(); + } +} diff --git a/src/main/java/ai/chalk/models/DeleteFeaturesResult.java b/src/main/java/ai/chalk/models/DeleteFeaturesResult.java new file mode 100644 index 0000000..26e1bea --- /dev/null +++ b/src/main/java/ai/chalk/models/DeleteFeaturesResult.java @@ -0,0 +1,20 @@ +package ai.chalk.models; + + +import ai.chalk.exceptions.ServerError; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Holds any errors that occurred during the deletion request. + * Deletion of a feature may partially succeed. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DeleteFeaturesResult { + private List errors; +} diff --git a/src/test/java/ai/chalk/client/TestDeleteFeatures.java b/src/test/java/ai/chalk/client/TestDeleteFeatures.java new file mode 100644 index 0000000..3766060 --- /dev/null +++ b/src/test/java/ai/chalk/client/TestDeleteFeatures.java @@ -0,0 +1,119 @@ +package ai.chalk.client; + +import ai.chalk.exceptions.ClientException; +import ai.chalk.models.DeleteFeaturesParams; +import ai.chalk.models.DeleteFeaturesResult; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TestDeleteFeatures { + private static final String ENVIRONMENT_ID = "test-environment"; + + @Test + public void testHttpDeleteFeatures() throws Exception { + AtomicReference method = new AtomicReference<>(); + AtomicReference body = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + AtomicReference environmentHeader = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/v1/oauth/token", exchange -> respondJson( + exchange, + """ + { + "access_token": "test-token", + "expires_in": 3600, + "primary_environment": "%s", + "engines": {} + } + """.formatted(ENVIRONMENT_ID) + )); + server.createContext("/v1/features/rows", exchange -> { + method.set(exchange.getRequestMethod()); + body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + environmentHeader.set(exchange.getRequestHeaders().getFirst("X-Chalk-Env-Id")); + respondJson(exchange, """ + {"errors": [{"code": "INTERNAL_SERVER_ERROR", "category": "REQUEST", "message": "partial failure"}]} + """); + }); + server.start(); + + String serverUrl = "http://localhost:" + server.getAddress().getPort(); + try (ChalkClient client = ChalkClient.builder() + .withApiServer(serverUrl) + .withClientId("test-client") + .withClientSecret("test-secret") + .withEnvironmentId(ENVIRONMENT_ID) + .build()) { + DeleteFeaturesResult result = client.deleteFeatures(DeleteFeaturesParams.builder() + .withNamespace("user") + .withFeatures(List.of("name", "email")) + .withPrimaryKeys(List.of("1", "2")) + .build()); + + assertEquals("DELETE", method.get()); + assertEquals( + "{\"namespace\":\"user\",\"features\":[\"name\",\"email\"],\"tags\":null," + + "\"primary_keys\":[\"1\",\"2\"],\"retain_offline\":false,\"retain_online\":false}", + body.get() + ); + assertEquals("Bearer test-token", authorization.get()); + assertEquals(ENVIRONMENT_ID, environmentHeader.get()); + assertEquals(1, result.getErrors().size()); + assertEquals("partial failure", result.getErrors().get(0).getMessage()); + } finally { + server.stop(0); + } + } + + @Test + public void testHttpDeleteFeaturesRejectsBranch() throws Exception { + AtomicReference deleteCalled = new AtomicReference<>(false); + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/v1/features/rows", exchange -> { + deleteCalled.set(true); + respondJson(exchange, "{\"errors\": []}"); + }); + server.start(); + + String serverUrl = "http://localhost:" + server.getAddress().getPort(); + try (ChalkClient client = ChalkClient.builder() + .withApiServer(serverUrl) + .withClientId("test-client") + .withClientSecret("test-secret") + .withEnvironmentId(ENVIRONMENT_ID) + .withBranch("my-branch") + .build()) { + ClientException e = assertThrows(ClientException.class, () -> client.deleteFeatures( + DeleteFeaturesParams.builder() + .withNamespace("user") + .withPrimaryKeys(List.of("1")) + .build() + )); + assertTrue(e.getMessage().contains("my-branch")); + assertFalse(deleteCalled.get()); + } finally { + server.stop(0); + } + } + + private static void respondJson(HttpExchange exchange, String body) throws IOException { + byte[] response = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } +} diff --git a/src/test/java/ai/chalk/client/TestGrpcClient.java b/src/test/java/ai/chalk/client/TestGrpcClient.java index 7cf4e79..4a9ea0a 100644 --- a/src/test/java/ai/chalk/client/TestGrpcClient.java +++ b/src/test/java/ai/chalk/client/TestGrpcClient.java @@ -143,7 +143,6 @@ public void testOnlineQueryOptionalParamsSanity() throws Exception { var params = OnlineQueryParams.builder() .withInput(FraudTemplateFeatures.user.id, userIds) .withQueryName("chalk-java::testOnlineQueryOptionalParamsSanity") - .withQueryNameVersion("1.0.0") .withOutputs(FraudTemplateFeatures.user.socure_score) // TODO: CHA-4791 // .withIncludeMeta(true)