Skip to content
Open
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
21 changes: 21 additions & 0 deletions .github/workflows/spark-search.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ on:
- "Makefile"
- "docker/**"
- "integration-tests/**"
- "lance-spark-base_2.12/src/main/java/org/lance/spark/LanceDataset.java"
- "lance-spark-base_2.12/src/main/java/org/lance/spark/search/**"
- "lance-spark-base_2.12/src/main/java/org/lance/spark/write/*ColumnsBackfill*.java"
- "lance-spark-base_2.12/src/main/scala/org/apache/spark/sql/execution/datasources/v2/*ColumnsBackfillExec.scala"
- "lance-spark-base_2.12/src/main/scala/org/lance/spark/search/**"
- "lance-spark-base_2.12/src/test/java/org/lance/spark/search/**"
- "lance-spark-*/src/main/scala/org/lance/spark/extensions/**"
Expand Down Expand Up @@ -71,6 +74,11 @@ env:
SEARCH_PYTEST_CMD: >-
pytest /home/lance/tests/test_lance_spark.py::TestDQLSearchTableFunctions
-v --timeout=180
MANAGED_BACKFILL_PYTEST_CMD: >-
pytest
/home/lance/tests/test_lance_spark.py::TestDMLAddColumn::test_add_column_from_view_on_rest
/home/lance/tests/test_lance_spark.py::TestDMLUpdateColumn::test_update_column_from_view_on_rest
-v --timeout=180

jobs:
search-docker-test:
Expand Down Expand Up @@ -153,3 +161,16 @@ jobs:
LANCE_SPARK_REST_DIR_PORT="${LANCE_SPARK_REST_DIR_PORT}" \
DOCKER_RUN_ARGS="${DOCKER_RUN_ARGS}" \
PYTEST_CMD="${SEARCH_PYTEST_CMD}"
# Keep managed versioning isolated from the existing REST search test server.
- name: Run managed REST directory namespace backfill tests

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new managed test step is not triggered by changes to the backfill implementation it protects. The workflow's pull_request.paths remains search-focused; LanceDataset.java, AddColumnsBackfillBatchWrite.java, and UpdateColumnsBackfillBatchWrite.java match none of its entries. The general Spark workflow does run for those paths, but its default backends skip these requires_rest tests, so a production-only regression receives no managed backfill run.

Please add the relevant catalog/backfill production paths to this workflow's trigger, or move the managed test into a workflow whose PR trigger already covers them.

Reproducer
from fnmatch import fnmatchcase
patterns = [
    '.github/workflows/spark-search.yml', 'Makefile', 'docker/**',
    'integration-tests/**',
    'lance-spark-base_2.12/src/main/java/org/lance/spark/search/**',
    'lance-spark-base_2.12/src/main/scala/org/lance/spark/search/**',
    'lance-spark-base_2.12/src/test/java/org/lance/spark/search/**',
    'lance-spark-*/src/main/scala/org/lance/spark/extensions/**',
    'pom.xml', '*/pom.xml',
]
for path in [
    'lance-spark-base_2.12/src/main/java/org/lance/spark/LanceDataset.java',
    'lance-spark-base_2.12/src/main/java/org/lance/spark/write/AddColumnsBackfillBatchWrite.java',
    'lance-spark-base_2.12/src/main/java/org/lance/spark/write/UpdateColumnsBackfillBatchWrite.java',
]:
    print(path, any(fnmatchcase(path, pattern) for pattern in patterns))

All three results were False.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Java paths are now covered, but the managed test still does not run for AddColumnsBackfillExec.scala or UpdateColumnsBackfillExec.scala. Those executors propagate managedVersioning into the temporary LanceDataset, so changing that propagation can reintroduce the failure without triggering this workflow.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c63ca60: the workflow path filter now matches both AddColumnsBackfillExec.scala and UpdateColumnsBackfillExec.scala, so changes to either managed-versioning propagation path select the managed REST backfill test.

