Skip to content

feat(namespace): support Lance Namespace across table operations - #35

Merged
universalmind303 merged 25 commits into
daft-engine:mainfrom
FANNG1:namespace
Jul 22, 2026
Merged

feat(namespace): support Lance Namespace across table operations#35
universalmind303 merged 25 commits into
daft-engine:mainfrom
FANNG1:namespace

Conversation

@FANNG1

@FANNG1 FANNG1 commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Closes #50 (motivating use case: Gravitino Lance REST server managed tables + Daft multimodal processing).

Summary

  • add a shared Lance namespace resolver for URI-or-namespace validation, namespace client construction, table resolution, and storage option merging
  • wire namespace table support through daft_lance.read_lance, the new daft_lance.write_lance, LanceDataSink, merge/index/compaction operations, and worker scan reconstruction
  • keep dataset-open state in an explicit LanceDatasetHandle instead of attaching Daft-private attributes to LanceDataset
  • explicitly thread resolved URIs, vended storage options, and namespace commit kwargs through scan, merge, and distributed index paths
  • reopen datasets on distributed workers from a serializable DatasetOpenContext instead of shipping a live LanceDataset, so maintenance tasks keep their namespace identity
  • add dir namespace coverage for reads/writes, transform and slow-path merges, segmented/non-segmented distributed indexes, compaction, credential vending, and worker reconstruction, plus a gated Gravitino Lance REST namespace integration test
  • tighten namespace table-not-found handling and request vended credentials explicitly
  • recover the overwrite path from a lost declare_table race by re-describing, while leaving create to fail on the conflict

Review fixes

Three blocking issues raised in review, all fixed here:

Namespace locations are now percent-decoded. Namespaces vend URI-encoded
locations: a table under daft lance/ came back from the dir namespace as
file:///.../daft%20lance/t.lance, and _normalize_file_uri took parsed.path
verbatim. Writes landed in a literally-named daft%20lance directory while the
subsequent describe_table resolved to the decoded one, so create appeared to
succeed and reads found nothing. Covered by unit tests (spaces, non-ASCII,
literal percent, object-store passthrough) plus an end-to-end roundtrip through
a namespace root and table id that both encode.

The construct_lance_dataset() compatibility wrapper is removed. It
returned only .dataset, discarding the resolved uri, open_kwargs,
managed_versioning and default_scan_options. open_kwargs is the
serialization boundary to workers (lance_scan.py puts it in the scan task's
func_args, and open_dataset_from_open_kwargs rebuilds the namespace client
from it), so callers going through the wrapper left workers with {}: no vended
credentials, no namespace triple, and no pinned version, meaning partitions of
one scan could read different snapshots. The stripped-nearest regression was a
second symptom — the dataset's own _default_scan_options has already had
nearest removed by then, so it cannot be recovered from the object. Rather
than reconstruct any of this from LanceDataset private attributes, the wrapper
is deleted; it had no callers inside daft_lance and was never exported.

Namespace + use_mem_wal=True is now rejected at construction time. The
namespace path declares the table up front as a metadata-only reservation, then
_ensure_mem_wal_dataset() asked Lance for a namespace-aware
write_dataset(mode="create"), which declares the same table again —
DeclareTable is not idempotent, so this always raised
TableAlreadyExistsError. The surrounding except (ValueError, FileNotFoundError, OSError) does not cover the namespace error types either. Making it work
requires hoisting the native create to the driver's start() so mem-WAL table
creation is not raced by N workers; that is left to a follow-up rather than
claiming support that cannot work.

Distributed worker reopen

LanceDataset.__reduce__ carries only (uri, storage_options, version, manifest, ...). _namespace_client, _table_id and _namespace_client_managed_versioning
are assigned after construction, so pickle drops them. Compaction, scalar index
and merge each shipped the driver's live dataset into their UDFs, which meant
workers silently lost the table's namespace identity and committed as though it
were a plain URI table.

Workers now receive a frozen, serializable DatasetOpenContext — physical uri,
the driver's resolved numeric version, effective initial storage options, the
namespace triple, managed_versioning, and the worker read options — and reopen
from it, rebuilding the namespace client per process through the existing
lru_cache. The driver keeps its live dataset for planning, validation and
commits.

