mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-12 14:52:26 +00:00
refactor!: implement lazy iterators for several APIs (#4459)
Implement lazy iterators for: - secrets - views - jobs - functions - materialized views
This commit is contained in:
@@ -112,7 +112,7 @@ describe("materialized views", () => {
|
||||
await db.createMaterializedView("adults", "people", {
|
||||
where: "age >= 18",
|
||||
});
|
||||
expect(await db.listMaterializedViews()).toEqual(["adults"]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual(["adults"]);
|
||||
await expect(db.openMaterializedView("people")).rejects.toThrow(
|
||||
"not a materialized view",
|
||||
);
|
||||
@@ -121,7 +121,7 @@ describe("materialized views", () => {
|
||||
);
|
||||
|
||||
await db.dropMaterializedView("adults");
|
||||
expect(await db.listMaterializedViews()).toEqual([]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a job when dropping a view asynchronously", async () => {
|
||||
@@ -130,7 +130,7 @@ describe("materialized views", () => {
|
||||
const job = await db.dropMaterializedViewAsync("adults");
|
||||
expect(job.id).toBeNull();
|
||||
await job.wait();
|
||||
expect(await db.listMaterializedViews()).toEqual([]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an invalid expression at create time", async () => {
|
||||
@@ -147,7 +147,7 @@ describe("materialized views", () => {
|
||||
db.createMaterializedView("bad", "people", { limit }),
|
||||
).rejects.toThrow("non-negative integer");
|
||||
}
|
||||
expect(await db.listMaterializedViews()).toEqual([]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
|
||||
const view = await db.createMaterializedView("copy", "people");
|
||||
for (const sourceVersion of [-1, 1.5, Infinity, NaN]) {
|
||||
@@ -173,3 +173,9 @@ describe("materialized views", () => {
|
||||
expect(await view.table().countRows()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
async function collect<T>(items: AsyncIterable<T>): Promise<T[]> {
|
||||
const result: T[] = [];
|
||||
for await (const item of items) result.push(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -258,7 +258,9 @@ describe("remote connection", () => {
|
||||
.end(JSON.stringify({ views: ["daily_sales"] }));
|
||||
},
|
||||
async (db) => {
|
||||
expect(await db.listMaterializedViews()).toEqual(["daily_sales"]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([
|
||||
"daily_sales",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -308,7 +310,7 @@ describe("remote connection", () => {
|
||||
.end(JSON.stringify({ views: ["adults"] }));
|
||||
},
|
||||
async (db) => {
|
||||
expect(await db.listViews()).toEqual(["adults"]);
|
||||
expect(await collect(db.listViews())).toEqual(["adults"]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1377,7 +1379,7 @@ describe("remote connection jobs surface", () => {
|
||||
});
|
||||
},
|
||||
async (db) => {
|
||||
const jobs = await db.listJobs();
|
||||
const jobs = await collect(db.listJobs());
|
||||
expect(jobs.map((job) => job.jobId)).toEqual(["job-1", "job-2"]);
|
||||
expect(jobs[0].state).toEqual("running");
|
||||
expect(jobs[1].state).toEqual("finished");
|
||||
@@ -1473,3 +1475,147 @@ describe("remote connection jobs surface", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function collect<T>(items: AsyncIterable<T>): Promise<T[]> {
|
||||
const result: T[] = [];
|
||||
for await (const item of items) result.push(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("lazy resource listings", () => {
|
||||
for (const kind of ["views", "materializedViews", "jobs"] as const) {
|
||||
const listing = (
|
||||
db: Connection,
|
||||
options: { pageToken?: string; pageLimit?: number } = {},
|
||||
) => {
|
||||
if (kind === "jobs") return db.listJobs(options);
|
||||
if (kind === "views") return db.listViews(["team"], options);
|
||||
return db.listMaterializedViews(options);
|
||||
};
|
||||
const page = (names: string[], token?: string) => {
|
||||
const items =
|
||||
kind === "jobs"
|
||||
? names.map((name) => ({
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
job_id: name,
|
||||
table: "t",
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
job_type: "create_index",
|
||||
state: "in_progress",
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
created_at_millis: 1,
|
||||
}))
|
||||
: names;
|
||||
return {
|
||||
[kind === "jobs" ? "jobs" : "views"]: items,
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
page_token: token,
|
||||
};
|
||||
};
|
||||
|
||||
it(`${kind} fetches lazily, skips empty pages, and resumes`, async () => {
|
||||
const requests: { url: string; body: Record<string, unknown> }[] = [];
|
||||
const responses = [
|
||||
page(["a", "b"], "empty"),
|
||||
page([], "last"),
|
||||
page(["c", "d"], ""),
|
||||
];
|
||||
await withMockDatabase(
|
||||
async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
requests.push({
|
||||
url: req.url ?? "",
|
||||
body: body ? JSON.parse(body) : {},
|
||||
});
|
||||
res
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(JSON.stringify(responses.shift()));
|
||||
},
|
||||
async (db) => {
|
||||
const items = listing(db, { pageToken: "start/token", pageLimit: 2 });
|
||||
expect(requests).toHaveLength(0);
|
||||
expect(items.pageToken()).toBe("start/token");
|
||||
expect(items.numPageResults()).toBe(0);
|
||||
expect((await items.next()).done).toBe(false);
|
||||
expect(items.numPageResults()).toBe(1);
|
||||
expect(items.pageToken()).toBe("empty");
|
||||
await items.next();
|
||||
expect(requests).toHaveLength(1);
|
||||
const resumed = listing(db, {
|
||||
pageToken: items.pageToken(),
|
||||
pageLimit: 2,
|
||||
});
|
||||
await resumed.next();
|
||||
expect(resumed.numPageResults()).toBe(1);
|
||||
expect(resumed.pageToken()).toBeUndefined();
|
||||
expect(requests).toHaveLength(3);
|
||||
await resumed.next();
|
||||
expect((await resumed.next()).done).toBe(true);
|
||||
expect((await resumed.next()).done).toBe(true);
|
||||
for (const [i, request] of requests.entries()) {
|
||||
const token = ["start/token", "empty", "last"][i];
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (kind === "jobs") {
|
||||
expect(url.pathname).toBe("/v1/jobs/list");
|
||||
expect(request.body).toEqual({
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
page_token: token,
|
||||
limit: 2,
|
||||
});
|
||||
} else {
|
||||
expect(url.pathname).toBe(
|
||||
kind === "views"
|
||||
? "/v1/namespace/team/view/list"
|
||||
: "/v1/namespace/$/materialized_view/list",
|
||||
);
|
||||
expect(url.searchParams.get("page_token")).toBe(token);
|
||||
expect(url.searchParams.get("limit")).toBe("2");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it(`${kind} retains failed tokens and serializes concurrent advances`, async () => {
|
||||
let requests = 0;
|
||||
await withMockDatabase(
|
||||
(_req, res) => {
|
||||
requests += 1;
|
||||
res
|
||||
.writeHead(requests === 1 ? 401 : 200, {
|
||||
"content-type": "application/json",
|
||||
})
|
||||
.end(JSON.stringify(requests === 1 ? {} : page(["a", "b"])));
|
||||
},
|
||||
async (db) => {
|
||||
const failed = listing(db, { pageToken: "retry" });
|
||||
await expect(failed.next()).rejects.toThrow();
|
||||
expect(failed.pageToken()).toBe("retry");
|
||||
expect((await failed.next()).done).toBe(true);
|
||||
expect(requests).toBe(1);
|
||||
const resumed = listing(db, { pageToken: failed.pageToken() });
|
||||
const results = await Promise.all([
|
||||
resumed.next(),
|
||||
resumed.next(),
|
||||
resumed.next(),
|
||||
]);
|
||||
expect(results.filter((result) => result.done)).toHaveLength(1);
|
||||
expect(requests).toBe(2);
|
||||
for (const limit of [
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
2147483648,
|
||||
]) {
|
||||
expect(() => listing(db, { pageLimit: limit })).toThrow("limit");
|
||||
}
|
||||
expect(requests).toBe(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./arrow";
|
||||
import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry";
|
||||
import { Job } from "./job";
|
||||
import { Listing, ListingOptions, validateListingOptions } from "./listing";
|
||||
import {
|
||||
MaterializedView,
|
||||
MaterializedViewSelect,
|
||||
@@ -347,9 +348,11 @@ export abstract class Connection {
|
||||
/**
|
||||
* The names of the materialized views in this database.
|
||||
*
|
||||
* Found by reading every table's schema, so this costs an open per table.
|
||||
* Iterate lazily with `for await...of`. Local listings inspect table schemas
|
||||
* one page at a time. Options control page size and the starting token.
|
||||
* See {@link Listing} for pagination state and error behavior.
|
||||
*/
|
||||
abstract listMaterializedViews(): Promise<string[]>;
|
||||
abstract listMaterializedViews(options?: ListingOptions): Listing<string>;
|
||||
|
||||
/**
|
||||
* Drop the materialized view named `name`.
|
||||
@@ -421,8 +424,13 @@ export abstract class Connection {
|
||||
* The names of the views in one namespace.
|
||||
*
|
||||
* Names only; a definition comes from {@link describeView}.
|
||||
* Iterate lazily with `for await...of`. See {@link Listing} for pagination
|
||||
* state and error behavior; options control page size and the starting token.
|
||||
*/
|
||||
abstract listViews(namespacePath?: string[]): Promise<string[]>;
|
||||
abstract listViews(
|
||||
namespacePath?: string[],
|
||||
options?: ListingOptions,
|
||||
): Listing<string>;
|
||||
|
||||
abstract openTable(
|
||||
name: string,
|
||||
@@ -637,8 +645,12 @@ export abstract class Connection {
|
||||
*/
|
||||
abstract openJob(jobId: string): Promise<Job>;
|
||||
|
||||
/** List server-side jobs across the database's tables. */
|
||||
abstract listJobs(): Promise<JobInfo[]>;
|
||||
/**
|
||||
* Iterate lazily over server-side jobs across the database's tables.
|
||||
* Use `for await...of`. Options control page size and the starting token.
|
||||
* See {@link Listing} for pagination state and error behavior.
|
||||
*/
|
||||
abstract listJobs(options?: ListingOptions): Listing<JobInfo>;
|
||||
|
||||
/**
|
||||
* Request cancellation of a server-side job by id.
|
||||
@@ -741,8 +753,11 @@ export class LocalConnection extends Connection {
|
||||
return new MaterializedView(new LocalTable(innerTable));
|
||||
}
|
||||
|
||||
async listMaterializedViews(): Promise<string[]> {
|
||||
return await this.inner.listMaterializedViews();
|
||||
listMaterializedViews(options: ListingOptions = {}): Listing<string> {
|
||||
validateListingOptions(options);
|
||||
return new Listing(
|
||||
this.inner.listMaterializedViews(options.pageToken, options.pageLimit),
|
||||
);
|
||||
}
|
||||
|
||||
async dropMaterializedView(
|
||||
@@ -788,8 +803,18 @@ export class LocalConnection extends Connection {
|
||||
return new Job(await this.inner.dropViewAsync(name, namespacePath ?? []));
|
||||
}
|
||||
|
||||
async listViews(namespacePath?: string[]): Promise<string[]> {
|
||||
return this.inner.listViews(namespacePath ?? []);
|
||||
listViews(
|
||||
namespacePath?: string[],
|
||||
options: ListingOptions = {},
|
||||
): Listing<string> {
|
||||
validateListingOptions(options);
|
||||
return new Listing(
|
||||
this.inner.listViews(
|
||||
namespacePath ?? [],
|
||||
options.pageToken,
|
||||
options.pageLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listTables(
|
||||
@@ -1057,8 +1082,11 @@ export class LocalConnection extends Connection {
|
||||
return new Job(await this.inner.openJob(jobId));
|
||||
}
|
||||
|
||||
async listJobs(): Promise<JobInfo[]> {
|
||||
return this.inner.listJobs();
|
||||
listJobs(options: ListingOptions = {}): Listing<JobInfo> {
|
||||
validateListingOptions(options);
|
||||
return new Listing(
|
||||
this.inner.listJobs(options.pageToken, options.pageLimit),
|
||||
);
|
||||
}
|
||||
|
||||
async cancelJob(jobId: string): Promise<boolean> {
|
||||
|
||||
@@ -649,3 +649,5 @@ export {
|
||||
DatabaseNames,
|
||||
connectCatalog,
|
||||
} from "./catalog";
|
||||
|
||||
export { Listing, ListingOptions } from "./listing";
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
/** Pagination controls for lazy resource listings. */
|
||||
export interface ListingOptions {
|
||||
/** Starting continuation token; omitted starts at the beginning. */
|
||||
pageToken?: string;
|
||||
/** Maximum results per request, not a limit on the whole listing. */
|
||||
pageLimit?: number;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function validateListingOptions(options: ListingOptions): void {
|
||||
if (
|
||||
options.pageLimit !== undefined &&
|
||||
(!Number.isInteger(options.pageLimit) ||
|
||||
options.pageLimit <= 0 ||
|
||||
options.pageLimit > 2147483647)
|
||||
) {
|
||||
throw new Error(
|
||||
"Listing page limit must be an integer between 1 and 2147483647",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A lazy async iterator over database resources with pagination state.
|
||||
* Requests occur during iteration; request errors terminate the iterator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* for await (const name of connection.listViews([], { pageLimit: 20 })) {
|
||||
* console.log(name);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class Listing<T> implements AsyncIterableIterator<T> {
|
||||
/** @hidden */
|
||||
constructor(
|
||||
private readonly inner: {
|
||||
next(): Promise<T | null | undefined>;
|
||||
numPageResults(): number;
|
||||
pageToken(): string | null | undefined;
|
||||
},
|
||||
) {}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterableIterator<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Fetch the next item, requesting another page only when needed. */
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
const value = await this.inner.next();
|
||||
return value == null
|
||||
? { done: true, value: undefined }
|
||||
: { done: false, value };
|
||||
}
|
||||
|
||||
/** Number of cached items available without another request. */
|
||||
numPageResults(): number {
|
||||
return this.inner.numPageResults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Token for the next request. Initially this is the supplied starting token.
|
||||
* Returns undefined after fetching the final page, even with cached items.
|
||||
* Drain the cache before saving a token to avoid skipping items on resumption.
|
||||
* Failed requests terminate iteration and retain their token for a new iterator.
|
||||
*/
|
||||
pageToken(): string | undefined {
|
||||
return this.inner.pageToken() ?? undefined;
|
||||
}
|
||||
}
|
||||
+28
-14
@@ -371,13 +371,15 @@ impl Connection {
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn list_materialized_views(&self) -> napi::Result<Vec<String>> {
|
||||
let views = self
|
||||
.get_inner()?
|
||||
.list_materialized_views()
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(views)
|
||||
pub fn list_materialized_views(
|
||||
&self,
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> napi::Result<crate::listing::NameListing> {
|
||||
Ok(crate::listing::NameListing::new(
|
||||
self.get_inner()?
|
||||
.list_materialized_views(crate::listing::options(page_token, page_limit)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Drop a materialized view.
|
||||
@@ -460,12 +462,18 @@ impl Connection {
|
||||
|
||||
/// The names of the views in one namespace.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn list_views(
|
||||
pub fn list_views(
|
||||
&self,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> napi::Result<Vec<String>> {
|
||||
let ns = namespace_path.unwrap_or_default();
|
||||
self.get_inner()?.list_views(&ns).await.default_error()
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> napi::Result<crate::listing::NameListing> {
|
||||
Ok(crate::listing::NameListing::new(
|
||||
self.get_inner()?.list_views(
|
||||
&namespace_path.unwrap_or_default(),
|
||||
crate::listing::options(page_token, page_limit),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Start dropping a materialized view and return its cleanup job.
|
||||
@@ -588,9 +596,15 @@ impl Connection {
|
||||
|
||||
/// List server-side jobs across the database's tables.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn list_jobs(&self) -> napi::Result<Vec<crate::job::JobInfo>> {
|
||||
let jobs = self.get_inner()?.list_jobs().await.default_error()?;
|
||||
Ok(jobs.into_iter().map(Into::into).collect())
|
||||
pub fn list_jobs(
|
||||
&self,
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> napi::Result<crate::listing::JobListing> {
|
||||
Ok(crate::listing::JobListing::new(
|
||||
self.get_inner()?
|
||||
.list_jobs(crate::listing::options(page_token, page_limit)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Request cancellation of a server-side job by id. Returns true if the
|
||||
|
||||
@@ -18,6 +18,7 @@ mod header;
|
||||
mod index;
|
||||
mod iterator;
|
||||
mod job;
|
||||
mod listing;
|
||||
pub mod merge;
|
||||
pub mod otel;
|
||||
pub mod permutation;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use crate::error::NapiErrorExt;
|
||||
use futures::{StreamExt, future::poll_fn};
|
||||
use lancedb::listing::{Listing, ListingOptions};
|
||||
use napi_derive::napi;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub fn options(page_token: Option<String>, page_limit: Option<u32>) -> ListingOptions {
|
||||
let mut options = ListingOptions::default();
|
||||
options.page_token = page_token;
|
||||
options.page_limit = page_limit;
|
||||
options
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct NameListing {
|
||||
inner: Mutex<Listing<String>>,
|
||||
next_lock: futures::lock::Mutex<()>,
|
||||
}
|
||||
impl NameListing {
|
||||
pub fn new(inner: Listing<String>) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(inner),
|
||||
next_lock: futures::lock::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
impl NameListing {
|
||||
#[napi]
|
||||
pub fn num_page_results(&self) -> u32 {
|
||||
self.inner.lock().unwrap().num_page_results() as u32
|
||||
}
|
||||
#[napi]
|
||||
pub fn page_token(&self) -> Option<String> {
|
||||
self.inner.lock().unwrap().page_token().map(str::to_owned)
|
||||
}
|
||||
#[napi]
|
||||
pub async fn next(&self) -> napi::Result<Option<String>> {
|
||||
let _guard = self.next_lock.lock().await;
|
||||
let value = poll_fn(|cx| self.inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.transpose()
|
||||
.default_error()?;
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct JobListing {
|
||||
inner: Mutex<Listing<lancedb::database::JobInfo>>,
|
||||
next_lock: futures::lock::Mutex<()>,
|
||||
}
|
||||
impl JobListing {
|
||||
pub fn new(inner: Listing<lancedb::database::JobInfo>) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(inner),
|
||||
next_lock: futures::lock::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
impl JobListing {
|
||||
#[napi]
|
||||
pub fn num_page_results(&self) -> u32 {
|
||||
self.inner.lock().unwrap().num_page_results() as u32
|
||||
}
|
||||
#[napi]
|
||||
pub fn page_token(&self) -> Option<String> {
|
||||
self.inner.lock().unwrap().page_token().map(str::to_owned)
|
||||
}
|
||||
#[napi]
|
||||
pub async fn next(&self) -> napi::Result<Option<crate::job::JobInfo>> {
|
||||
let _guard = self.next_lock.lock().await;
|
||||
let value = poll_fn(|cx| self.inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.transpose()
|
||||
.default_error()?;
|
||||
Ok(value.map(|value| value.into()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user