if: ${{ steps.rest.outputs.start_rest_dir == 'true' }}
run: |
make docker-test \
SPARK_VERSION=${SPARK_VERSION} \
SCALA_VERSION=${SCALA_VERSION} \
TEST_BACKENDS=rest-dir \
LANCE_SPARK_START_REST_DIR=1 \
LANCE_SPARK_REST_DIR_ROOT=/home/lance/rest-managed-data \
LANCE_SPARK_REST_DIR_PORT=10025 \
LANCE_SPARK_REST_DIR_MANAGED_VERSIONING=1 \
PYTEST_CMD="${MANAGED_BACKFILL_PYTEST_CMD}"
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ docker-test:
$(if $(LANCE_SPARK_START_REST_DIR),-e LANCE_SPARK_START_REST_DIR=$(LANCE_SPARK_START_REST_DIR)) \
$(if $(LANCE_SPARK_REST_DIR_ROOT),-e LANCE_SPARK_REST_DIR_ROOT=$(LANCE_SPARK_REST_DIR_ROOT)) \
$(if $(LANCE_SPARK_REST_DIR_PORT),-e LANCE_SPARK_REST_DIR_PORT=$(LANCE_SPARK_REST_DIR_PORT)) \
$(if $(LANCE_SPARK_REST_DIR_MANAGED_VERSIONING),-e LANCE_SPARK_REST_DIR_MANAGED_VERSIONING=$(LANCE_SPARK_REST_DIR_MANAGED_VERSIONING)) \
$(if $(TEST_BACKENDS),-e TEST_BACKENDS=$(TEST_BACKENDS)) \
$(if $(LANCE_FTS_FORMAT_VERSION),-e LANCE_FTS_FORMAT_VERSION=$(LANCE_FTS_FORMAT_VERSION)) \
$(if $(AWS_REGION),-e AWS_REGION=$(AWS_REGION)) \
Expand Down
178 changes: 172 additions & 6 deletions integration-tests/LanceRestDirNamespaceServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,195 @@
* limitations under the License.
*/

import org.lance.namespace.RestAdapter;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import org.lance.namespace.RestAdapter;

public final class LanceRestDirNamespaceServer {
private static final String CREATE_TABLE_VERSION_COUNT_PATH =
"/__lance_test/create_table_version_count";

private LanceRestDirNamespaceServer() {}

public static void main(String[] args) throws Exception {
String root = args.length > 0 ? args[0] : "/home/lance/rest-data";
String host = args.length > 1 ? args[1] : "127.0.0.1";
int port = args.length > 2 ? Integer.parseInt(args[2]) : 10024;
boolean managedVersioning = args.length > 3 && Boolean.parseBoolean(args[3]);

Map<String, String> backendConfig = new HashMap<>();
backendConfig.put("root", root);
if (managedVersioning) {
// DirectoryNamespace uses manifest storage to track versions managed by the namespace.
backendConfig.put("manifest_enabled", "true");
backendConfig.put("table_version_tracking_enabled", "true");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This configuration advertises managed versioning, but the directory backend still accepts and exposes direct dataset commits, so the existing result-only tests are not sensitive to whether the backfill used the namespace commit API. Against the exact lance-core 11.0.0-beta.10 dependency, I established a namespace-owned version, deliberately added a field directly through Dataset without namespaceClientManagedVersioning(true), then reopened through the namespace client; it returned version 3 with both fields. The selected ADD/UPDATE tests can therefore pass if the production managed-commit branch regresses.

Please add an observable namespace-commit assertion—for example, a test-server counter/log or equivalent signal that advances only through create_table_version—and a negative control that fails on a direct commit.

Reproducer

Run in JShell with the Maven test classpath and the JNI temp directory on an executable filesystem:

backendConfig.put("root", testRoot);
backendConfig.put("manifest_enabled", "true");
backendConfig.put("table_version_tracking_enabled", "true");
var adapter = new RestAdapter("dir", backendConfig, "127.0.0.1", 0);
adapter.start();
clientConfig.put("uri", "http://127.0.0.1:" + adapter.getPort());
var client = new RestNamespace();
client.initialize(clientConfig, allocator);
client.createNamespace(new CreateNamespaceRequest().id(List.of("default")).mode("create"));
var tableId = List.of("default", "gate_table");
var declared = client.declareTable(new DeclareTableRequest().id(tableId));
var created = Dataset.create(allocator, declared.getLocation(), schema,
    new WriteParams.Builder().withMode(WriteParams.WriteMode.CREATE).build());
var updateMap = UpdateMap.builder()
    .updates(Map.of("registered", "true")).replace(false).build();
var txn = new Transaction.Builder().readVersion(created.version())
    .operation(UpdateConfig.builder().configUpdates(updateMap).build()).build();
var registered = new CommitBuilder(created)
    .namespaceClient(client).tableId(tableId)
    .namespaceClientManagedVersioning(true).execute(txn);
registered.addColumns(List.of(extra)); // deliberate direct commit
var opened = Dataset.open().allocator(allocator)
    .namespaceClient(client).tableId(tableId).build();
System.out.println(opened.version());
System.out.println(opened.getSchema().getFields().stream()
    .map(Field::getName).toList());

Observed:

3
[id, extra]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 75c318c: the managed proxy now counts successful create-table-version requests, and the revised tests verify that a namespace commit increments the counter while a deliberate direct commit does not.\n\n

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 75c318c: the managed proxy now counts successful create-table-version requests, and the revised tests verify that a namespace commit increments the counter while a deliberate direct commit does not.

}

RestAdapter adapter = new RestAdapter("dir", backendConfig, host, port);
Runtime.getRuntime().addShutdownHook(new Thread(adapter::close));
RestAdapter adapter = new RestAdapter("dir", backendConfig, host, managedVersioning ? 0 : port);
adapter.start();

ManagedVersioningProxy proxy =
managedVersioning ? new ManagedVersioningProxy(host, port, adapter.getPort()) : null;
if (proxy != null) {
proxy.start();
}

Runtime.getRuntime()
.addShutdownHook(
new Thread(
() -> {
if (proxy != null) {
proxy.close();
}
adapter.close();
}));
System.out.printf(
"Lance REST directory namespace listening on http://%s:%d with root %s%n",
host, adapter.getPort(), root);
"Lance REST directory namespace listening on http://%s:%d with root %s "
+ "(managed versioning: %s)%n",
host, managedVersioning ? port : adapter.getPort(), root, managedVersioning);
new CountDownLatch(1).await();
}