Reopen goes through the low-level LanceDataset(uri, namespace_client=...)
constructor rather than lance.dataset(None, namespace_client=..., table_id=...),
which resolves the location with a describe_table on every call. Measured
with pylance's ops_metrics: the low-level open costs zero namespace calls
(open and scan), the high-level one costs a round-trip per task. Each UDF
instance opens once, lazily — the reopen costs a pinned-manifest read, so it must
not sit on the per-call path.

The serialized manifest is deliberately not carried, keeping the task payload
independent of fragment count at the cost of that manifest read. This matches
the lance-spark and Lance Ray maintenance paths. Version semantics are preserved:
workers pin the snapshot the driver planned against, and only the index
coordinator steps that must observe worker output reopen at latest.

Scan workers are pinned too. construct_lance_dataset_handle stored the
caller's version argument in open_kwargs — defaulting to None — and
open_kwargs is precisely what crosses to scan workers, so each task
independently opened latest. Both failure modes reproduce: a compaction
landing between planning and execution leaves workers hunting fragment ids that
no longer exist (planned [0,1,2], worker sees [3], get_fragment(0) returns
None), and an overwrite silently substitutes different data with no error at
all. open_kwargs now carries the resolved numeric dataset.version, and
asof is dropped since it is an input to that resolution rather than something
workers should re-evaluate.

Since the context supplies uri, storage options and namespace kwargs, the
internal entry points drop those four parameters rather than carry a second copy
of the same state, and FastPathFragmentWriter loses its private
uri/storage_options fields for the same reason. Scan otherwise keeps its
existing open_kwargs path — unifying the two state models is left to a
separate change so this one does not perturb tag/default-scan-option behavior.

Known limitation. Workers start from the storage options the driver resolved.
pylance installs its namespace refresh provider only when those options are
present (python/src/dataset.rs, gated on initial_storage_options.is_some()),
and the accessor refreshes on expires_at_millis; the upstream provider's
DescribeTableRequest does not set vend_credentials=True. So the wiring is
restored and refresh is possible, but reliable refresh depends on the catalog
vending expiry metadata and credentials — a task outliving its credentials can
still fail. Full credential refresh, including expired-credential tests, is
tracked separately.

Follow-ups (not in this PR)

  • Worker credential refresh. See the known limitation above: explicit
    vend_credentials=True on the refresh request, expired-credential and
    replacement-token tests, and refresh failure/retry behavior. It should also
    carry a switch for whether workers may rebuild the namespace client at all —
    lance-spark's executorCredentialRefresh defaults to true but is explicitly
    disableable, since not every catalog is reachable from the worker network.
  • Recovery from a failed write after declare. A declared-but-empty table is a
    protocol-allowed intermediate state; the namespace spec deliberately keeps
    catalogs out of slow, unreliable data deletion. Cleanup should be
    DeregisterTable scoped to a table this operation declared and confirmed still
    declared-only, not an unconditional drop_table on worker failure — and Daft's
    DataSink has no abort callback to hang it on.

Validation

  • .venv/bin/pytest -q (347 passed, 5 skipped, 2 xfailed, 2 xpassed)
  • .venv/bin/ruff format --check daft_lance tests
  • .venv/bin/ruff check daft_lance tests
  • .venv/bin/mypy daft_lance
  • git diff --check

Cross-repo plan

daft_lance.write_lance in this PR looks like a copy of DataFrame.write_lance, but the
direction is the opposite: Eventual-Inc/Daft#6957 already migrated all of daft.io.lance to
this package, and DataFrame.write_lance's inline body is the last piece left behind
upstream. Eventual-Inc/Daft#7282 proposes reducing it to a thin delegation to
daft_lance.write_lance, making this module the single canonical implementation.

Until Eventual-Inc/Daft#7282 lands, namespace users call daft_lance.read_lance /
daft_lance.write_lance directly. (An earlier revision of this PR shipped a patch_daft()
monkeypatch as interim glue for Daft's native entry points; it was removed in favor of the
direct entry points since it had never been released.)

