fix(spark): propagate per-read .option() storage credentials into path-based loadTable#666
fix(spark): propagate per-read .option() storage credentials into path-based loadTable#666sezruby wants to merge 3 commits into
Conversation
…h-based loadTable Path-based reads that pass storage credentials via the DataFrame .option(...) API (e.g. an Azure SAS token) failed with an Azure Managed Identity (IMDS 169.254.169.254) token error. LanceDataSource implements SupportsCatalogOptions, so Spark calls extractIdentifier(options) -> catalog.loadTable(identifier) and forwards only the Identifier. LanceIdentifier kept just the location, so loadTableFromPath rebuilt storage options from catalogConfig alone -- empty for per-read credentials -- and the native open fell back to object_store's default Azure credential chain (MSI). Carry the per-read options on LanceIdentifier and thread them through loadTableFromPath via a new Utils.createReadOptions overload, seeded before withCatalogDefaults so per-read options override catalog storage options. Fixes lance-format#665 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ath key
Address review feedback:
- Apply the same per-read .option() credential propagation to tableExistsAtPath,
which had the identical drop -> Azure MSI fallback bug as loadTableFromPath.
Extract a shared getPathOptions(Identifier) helper used by both.
- Strip the dataset-URI key ("path") from per-read options before seeding the
read options, so it does not leak into the native storage_options map
(related to lance-format#520).
- Document the per-read-overrides-catalog precedence on the LanceIdentifier
constructor; assert the "path" key is not present in storage options; warn in
the integration test against reverting to AZURE_STORAGE_SAS_TOKEN (would defeat
the regression guard, see lance-format#665).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Gentle ping 🙂 this one's been sitting without a reviewer. It's green (all checks pass, spotless/checkstyle clean) and mergeable. Quick recap: path-based reads that pass storage creds via @geruh @LuciferYang @fangbo would any of you have a few minutes to take a look? Happy to adjust anything. Thanks! |
| * override catalog-level storage options so path-based access can supply credentials without a | ||
| * catalog being configured. | ||
| */ | ||
| private static Map<String, String> getPathOptions(Identifier ident) { |
There was a problem hiding this comment.
getPathOptions(ident) is only consumed on the read path (loadTableFromPath, tableExistsAtPath). Since extractIdentifier is shared by reads and writes, the other path-based sites that also receive the credential-carrying Identifier still pass Optional.empty() and use catalogConfig.getStorageOptions() only:
- createTableAtPath (native write driven by readOptions.getStorageOptions())
- stageCreateAtPath / stageReplaceAtPath / stageCreateOrReplaceAtPath (StagedCommitOptions.pathBased(catalogConfig.getStorageOptions(), ...))
- dropTableAtPath (Dataset.drop(uri, catalogConfig.getStorageOptions()))
- resolveIdentifier (reached on the path-based flow via alterTable; stageReplace early-returns to stageReplaceAtPath before it)
So .option()-supplied credentials (e.g. an Azure SAS) are dropped on the write/drop/alter path-based flows and the native open falls back to the default credential chain (Azure MSI). This is pre-existing behavior, not a regression from this PR. But this PR does introduce an intra-operation inconsistency: stageCreateOrReplaceAtPath now authenticates the existence probe (fixed tableExistsAtPath) with the SAS, then opens the same dataset without it — the probe passes and the open fails, which is hard to diagnose.
Please either thread getPathOptions(ident) into all path-based open sites in this PR (for lanceDatasetExists / registerExistingTable, which take a bare String location, pass the options down from the caller), or explicitly declare the write/drop/alter paths out of scope here and file a follow-up issue.
| // map is handled separately (see lance-format/lance-spark#520). | ||
| if (pathOptions != null && !pathOptions.isEmpty()) { | ||
| Map<String, String> seedOptions = new HashMap<>(pathOptions); | ||
| seedOptions.remove(LanceSparkReadOptions.CONFIG_DATASET_URI); |
There was a problem hiding this comment.
This strips only the "path" key. builder.fromOptions(seedOptions) then stores the whole seed map into storageOptions (parseTypedFlags only reads keys, never removes them), so per-read typed flags like version / batch_size / block_size remain in the map forwarded to the native object store via getStorageOptions(). The comment above acknowledges the cleanup is deferred to #520, but this PR widens the leak surface through the new pathOptions channel. Fine to defer — please just call out the known limitation in the PR description (or strip the recognized typed keys here too).
| * | ||
| * <pre> | ||
| * AZURE_ABFSS_URI e.g. abfss://<fs>@<account>.dfs.core.windows.net/path/to/ds.lance | ||
| * AZURE_STORAGE_SAS_TOKEN the SAS query string (no leading '?') |
There was a problem hiding this comment.
This javadoc block has two bugs that both make the regression guard silently no-op:
- The env-var usage block (this line) documents AZURE_STORAGE_SAS_TOKEN, but the test is gated by @EnabledIfEnvironmentVariable(named="LANCE_IT_SAS") and reads System.getenv("LANCE_IT_SAS"). A developer following the doc exports AZURE_STORAGE_SAS_TOKEN, LANCE_IT_SAS stays unset, and the test is silently skipped — they think the guard ran when it did not. (This is exactly what the in-body comment "Do not simplify this back to AZURE_STORAGE_SAS_TOKEN" warns against.)
- The "Run with" command uses -pl lance-spark-base_2.12, but this test lives in lance-spark-3.5_2.12, so that module contains no such test and -Dtest=AbfssSasReadIntegrationTest matches nothing and reports success with 0 tests run.
Please fix both: use LANCE_IT_SAS in the usage block and the run command, and change the module to -pl lance-spark-3.5_2.12.
…st doc - stageCreateOrReplaceAtPath: thread getPathOptions(ident) into the open and the staged commit so they use the same per-read .option() credentials as the existence probe (tableExistsAtPath). Previously the probe authenticated with the SAS while the open/commit fell back to the default credential chain, which is hard to diagnose. The remaining write/drop/alter path-based sites are tracked as a follow-up. - AbfssSasReadIntegrationTest javadoc: document LANCE_IT_SAS (not AZURE_STORAGE_SAS_TOKEN, which core's env fallback would mask) and correct the run command module to lance-spark-3.5_2.12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the careful review @LuciferYang! Addressed all three in 66a723f:
All checks green, spotless/checkstyle clean. |
|
Thanks for the detailed review @LuciferYang. Pushed in 66a723f:
CI is green across all Spark/Scala matrices. Mind another look when you have time? cc @hamersaw for maintainer visibility. |
|
@sezruby thanks for the PR! Looking into this a bit I have some concerns about piggybacking on the Is there something specific about your use-case where we can't make one of ^^^ work? A code snippet for basically an ephemeral catalog could be something like: |
|
Thanks @hamersaw — fair framing, and you're right about the mechanics: The gap your three alternatives don't cover is per-read / per-dataset credentials within a single session:
So the ask isn't "auth without a catalog" in general — it's a per-read credential channel for the multi-tenant / no-managed-identity case, which mirrors the standard Spark idiom for storage creds on file sources ( I'd split this into two questions:
Which would you prefer? I'll adjust accordingly. |
|
@sezruby I guess I'm leaning towards saying we don't support per-read creds. This seems to be the precedent set by all other Spark connectors using |
|
@hamersaw Thanks, catalog-config works for our immediate case, so we'll use that for now. On whether one of the three always suffices:
So: catalog for now, but complex workloads read different datasets on the same account/container with distinct short-lived creds by path, and there's no way to express that through catalog/env/MSI. I'm flexible on mechanism, the identifier carry, or delivering path reads through the DSv2 getTable options path. Would you be open to supporting the per-read case or, at minimum, failing fast with a clear message instead of the MSI red herring? -- |
I agree, it's the fact that we use
If this can work using the existing catalog approach, that's the cleanest solution I think. A clear error message would certainly help this! |
Problem
Path-based reads that pass storage credentials via the DataFrame
.option(...)API(e.g. an Azure SAS token) fail with an Azure Managed Identity (IMDS
169.254.169.254)token error. Fixes #665.
LanceDataSourceimplementsSupportsCatalogOptions, so Spark callsextractIdentifier(options)→catalog.loadTable(identifier)and forwards only theIdentifier.LanceIdentifierkept just the location, so the path-based opens rebuiltstorage options from
catalogConfigalone (empty for per-read credentials), and thenative open fell back to object_store's default Azure credential chain (MSI).
Change
LanceIdentifiergains an optional immutableoptionsmap (back-compat 1-argconstructor retained). On the load path these options override catalog-level
storage.*defaults on conflict; catalog options still fill gaps.LanceDataSource.extractIdentifiercaptures the reader options onto the identifier.Utils.createReadOptionsgets an overload takingpathOptions, seeded beforewithCatalogDefaultsto get that precedence. The dataset-URI key (path) is strippedfrom the seed so it does not leak into the native
storage_optionsmap (related tofix(spark): strip recognized typed options from Rust storage_options map #520, which removes the other recognized typed keys).
getPathOptions(Identifier)helper:loadTableFromPath(the time-travel resolution open and the main open), andtableExistsAtPath, which had the identical drop → MSI bug and is on theCREATE TABLE IF NOT EXISTS/ overwrite path.Credential precedence on path-based access: reader
.option(...)> catalogstorage.*defaults.Tests
LanceIdentifierOptionsTest(unit, base module): identifier carries optionsimmutably; null → empty; per-read options reach
getStorageOptions(); per-readoverrides catalog default while catalog default still fills gaps; the
pathkey isnot present in the storage options.
AbfssSasReadIntegrationTest(3.5 module,@EnabledIfEnvironmentVariable-gated so CIskips it without creds): reads a real
abfss://dataset with the SAS supplied only via.option(...). Verified locally: fails onmainwith the MSI error(
GET http://169.254.169.254/.../oauth2/token), passes with this change. The testreads the SAS from a neutral env var (
LANCE_IT_SAS) so lance core'sAZURE_STORAGE_*env fallback can't mask the connector behavior. Relates to Run test against Azure #241.
Scope and known limitations
.option()credentials through thepath-based read flows (
loadTableFromPath,tableExistsAtPath) and intostageCreateOrReplaceAtPath(so its SAS-authed existence probe and its open/commit agree).The remaining path-based write / drop / alter flows (
createTableAtPath,stageCreateAtPath,stageReplaceAtPath,dropTableAtPath,resolveIdentifier, and thebare-
StringhelperslanceDatasetExists/registerExistingTable) still drop.option()creds. This is pre-existing behavior, not a regression; tracked as a follow-up in Thread per-read .option() storage credentials through path-based write/drop/alter flows #683 to keep
this PR scoped to the read path.
pathOptionschannel strips only the dataset-URI (path) keybefore forwarding to the native
storage_options; other recognized per-read typed flags(
version,batch_size,block_size, ...) still pass through, same as the existing.option()handling. fix(spark): strip recognized typed options from Rust storage_options map #520 removes those keys; this PR does not duplicate that work.Notes
functional overlap. If fix(catalog): propagate namespace storage options into read options #522 merges first, the path-based call sites become
createPathBasedReadOptions(...); this patch rebases by adding thepathOptionsoverload there instead (trivial).
🤖 Generated with Claude Code