/**
* Proxies the managed REST test server so tests can observe successful namespace-owned version
* commits. Direct Dataset commits bypass this proxy endpoint and therefore do not increment the
* counter.
*/
private static final class ManagedVersioningProxy implements AutoCloseable {
private static final Set<String> HOP_BY_HOP_HEADERS =
Set.of("connection", "content-length", "expect", "host", "transfer-encoding", "upgrade");

private final String backendUri;
private final HttpClient client = HttpClient.newHttpClient();
private final AtomicLong createTableVersionCount = new AtomicLong();
private final ExecutorService executor = Executors.newCachedThreadPool();
private final HttpServer server;

private ManagedVersioningProxy(String host, int port, int backendPort) throws IOException {
this.backendUri = "http://" + host + ":" + backendPort;
this.server = HttpServer.create(new InetSocketAddress(host, port), 0);
this.server.createContext("/", this::handle);
this.server.setExecutor(executor);
}

private void start() {
server.start();
}

private void handle(HttpExchange exchange) throws IOException {
try {
if (CREATE_TABLE_VERSION_COUNT_PATH.equals(exchange.getRequestURI().getPath())) {
handleCreateTableVersionCount(exchange);
return;
}

byte[] requestBody = exchange.getRequestBody().readAllBytes();
URI target = URI.create(backendUri + exchange.getRequestURI().toASCIIString());
HttpRequest.Builder request =
HttpRequest.newBuilder(target)
.method(
exchange.getRequestMethod(),
HttpRequest.BodyPublishers.ofByteArray(requestBody));
exchange
.getRequestHeaders()
.forEach(
(name, values) -> {
if (!isHopByHopHeader(name)) {
values.forEach(value -> request.header(name, value));
}
});

HttpResponse<byte[]> response;
try {
response = client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
} catch (IOException e) {
sendResponse(
exchange,
502,
("REST proxy failed: " + errorMessage(e)).getBytes(StandardCharsets.UTF_8));
return;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
sendResponse(exchange, 502, "REST proxy interrupted".getBytes(StandardCharsets.UTF_8));
return;
}

response
.headers()
.map()
.forEach(
(name, values) -> {
if (!isHopByHopHeader(name)) {
values.forEach(value -> exchange.getResponseHeaders().add(name, value));
}
});
if (isSuccessfulCreateTableVersion(exchange, response.statusCode())) {
createTableVersionCount.incrementAndGet();
}
sendResponse(exchange, response.statusCode(), response.body());
} catch (RuntimeException e) {
sendResponse(
exchange,
502,
("REST proxy failed: " + errorMessage(e)).getBytes(StandardCharsets.UTF_8));
} finally {
exchange.close();
}
}

private void handleCreateTableVersionCount(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 405, new byte[0]);
return;
}
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
sendResponse(
exchange,
200,
Long.toString(createTableVersionCount.get()).getBytes(StandardCharsets.UTF_8));
}