@FANNG1
FANNG1 marked this pull request as draft June 19, 2026 12:17
@FANNG1 FANNG1 changed the title Add Lance namespace read/write support feat(namespace): support Lance Namespace across table operations Jul 16, 2026
fanng added 13 commits July 16, 2026 19:02
…tion

Build on the initial namespace read/write support:

- Extend namespace params (namespace_impl/namespace_properties/table_id)
  to merge_columns, merge_columns_df, create_scalar_index, compact_files,
  threading namespace kwargs into their commit sites.
- Add an explicit daft_lance.write_lance() and make the daft monkeypatch
  opt-in via patch_daft(); wire write_lance(mode="merge") through namespace.
- Fix _declare_table fallback that always raised ImportError on the
  CreateEmptyTableRequest import; keep only declare_table.
- Narrow overwrite resolution to only declare on TableNotFound instead of
  swallowing every describe_table exception.
- Drop dead code (open_lance_dataset, pylance<5 compat) and dedupe the
  worker dataset-reopen logic into open_dataset_from_open_kwargs.
- Cover all entry points with dir-namespace tests plus a Gravitino REST
  namespace e2e roundtrip.
- Work around an upstream daft+lance native teardown SIGSEGV (unrelated to
  test outcomes) via a pytest_unconfigure hard-exit after reporting.
…nk.start()

Constructing a LanceDataSink no longer talks to the namespace or opens the
dataset; all resolution moves to start(), which Daft runs once on the driver
before the sink is serialized to workers. This keeps declare_table from
firing during plan construction and from leaving orphan declared tables
behind when local parameter validation fails.

Create-mode resolution is now describe-first (check_declared=True): a real
existing table fails fast with a clear error suggesting overwrite/append, a
declared-only stub is reused as a placeholder (write_fragments switches to
overwrite when the stub materialized a dataset), and a lost declare race is
recovered by re-describing and classifying. resolve_namespace_table returns
a ResolvedNamespaceTable carrying uri/storage_options/placeholder state so
the sink does not have to re-describe.

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
…locations

Previously io_config was converted to storage options only when a plain uri
was passed, so a namespace that resolves to s3://... without vending
credentials silently ignored the user's io_config. construct_lance_dataset
and LanceDataSink now derive storage options from io_config against the
resolved location and layer them as: io_config-derived < user-provided
storage_options < namespace-vended. Plain-uri behavior is unchanged
(user-provided options still replace io_config-derived ones).

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
daft_lance/ sources and the namespace test files now pass mypy strict. The
bulk came from LanceDataSink._namespace_kwargs being annotated dict[str,
object], which poisoned every **-expansion into typed lance APIs. Also
widens read_lance's default_scan_options to dict[str, Any] to match the
documented usage ({"with_row_address": True}) and the other entry points.

Pre-existing errors in the rest of tests/ are out of scope for this branch.

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
…phan properties

- read_lance / merge_columns(_df) / create_scalar_index / compact_files
  docstrings now describe table_id / namespace_impl / namespace_properties
- namespace_properties without namespace_impl now fails fast instead of
  being silently ignored
- README documents io_config fallback for namespace locations and the
  DAFT_LANCE_NAMESPACE_CACHE_SIZE environment variable

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
- overwrite-mode resolution now describes with check_declared=True (initial
  and declare-race recovery): strict namespace impls 404 a plain describe on
  declared-only stubs, so the previous recovery path failed in exactly the
  case it existed for. A declared-only stub is a valid overwrite target.
- mode=create over a namespace placeholder that materialized a dataset is
  rejected with a clear error when use_mem_wal=True; the MemWAL path cannot
  apply the overwrite-placeholder semantics.
- plain-uri entry points treat storage_options={} as unset again (falsy
  fallthrough to io_config-derived options), matching pre-branch behavior.

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
…nt count

- pickle a started sink and run write()/finalize() on the copy, codifying
  the driver-resolves/worker-writes contract instead of relying on manual
  verification
- compact_files test now asserts the fragment count actually shrinks, not
  just data correctness

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
… storage_options handling

