Skip to content

fix(none): Databricks Volumes check reads the configured volume - #816

Closed
paulkarayan wants to merge 5 commits into
mainfrom
pk/databricks-volumes-write-precheck
Closed

paulkarayan wants to merge 5 commits into
mainfrom
pk/databricks-volumes-write-precheck

Conversation

@paulkarayan

@paulkarayan paulkarayan commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

What & why

Problem: Customers setting up a Databricks Volumes destination got a green connection test for a volume they could not write to, and found out when the job failed at the upload node, after every file had already been partitioned, enriched, embedded and paid for. The check asked whether the principal was awake (current_user.me().active) and never whether it could write, so a volume that doesn't exist, a typo in the path, a principal without WRITE VOLUME, or a catalog or schema it can't USE all passed.

Change: The uploader's precheck now writes a zero-byte unstructured_precheck_<hex> file at the configured volume path through the same files.upload the run uses, then deletes it. Only a 401, 403 or 404 fails the check, and the error names the volume path. Databricks SDK errors are also classified by their HTTP status even when the SDK raises a more specific subclass, which the missing-volume case depends on.

Blast radius: 3/5 -- one connector's precheck, but it now writes (and deletes) a real file in the customer's volume, and the shared wrap_error classification changes for every Databricks Volumes connector.

Linked ticket

none. The source side of this connector got the equivalent fix in #794.

Why a write probe, and why only three status codes

current_user.me() proves the token and the host. It never reads upload_config.path, so it can't see anything Unity Catalog decides per object. The only call that collects the same verdict as the run is the run's own call, so the probe is files.upload at the configured path with overwrite=True, and it goes through the same client.

The fatal set is deliberately narrow. 403 means "you may not write here", 404 means the catalog, schema or volume doesn't exist, and 401 means the workspace rejected the token. A throttle, a Databricks-side 5xx, a dropped socket or an exception the SDK doesn't map is not an answer to the permission question, so it logs a warning and passes: no destination that works today starts failing its connection test.

The probe is deleted afterwards because a Unity Catalog volume is routinely read back by a table, an Auto Loader stream or another ingest job. WRITE VOLUME already covers the delete. If the delete fails anyway, the write question has already been answered, so it warns and names the file it left behind rather than failing the check.

me() still runs first, so a rejected token fails there with no write attempted. Its active flag is no longer asserted, which matches the source check; the write probe answers the question that flag stood in for.

The SDK subclass fix

The Databricks SDK raises the error-code class in preference to the status class, so a 404 carrying RESOURCE_DOES_NOT_EXIST arrives as ResourceDoesNotExist, a subclass of NotFound. Both the probe's status lookup and wrap_error matched the exact type, so that error read as "not a permission answer" and the missing-volume case this PR exists to catch passed anyway. The lookup now walks the MRO, and wrap_error goes through the same helper. The helper change isn't optional: without it the probe's fatal branch would get the raw SDK error back from wrap_error and re-raise its unredacted text.

Impact

  • Customers: a Databricks Volumes destination that can't write now fails its connection test with an error naming the volume path, instead of passing and failing the job at the upload node after the paid pipeline work. A destination that works today still passes, including when the probe hits a throttle or a Databricks outage. Each connection test now writes and removes one zero-byte file in the configured path; a consumer watching that path with file notifications can see the create event before the delete.
  • Internal: wrap_error is shared by the native, AWS, Azure and GCP Volumes connectors at precheck and run time on both source and destination; SDK error-code subclasses (ResourceDoesNotExist, InvalidParameterValue, RequestLimitExceeded, DataLoss and the rest) that used to escape as raw, unclassified errors now become UserError, RateLimitError or ProviderError like their parent status.
  • Wire contract / clients: the uploader precheck can now raise UserAuthError (401/403) or UserError (404) where it used to pass. wrap_error returns a typed error for SDK subclasses it used to return raw. Both are in the CHANGELOG.
  • Deployment target considerations: identical across SaaS, DI, in-VPC and on-prem, since the probe talks only to the customer's own Databricks workspace. Not exercised against a live workspace on any target.

Risk / rollback

The main risk is a live Files API answering in a way the mocked SDK doesn't: a missing USE CATALOG grant returning something outside 401/403/404 would warn and pass, which is the safe direction but leaves that case uncaught. Revert the PR to restore the old check.

How it was verified

The new precheck and classification tests were red on the pre-fix code and green after, and the full unit suite passes locally on the rebased branch. Nothing ran against a real Databricks workspace; see the waiver below.

Proof

Proof waived (environment) -- no reachable Databricks workspace or Unity Catalog volume from this machine, so the probe was never run against a live Databricks Files API. What is missing is a Databricks workspace with a Unity Catalog volume and a principal lacking WRITE VOLUME on it; what unblocks it is workspace credentials in the integration-test secret set, after which test/integration/connectors/databricks/test_volumes_native.py is the place to add a live destination-precheck case.

Repro (local). DatabricksVolumesUploader.precheck driven against a faked databricks.sdk WorkspaceClient, with files.upload raising the SDK's 403 PermissionDenied, then its 404 NotFound, for /Volumes/catalog/schema/volume/path. The precheck returned cleanly every time and never called files.upload: its only live call was current_user.me().

Failing tests on the pre-fix code (uv run --locked --no-sync pytest test/unit/connectors/databricks/test_volumes.py -q):

FAILED test_volumes.py::test_uploader_precheck_error_does_not_leak_raw_text_in_traceback
FAILED test_volumes.py::test_uploader_precheck_writes_a_probe_at_the_configured_path_and_removes_it
FAILED test_volumes.py::test_uploader_precheck_probes_the_volume_root_when_no_volume_path_is_set
FAILED test_volumes.py::test_uploader_precheck_raises_when_volume_write_is_not_granted
FAILED test_volumes.py::test_uploader_precheck_raises_when_volume_path_is_missing
FAILED test_volumes.py::test_uploader_precheck_passes_when_the_probe_cannot_be_cleaned_up

The rows that already passed before the fix, and had to stay green, are the no-false-failure guarantees: credentials rejected at me(), and a 429/500/503 or unrecognised probe failure passing with a warning.

Subclass fix, red on the write-probe commit alone:

WARNING  unstructured_ingest:volumes.py:293 skipping write-access precheck for /Volumes/catalog/schema/volume/path: the probe failed for a reason that is not a permission answer (ResourceDoesNotExist)
FAILED test_volumes.py::test_wrap_error_classifies_error_code_subclasses_by_their_status[ResourceDoesNotExist-UserError]
FAILED test_volumes.py::test_wrap_error_classifies_error_code_subclasses_by_their_status[InvalidParameterValue-UserError]
FAILED test_volumes.py::test_wrap_error_classifies_error_code_subclasses_by_their_status[RequestLimitExceeded-RateLimitError]
FAILED test_volumes.py::test_wrap_error_classifies_error_code_subclasses_by_their_status[DataLoss-ProviderError]
FAILED test_volumes.py::test_uploader_precheck_raises_when_a_404_subclass_says_the_volume_is_missing

After: every test in test/unit/connectors/databricks/test_volumes.py passes, and uv run --locked --no-sync pytest -n auto test/unit --ignore test/unit/unstructured -q passes.

Dependencies / merge order

none

🤖 Generated with Claude Code

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="test/unit/connectors/databricks/test_volumes.py">

<violation number="1" location="test/unit/connectors/databricks/test_volumes.py:302">
P2: This assertion does not prove that the probe is placed at the volume root because any descendant path also matches it. Include the required `unstructured_precheck_` filename prefix in the expected prefix so a nested-path regression fails.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread unstructured_ingest/processes/connectors/databricks/volumes.py
_uploader(mocker, client, volume_path="").precheck()