private static boolean isSuccessfulCreateTableVersion(HttpExchange exchange, int statusCode) {
String path = exchange.getRequestURI().getPath();
return "POST".equals(exchange.getRequestMethod())
&& path.startsWith("/v1/table/")
&& path.endsWith("/version/create")
&& statusCode >= 200
&& statusCode < 300;
}

private static boolean isHopByHopHeader(String name) {
return HOP_BY_HOP_HEADERS.contains(name.toLowerCase());
}

private static String errorMessage(Exception error) {
return error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage();
}

private static void sendResponse(HttpExchange exchange, int statusCode, byte[] body)
throws IOException {
exchange.sendResponseHeaders(statusCode, body.length);
exchange.getResponseBody().write(body);
}

@Override
public void close() {
server.stop(0);
executor.shutdownNow();
}
}
}
32 changes: 31 additions & 1 deletion integration-tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ def minio():
"/home/lance/rest-data",
)
LANCE_SPARK_REST_DIR_PORT = int(os.environ.get("LANCE_SPARK_REST_DIR_PORT", "10024"))
LANCE_SPARK_REST_DIR_MANAGED_VERSIONING = os.environ.get(
"LANCE_SPARK_REST_DIR_MANAGED_VERSIONING",
"",
).lower() in ("1", "true", "yes")
CREATE_TABLE_VERSION_COUNT_PATH = "/__lance_test/create_table_version_count"
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
AWS_REGION = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1"
AWS_GLUE_CATALOG_ID = os.environ.get("AWS_GLUE_CATALOG_ID")
Expand Down Expand Up @@ -372,6 +377,7 @@ def spark(request):
session.sql("CREATE NAMESPACE IF NOT EXISTS default")
# Store backend name for marker-based test skipping
session._lance_backend = backend
session._lance_rest_dir = rest_dir if backend == "rest-dir" else None
yield session
session.stop()

Expand Down Expand Up @@ -403,17 +409,41 @@ def rest_dir_namespace():
LANCE_SPARK_REST_DIR_ROOT,
"127.0.0.1",
str(port),
str(LANCE_SPARK_REST_DIR_MANAGED_VERSIONING).lower(),
],
stdout=log,
stderr=subprocess.STDOUT,
)
try:
_wait_for_tcp(host, port, proc, "Lance REST directory namespace")
yield {"uri": uri}
yield {
"uri": uri,
"create_table_version_count_uri": (
uri.rstrip("/") + CREATE_TABLE_VERSION_COUNT_PATH
if LANCE_SPARK_REST_DIR_MANAGED_VERSIONING
else None
),
}
finally:
_stop_process(proc)


@pytest.fixture
def create_table_version_count(spark):
"""Return the managed REST server's successful create-table-version count."""
rest_dir = getattr(spark, "_lance_rest_dir", None)
count_uri = rest_dir.get("create_table_version_count_uri") if rest_dir else None
if not count_uri:
return None

def retrieve_count():
with urllib.request.urlopen(count_uri, timeout=5) as response:
return int(response.read().decode("utf-8"))

retrieve_count()
return retrieve_count


@pytest.fixture
def test_table(request, spark):
"""Provide a unique table name for each test to avoid isolation issues.
Expand Down
Loading
Loading