- describe_table/declare_table now pass vend_credentials=True: per the
  lance-namespace spec, whether credentials are returned is
  implementation-defined when the flag is unset, so the documented
  "namespace vends storage_options" behavior was not guaranteed
- LanceDataSink now treats storage_options={} the same as the read entry
  points (falsy fallthrough to io_config-derived options); previously
  write_lance dropped io_config credentials for that input while read_lance
  kept them

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
The old message predated namespace support and directed users to "the
daft-lance package" — which is where they already are. Show the actual
read_lance(namespace_impl="rest", ...) usage instead.

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
Module docstring covers the three load-bearing decisions (serialize the
triple + per-process client cache, describe-first resolution, defensive
error classification), and resolve_namespace_table documents the full
create/overwrite/read state machine including the declare-race legs.

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
patch_daft() was introduced on this branch and never released, so it can be
removed without a deprecation cycle. Until Eventual-Inc/Daft#7282 delegates
DataFrame.write_lance / read_lance natively, namespace users call
daft_lance.read_lance / daft_lance.write_lance directly — which the README
documents as the primary usage anyway. This also removes the only monkeypatch
in the package and avoids having to keep the patch compatible with the
upstream signature change coming in #7282.

Claude-Session: https://claude.ai/code/session_01VyD31uRyBSEmKvPQhLtM5E
@FANNG1
FANNG1 marked this pull request as ready for review July 16, 2026 10:10
@FANNG1
FANNG1 marked this pull request as draft July 16, 2026 13:54
@FANNG1
FANNG1 marked this pull request as ready for review July 21, 2026 03:50
@universalmind303
universalmind303 self-requested a review July 21, 2026 15:14
@universalmind303 universalmind303 self-assigned this Jul 21, 2026
@universalmind303

Copy link
Copy Markdown
Collaborator

thanks for the PR @FANNG1, Will take a look asap!

@universalmind303 universalmind303 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I ran this through a pretty deep review & a no-mocks e2e harness (real dir namespace via lance-namespace: create/append/read/pushdowns/merge columns/index/compaction all through the triple, cross-checked with plain pylance). happy path holds up nicely. found a handful of real issues though.

I think the percent-encoding & compat wrapper ones need fixing before merge, the rest can be follow ups.

Comment thread daft_lance/namespace.py Outdated
Comment thread daft_lance/utils.py Outdated
Comment thread daft_lance/lance_data_sink.py
Comment thread daft_lance/_lance.py
Comment thread daft_lance/lance_data_sink.py
fanng added 6 commits July 22, 2026 08:45
Namespaces vend URI-encoded locations: a table under `daft lance/` comes
back from the dir namespace as `file:///.../daft%20lance/t.lance`.
_normalize_file_uri took parsed.path verbatim, so writes landed in a
literally-named `daft%20lance` directory while the subsequent
describe_table resolved to the decoded one — create appeared to succeed
and reads found nothing.

Claude-Session: https://claude.ai/code/session_018YLUk2vAhX3jXFL9D7za7u
The wrapper returned only .dataset, discarding the resolved uri,
open_kwargs, managed_versioning and default_scan_options that
construct_lance_dataset_handle resolves. Callers going through it lost
the reusable open context that distributed workers need to reopen the
dataset, and nearest-vector scan defaults silently became None.

It had no callers inside daft_lance and was never exported, so remove it
rather than reconstructing the lost state from dataset private
attributes. This also keeps a single explicit context type as the
foundation for the serializable access context that credential refresh
will need.

Claude-Session: https://claude.ai/code/session_018YLUk2vAhX3jXFL9D7za7u
The namespace write path declares the table up front as a metadata-only
reservation, then the mem-WAL path asked Lance for a namespace-aware
write_dataset(mode="create"), which declared the same table again and
raised TableAlreadyExistsError. The combination could never succeed, and
_ensure_mem_wal_dataset's except clause does not cover the namespace
error types either.

Fail at construction time with an actionable message instead of claiming
support. Making it work requires the mem-WAL path to skip our declare and
let the native create own table creation, which is left to a follow-up.