probe_path = client.files.upload.call_args.kwargs["file_path"]
assert probe_path.startswith("/Volumes/catalog/schema/volume/")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This assertion does not prove that the probe is placed at the volume root because any descendant path also matches it. Include the required unstructured_precheck_ filename prefix in the expected prefix so a nested-path regression fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/unit/connectors/databricks/test_volumes.py, line 302:

<comment>This assertion does not prove that the probe is placed at the volume root because any descendant path also matches it. Include the required `unstructured_precheck_` filename prefix in the expected prefix so a nested-path regression fails.</comment>

<file context>
@@ -200,3 +226,172 @@ def test_indexed_file_reports_modification_time_in_epoch_seconds(mocker: MockerF
+    _uploader(mocker, client, volume_path="").precheck()
+
+    probe_path = client.files.upload.call_args.kwargs["file_path"]
+    assert probe_path.startswith("/Volumes/catalog/schema/volume/")
+
+
</file context>
Suggested change
assert probe_path.startswith("/Volumes/catalog/schema/volume/")
assert probe_path.startswith("/Volumes/catalog/schema/volume/unstructured_precheck_")

@awalker4

Copy link
Copy Markdown
Contributor

The error-classification fix looks right to me. I checked each of the SDK's error-code subclasses against the locked SDK and they all map to their parent status. Two things before this merges, though. The first is why I'm holding it.

The precheck shouldn't write to the customer's volume

_verify_write_access creates a real file in the destination and then deletes it in a separate call. That means every Test Connection produces a visible create event, and when the delete fails the file stays there with only a warning. The description itself says why that matters for this destination: a Unity Catalog volume is often read by a table, an Auto Loader stream or another ingest job. A file-arrival trigger or a stream without a glob filter can pick up a zero-byte, extension-less file during the window between the upload and the delete. Destination prechecks in this repo have mostly avoided writing. The only prechecks that do write are fsspec's _empty marker, which is never cleaned up, SFTP, and the Delta Table directory marker. Those are older behavior we haven't copied forward, not a precedent. #811 set the standard for exactly this question: the real statement made harmless, with "nothing is written or removed even where the driver is in autocommit."

Unity Catalog can answer the same question without a write. volumes.read("<catalog>.<schema>.<volume>") covers a missing catalog, schema or volume (the 404 this PR exists to catch). grants.get_effective("VOLUME", full_name, principal=<me().user_name>) covers WRITE_VOLUME, plus USE_SCHEMA/USE_CATALOG on the parents. files.get_directory_metadata(path) covers a mistyped volume_path. #811 argued against reading grant tables for SQL because a catalog can drift from what the session is actually allowed to do. Here Unity Catalog is what makes the decision, so that argument doesn't carry over the same way. One thing I haven't verified: whether a principal can always read its own effective grants on a volume it doesn't own. If it can't, a 403 there should get the same warn-and-pass handling this PR already uses.

Aborted stops retrying

Before this change, an unmapped SDK subclass left the plugin as a 500, and the platform retries a 500. With the MRO walk, the 400 and 409 subclasses become UserError (422), and the platform treats a non-429 4xx as terminal. For most of them that's an improvement: a missing volume should fail fast and count against the customer. Aborted is the exception. Databricks uses it for concurrency conflicts, which are usually transient, and the uploader's overwrite=True upload to a shared output path seems like a plausible way to hit one. I couldn't confirm the Files API returns ABORTED for that race without a live workspace, so this is worth a second opinion. If it does, mapping Aborted to ProviderError in wrap_error keeps it retryable without undoing the rest of the fix.

@paulkarayan paulkarayan added the do-not-merge Operator hold: excluded from all PR automation label Sep 23, 2026
paulkarayan added a commit that referenced this pull request Sep 24, 2026
… DDL (#818)

## What & why

**Problem:** A Teradata destination user who cannot create the table the
job auto-creates still finds that out at upload time rather than at the
connector check, because the check asks a narrower question than the
upload does. The probe that shipped in 1.11.18 creates a
one-INTEGER-column table, while `create_destination()` runs the
connector's real DDL with `CLOB`, `VECTOR32`, `JSON` and a `PRIMARY
INDEX`. A right one of those column types needs and CREATE TABLE alone
does not carry is invisible to the check, so the credential passes and
the job fails partway through. Separately, a user whose per-workflow
table already exists and who has since lost CREATE TABLE is now refused
by that check with no way to act on the message, even though the upload
would have found the table and never created one.

**Change:** The probe runs `create_destination()`'s own statement under
the throwaway name, unqualified, so the rights it asks for are the
rights the upload needs. Teradata 3523 refuses alongside 3524, with a
message that does not claim CREATE TABLE is the missing right. Blank
Database and unset Table Name each add a sentence naming the field that
settles the refusal, so the customer has something to do. A refusal the
server already gave survives a failure tearing the session down, and
each precheck outcome gets its own log line.

**Blast radius:** 3/5 -- the connector check runs the real destination
DDL on the customer's Teradata; one connector, revert-safe.

## Linked ticket

none

Follow-up to
#815, which
merged before this work landed. This branch closes Trevor's review items
**2, 3, 4, 5, 6 (traceability), 7, 10, 11a and 11b** from
#815 (comment),
and the code half of item **1**. Items **9** and the body half of **1**
were fixed by editing the merged body of
#815. Item
**8** (version collision) and item **11c** (`TableKind = 'T'`) are
answered in that thread and not changed here; the reasoning is in
#815 (comment).

**Provenance, so the diff is not mis-read.** The first commit here,
`7e6f164a` "probe Teradata CREATE with the real destination DDL", was
written in a separate earlier session against the branch of
#815 and was
still unpushed when that PR merged. It is not new work and it is not
mine; this branch carries it forward unchanged, cherry-picked onto
`main` (the trees matched exactly, so it applied clean). The second
commit is the new work: the Table Name hint, its tests, and the
CHANGELOG restructure.

Client-facing follow-up: a customer configured a Teradata destination
with a non-admin user and a blank Database field; it passed the
connector check and the run auto-created its table in a database the
user had not chosen.

## Impact

- **Customers:** Teradata destination users whose table is auto-created
are now refused for the rights the real CREATE needs, not just CREATE
TABLE on the database: a `VECTOR32` column refused UDTUSAGE on
`SYSUDTLIB` comes back as 3523 and is caught here instead of failing the
job. Refusal messages become actionable rather than dead ends. With the
Database field blank the message says the database it names is only the
session default, so a DBA does not grant rights on a database nobody
chose. With no Table Name configured the message says the check had to
create a table to test the right, and that setting Table Name makes the
check look the table up instead. That is the escape hatch for the one
case this check can refuse a credential the job would not have needed.
Destinations with a configured, existing table are unchanged: they still
get the 1.11.16 INSERT/DELETE probe.
- **Internal (devs / ops / other teams):** support gets one INFO line
per precheck outcome (skipped, refused, created, inconclusive) instead
of a single line that could not distinguish "probe passed" from
"skipped, table exists", and `create_destination()` now names the
database on the line that actually creates the table. The probe table is
named in a log line *before* the CREATE runs, so a pod killed between
CREATE and DROP leaves a traceable name. No other connector is touched;
`_write_denied_message` is called with the same arguments 1.11.18
introduced.
- **Wire contract / clients:** the precheck `UserError` (422) messages
gain trailing sentences (the blank-Database hint from 1.11.18, plus the
new Table Name hint). Teradata **3523 is a new refusal code** for the
destination precheck: a credential that previously passed the CREATE
probe and failed at upload now gets a 422 at check time.
`test_teradata.py:1329` pins the upload-path wording and is unchanged.
Nothing outside the `teradata` connector changes; the shared
`_USER_FAULT_TERADATA_CODES` map is not edited by this branch.
- **Deployment target considerations:** the same on every target,
because this is library code in the connector rather than anything
deployed on its own. It runs wherever a Teradata destination precheck
runs, which is per job where the preflight gate is enabled and on every
UI test-connection. Not verified on any target with a real Teradata (see
Proof).

## Risk / rollback

- The probe now runs the real DDL, which is a larger statement than the
one-column stand-in. It still creates and drops one table and commits
under the driver's autocommit, so the leak window is unchanged: two
statements. A failed DROP still passes the check and names the leftover.
- 3523 refusing is the one genuinely wider behaviour here. It is bounded
to the CREATE probe's own `except`, and 3523 means a refused right and
nothing else, so it cannot turn a missing table into a permissions
error.
- No sweep of leftover probe tables was added, deliberately: bounded
wrong it can drop a concurrent precheck's in-flight probe under the same
credential.
- Revert the PR to back it out; nothing persists, no migration.

## How it was verified

- `make test-unit`: 1843 passed. The teradata module alone is 168, up
from 166, the two new ones being the Table Name hint firing when the
field is unset and staying off when it is set.
- `make check` (`ruff check .`): all checks passed. `ruff format` was
deliberately **not** run: the file was already format-dirty at `HEAD`
before any edit here (verified with `git show HEAD:<file> | ruff format
--check`), 17 files repo-wide fail `ruff format --check`, and CI's lint
job runs `ruff check` only. Reformatting would have been unrelated churn
on a review diff.
- The integration test asserts the precheck leaves no
`unstructured_precheck_%` table behind, with `_` escaped in the LIKE so
the prefix cannot match by accident. It is gated on `TERADATA_*`
credentials and skipped without them, so it did not run here.
- NOT verified against a live Teradata. The 3523 classification comes
from Teradata's Database Messages manual and the existing
`_USER_FAULT_TERADATA_CODES`, not from a server.

## Proof

> **Proof waived (environment)** -- no live Teradata is reachable from
this machine. There are no `TERADATA_*` credentials locally and no
Vantage SQL listener answers here. Unblocked by a Teradata Vantage (a
ClearScape trial works) with an admin who can create a user lacking
CREATE TABLE in one database, and a second lacking UDTUSAGE on
`SYSUDTLIB`; then run the CREATE-probe red/green for both 3524 and 3523
against `main` and this branch, and land the restricted-user integration
test modelled on
`test_postgres_destination_precheck_refuses_a_credential_that_cannot_write`
(`test_postgres.py:247`). That test is owed and is not in this branch.

Unit-level repro-first record for the new behaviour in this branch (the
Table Name hint):

**Red** -- test written before the fix, against this branch's first
commit:

```
$ uv run --locked --no-sync pytest -q test/unit/connectors/sql/test_teradata.py -k table_name_field
E   assert 'No Table Name is configured' in "The destination credentials can connect to the
    database but do not have CREATE TABLE permission on database 'test_db'. Records would fail
    to write. Grant CREATE TABLE on that database to the user this connector authenticates as."
FAILED test_teradata_precheck_refusal_points_at_the_table_name_field_when_it_is_unset
1 failed, 167 deselected in 0.41s
```

**Green** -- after adding `_unset_table_hint()` and appending it to both
refusal messages:

```
$ uv run --locked --no-sync pytest -q test/unit/connectors/sql/test_teradata.py
168 passed in 0.64s

$ make test-unit
1843 passed, 1 warning in 37.62s

$ make check
uv run --locked --no-sync ruff check .
All checks passed!
```

The second test in that pair
(`..._omits_the_table_name_hint_when_one_is_configured`) passes both
before and after by construction; it is there to pin that the hint stays
off for a customer who already set the field.

## Dependencies / merge order

none

Note for whoever merges: this takes **1.11.20**, renumbered from 1.11.19
when #814 shipped that version on main.
#816 is still
at 1.11.17 while `main` is at 1.11.19, and is currently conflicting.
`scripts/version-sync.sh:125` only rejects a version *equal* to main's,
so a resolution there that keeps 1.11.17 goes backwards and CI will not
catch it. Whichever of these two lands second needs its version
re-checked by hand, not just its conflict resolved.

---
<sub>Generated from Orca worktree `pk-teradata-precheck-followup`
(branch `pk/teradata-precheck-review-followup`).</sub>


🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/Unstructured-IO/unstructured-ingest/pull/818?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: paulkarayan <pk@unstructured.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@paulkarayan paulkarayan removed the do-not-merge Operator hold: excluded from all PR automation label Sep 24, 2026
paulkarayan and others added 5 commits September 24, 2026 13:33
…write access

The Volumes uploader precheck asserted `current_user.me().active`. That
proves the token authenticates and the principal is enabled, and nothing
else: it never read `upload_config.path`. So a volume that does not
exist, a path typo, a principal with no WRITE VOLUME grant, and a
catalog or schema the principal cannot USE all reported a green
connector, and the run then failed at `files.upload` -- after the
customer had already paid to partition, enrich and embed every file in
it.

precheck now does what the indexer has done since PLU-637: `me()` for
the credentials and the host, then a probe at the configured path. The
probe is the write itself, a zero-byte `unstructured_precheck_<hex>`
file through the same `files.upload` the run uses, so it collects the
same verdict. Unlike the fsspec uploader, which leaves its `_empty`
marker behind forever, this deletes the probe: a Unity Catalog volume is
routinely read back by a table or another job, so a stray file there is
not inert. WRITE VOLUME already covers the delete, so cleanup asks for
no grant the run does not need, and a failed cleanup warns rather than
failing a check whose question has already been answered.

Only a 401, 403 or 404 fails the check. A throttle, a Databricks-side
5xx, or an exception the SDK status mapping does not recognise is logged
and allowed through, so no destination that works today starts failing
on a check that was never meant to answer that question. The error names
the volume path, which is the one thing the old check hid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HTTP status

The SDK raises the error-code class (ResourceDoesNotExist) in preference to
the status class (NotFound), and both _databricks_status_code and wrap_error
matched the exact type, so a missing volume reached the write probe as
"not a permission answer" and passed. Walk the MRO, and route wrap_error
through the same helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The destination precheck proved write access by writing: a zero-byte probe
file at the configured path, deleted again in a second call. A Unity Catalog
volume is routinely watched, and an Auto Loader stream or a file-arrival
trigger without a glob filter cannot tell that probe from real input during
the window before the delete -- or ever, if the delete fails.

Unity Catalog answers the same question by reading. volumes.read on the
three-level name covers the missing catalog, schema or volume; grants
.get_effective covers WRITE VOLUME for this principal; get_directory_metadata
covers the configured volume_path. Nothing is written and nothing is removed.

Only one thing fails the check now: Unity Catalog saying the volume does not
exist, and only when the grant lookup did not already resolve that same
volume. Everything else warns and passes, which is narrower than it looks:
WRITE VOLUME does not imply READ VOLUME, so a 403 on a read is not a write
denial, and the effective-permissions API is documented to expand parent
securables and says nothing about group membership, so a WRITE_VOLUME missing
from its answer may still be held through a group.

me() is now filtered the same way as the rest: a 429, a 5xx or a socket error
on the identity call warns and passes instead of aborting the check, while a
401, 403 or 404 still refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Walking the MRO gave the SDK's error-code subclasses their parent status, which
also pulled the 409 subclasses into the 400-499 branch and made them UserError.
The platform treats a non-429 4xx as terminal, so Aborted stopped being retried.

Aborted is Databricks losing a race with itself -- two writers on one path, a
sequencer check that did not hold -- not the customer's input and not our bug,
and a retry is usually what clears it. It is classified as ProviderError, which
keeps it retryable; the other 409 subclasses are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… they should

The two warn-and-pass cases asserted only that precheck did not raise, which
also passes if the check silently did nothing. Assert what each one proves: an
identity call with no answer stops before the Unity Catalog reads, and a volume
read that could not answer was really attempted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@paulkarayan
paulkarayan force-pushed the pk/databricks-volumes-write-precheck branch from e727263 to d547916 Compare September 24, 2026 08:05
@paulkarayan paulkarayan changed the title fix(none): Databricks Volumes destination check proves it can write fix(none): Databricks Volumes check reads the configured volume Sep 24, 2026
@paulkarayan

Copy link
Copy Markdown
Contributor Author

Taken, on both. The write probe is gone; the precheck reads Unity Catalog now, and nothing is created or removed in the volume.

I checked your three calls against the locked SDK (0.88.0) rather than the docs, and all three are there:

  • VolumesAPI.read(name) -- the three-level name. Its own docstring is the source of the 403 answer below: it needs READ VOLUME plus USE_CATALOG / USE_SCHEMA on the parents.
  • GrantsAPI.get_effective(securable_type: str, full_name: str, *, principal=None) -- returns EffectivePermissionsList.privilege_assignments[].privileges[].privilege, a Privilege enum whose members include WRITE_VOLUME and ALL_PRIVILEGES. securable_type is a plain str in 0.88, so it is "VOLUME".
  • FilesAPI.get_directory_metadata(directory_path: str) -- a HEAD on /api/2.0/fs/directories/..., no body.

On the unknown you flagged: yes, a 403 there is warn-and-pass. The answer turned out to be broader than the grants call, though. Databricks' privilege reference gives WRITE VOLUME as "add, modify, or delete files inside a volume" and does not make READ VOLUME a prerequisite the way WRITE FILES requires READ FILES on external locations. So a principal that can write here can be refused any of the reads, and a 403 from any of them is not a write denial. All three treat it as a warning.

That leaves the refusal surface narrower than the probe's, and I would rather say so than paper over it:

  • 404 from volumes.read fails the check -- the missing catalog, schema or volume, which is what this PR is for. One guard on it: the grant lookup runs first, and a lookup that resolved the securable proves the volume exists, so a 404 after that is about visibility and only warns.
  • Missing WRITE_VOLUME warns, it does not fail. The effective-permissions API is documented as expanding privileges inherited from parent securables; it says nothing about expanding group membership, and a grant held through a group is the normal case here. I could not verify that against a live workspace, so acting on an absent privilege would risk exactly the false failure the rest of the check avoids. It logs the volume and the privilege to grant.
  • 404 from get_directory_metadata warns. The uploader writes path/<relative_path>.json and never creates a directory, so the Files API is already relied on to create the parents at upload time; a directory that is not there yet is not a broken destination. A typo is still worth saying out loud.

If a live workspace later shows that get_effective does expand groups, that leg can be promoted to a refusal -- every one of these is set in the direction where a live answer can only let the check refuse more, never less.

On Aborted: agreed, and it is mapped to ProviderError. Reasoning, since it is a classification and not just a status: the audience is the provider (Databricks losing a race with itself, a sequencer check that did not hold -- not the customer's input and not our bug), and it is retryable, which is what ProviderError buys, since the platform retries a 5xx and treats a non-429 4xx as terminal. The other 409 subclasses stay where the MRO walk puts them. I could not confirm against a live Files API that an overwrite=True race actually surfaces as ABORTED either, but the cost of the two mistakes is not symmetric: mapped as a user 4xx a real one drops the write, and mapped as a provider error a phantom one costs a retry.

Also fixed the two cubic P2s: a 429 or 5xx on me() now warns and passes like everything else rather than aborting the check (401/403/404 still refuse), and the probe-placement assertion is gone with the probe -- the reads are asserted on their exact arguments instead.

This branch was successfully deployed

1 active deployment
ci — d547916f Deployed Sep 24, 2026 by paulkarayan via test_ingest_help (3.11) #4249
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.

2 participants