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)