Claude-Session: https://claude.ai/code/session_018YLUk2vAhX3jXFL9D7za7u
Adds unit coverage for _normalize_file_uri (spaces, non-ASCII, literal
percent, object-store passthrough) and an end-to-end roundtrip through a
namespace root and table id that both percent-encode, asserting data
lands under the decoded name. Also asserts namespace + use_mem_wal is
rejected, and moves the two construct_lance_dataset call sites to
construct_lance_dataset_handle.

Claude-Session: https://claude.ai/code/session_018YLUk2vAhX3jXFL9D7za7u
LanceDataset.__reduce__ carries only (uri, storage_options, version,
manifest, ...); _namespace_client, _table_id and
_namespace_client_managed_versioning are assigned after construction and
are dropped by pickle. Compaction, scalar index and merge all shipped the
driver's live dataset into their UDFs, so workers silently lost the
table's namespace identity and committed as if it were a uri table.

Introduce a frozen, serializable DatasetOpenContext carrying the physical
uri, the driver's resolved numeric version, the effective initial storage
options, the namespace triple, managed_versioning and the worker read
options. Workers rebuild the namespace client per process and reopen; the
driver keeps its live dataset for planning, validation and commits.

Workers open through the low-level LanceDataset constructor with the
physical uri rather than lance.dataset(None, namespace_client=...), which
resolves the location with a describe_table on every call. Measured with
pylance's ops_metrics: the low-level open costs zero namespace calls,
the high-level one costs a round-trip per task.

Because the context supplies uri, storage options and namespace kwargs,
the internal entry points drop those four parameters instead of carrying
a second copy of the same state; FastPathFragmentWriter loses its private
uri/storage_options fields for the same reason. Each UDF instance opens
once, lazily -- the reopen costs a pinned-manifest read, so it must not
sit on the per-call path.

The serialized manifest is deliberately not carried, keeping the task
payload independent of fragment count at the cost of that manifest read.
Version semantics are preserved: workers pin the snapshot the driver
planned against, and only the index coordinator steps that must observe
worker output reopen at latest.

Known limitation: workers start from the storage options the driver
resolved. pylance installs its namespace refresh provider only when those
options are present, and refresh fires on expires_at_millis, so tables
whose catalog vends no expiry metadata will not refresh mid-task. Full
credential refresh is daft-engine#53. Mem-WAL still rejects namespace writes (daft-engine#54)
and builds a uri-only context. Scan keeps its existing open_kwargs path
untouched.

Claude-Session: https://claude.ai/code/session_01Ss3gSAbK3FwfPmWqk1CSbJ
… declare race

Two review findings.

Scan workers were not pinned to the version the driver planned against.
construct_lance_dataset_handle stored the caller's `version` argument in
open_kwargs, which defaults to None, and open_kwargs is exactly what
crosses to scan workers -- so `version=None` there means every task
independently opens latest. Reproduced both failure modes: a compaction
landing between planning and execution leaves workers looking for
fragment ids that no longer exist (planned [0,1,2], worker sees [3],
get_fragment(0) returns None), and an overwrite silently substitutes
different data with no error at all (driver planned 3 rows, worker read
1 different row).

open_kwargs now carries the resolved numeric `dataset.version`, and
`asof` is dropped since it is an input to that resolution rather than
something workers should re-evaluate. pylance lets `version` win when
both are passed, so dropping it is belt-and-braces rather than load
bearing. This is the same invariant DatasetOpenContext already enforces
for the maintenance paths; scan was left out when that change
deliberately avoided touching the scan state model.

Overwrite could also lose a declare race: when the initial describe
raises TableNotFoundError but a rival writer declares before our own
declare_table lands, TableAlreadyExistsError propagated and the
overwrite failed. Overwrite targets whatever exists now, so it
re-describes on that conflict. `create` is unchanged -- for it the
conflict is the correct answer, and a test pins that distinction.

Claude-Session: https://claude.ai/code/session_01Ss3gSAbK3FwfPmWqk1CSbJ
@universalmind303
universalmind303 merged commit 0452497 into daft-engine:main Jul 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Lance Namespace table addressing (catalog-managed Lance tables, e.g. Gravitino Lance REST server)

2 participants