Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/main/java/ai/chalk/client/ChalkClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p> See {@link DeleteFeaturesParams} for more details on the parameters.
*
* <p>
* Example usage:
* <pre>
* {@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");
* }
*
* </pre>
* </p>
*
* @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()."
);
}




Expand Down
34 changes: 34 additions & 0 deletions src/main/java/ai/chalk/client/ChalkClientImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
Expand All @@ -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(),
Expand Down Expand Up @@ -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<byte[]> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ai.chalk.internal.request.models;

import java.util.List;

public record FeatureObservationDeletionRequest(
String namespace,
List<String> features,
List<String> tags,
List<String> primaryKeys,
boolean retainOffline,
boolean retainOnline
) {
}
128 changes: 128 additions & 0 deletions src/main/java/ai/chalk/models/DeleteFeaturesParams.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> tags;
/**
* The primary keys of the observations that should be targeted for deletion.
*/
private final List<String> 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<String> features;
protected List<String> tags;
protected List<String> 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<String> features) {
this.features = features;
return this;
}

public Builder withTags(List<String> tags) {
this.tags = tags;
return this;
}

public Builder withPrimaryKeys(List<String> 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();
}
}
20 changes: 20 additions & 0 deletions src/main/java/ai/chalk/models/DeleteFeaturesResult.java
Original file line number Diff line number Diff line change
@@ -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<ServerError> errors;
}
119 changes: 119 additions & 0 deletions src/test/java/ai/chalk/client/TestDeleteFeatures.java
Original file line number Diff line number Diff line change
@@ -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<String> method = new AtomicReference<>();
AtomicReference<String> body = new AtomicReference<>();
AtomicReference<String> authorization = new AtomicReference<>();
AtomicReference<String> 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<Boolean> 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();
}
}
1 change: 0 additions & 1 deletion src/test/java/ai/chalk/client/TestGrpcClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading