refactor!: implement lazy iterators for several APIs (#4459)

Implement lazy iterators for:
- secrets
- views
- jobs
- functions
- materialized views
This commit is contained in:
Colin Patrick McCabe
2026-10-07 10:24:20 -07:00
committed by GitHub
parent 069b2f7d31
commit de005f1d61
32 changed files with 2185 additions and 454 deletions
+10 -4
View File
@@ -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;
}
+149 -3
View File
@@ -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);
},
);
});
}
});
+39 -11
View File
@@ -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> {
+2
View File
@@ -649,3 +649,5 @@ export {
DatabaseNames,
connectCatalog,
} from "./catalog";
export { Listing, ListingOptions } from "./listing";
+73
View File
@@ -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
View File
@@ -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
+1
View File
@@ -18,6 +18,7 @@ mod header;
mod index;
mod iterator;
mod job;
mod listing;
pub mod merge;
pub mod otel;
pub mod permutation;
+83
View File
@@ -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()))
}
}