Skip to content

[SPARK-58021][CONNECT] Add local server pool filesystem storage - #57685

Closed
ericm-db wants to merge 3 commits into
apache:masterfrom
ericm-db:local-connect-pool-storage
Closed

[SPARK-58021][CONNECT] Add local server pool filesystem storage#57685
ericm-db wants to merge 3 commits into
apache:masterfrom
ericm-db:local-connect-pool-storage

Conversation

@ericm-db

@ericm-db ericm-db commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This is layer 2 of the seven-PR local Connect pool stack:

#57684 -> #57685 -> #57907 -> #57686 -> #57687 -> #57102 -> #57688

The review unit introduced here is commit 23f806b64ad.

This layer adds the filesystem-backed storage foundation for pool members:

  • stable state-file paths keyed by member ID and per-member directories;
  • an overridable private pool directory under the per-user runtime directory;
  • a per-pool cross-process POSIX file lock;
  • private directory, lock-file, and JSON state-file permissions; and
  • locked helpers for listing, reading, writing, renaming, and removing member state.

Member validation, compatibility fingerprints, and atomic claiming are isolated in #57907.
Process lifecycle, acquisition, SparkSession integration, and JIT warmup remain in later PRs.

Why are the changes needed?

The pool needs a small, independently reviewable state model before adding compatibility checks,
claiming, and process supervision. Keeping this layer limited to path layout, locking, and state
file access makes its filesystem and concurrency contract reviewable on its own.

Does this PR introduce any user-facing change?

No. The storage model is internal and is not wired into SparkSession in this layer.

How was this patch tested?

Added three focused tests covering directory selection, private permissions and malformed JSON,
and cross-process lock contention.

python/run-tests --testnames pyspark.sql.tests.connect.test_connect_local_server_pool

These cases passed on Python 3.11 as part of the combined suite before the stack was split. The
rebuilt commit passed git diff --check, Python AST parsing, and changed-line ASCII and 100-column
checks.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Fable 5) and OpenAI Codex (GPT-5)

@HyukjinKwon HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

0 blocking, 0 non-blocking, 0 nits.
High-quality, defensively-designed pool storage; concurrency and crash-safety handled carefully. No issues found.

Verification

Verified the concurrency discipline: every mutating accessor asserts the exclusive lock is held (_assert_locked); state transitions use atomic os.rename; read_json returns None on OSError/ValueError so partial writes are treated as absent and reaped; _pid_alive is zombie-aware on Linux so dead-client reclamation is correct. Build/test: python/run-tests --testnames 'pyspark.sql.tests.connect.test_connect_local_server_pool'.

dtenedor pushed a commit that referenced this pull request Aug 10, 2026
### What changes were proposed in this pull request?

This is layer 1 of the six-PR local Connect pool stack:

#57684 -> #57685 -> #57686 -> #57687 -> #57102 -> #57688

This patch extracts the reusable pieces of local Spark Connect server startup from the persistent-server reuse path:

- expose the per-user runtime directory and startup seed configuration as module helpers;
- add `LocalConnectServer.start()` as the common lifecycle-owned launch entry point; and
- let the launcher use an ephemeral port and a precomputed startup configuration when requested.

The existing reuse path now calls the same `LocalConnectServer.start()` method. Focused tests cover seed configuration and delegation of the new launch options.

### Why are the changes needed?

The persistent reuse mode currently combines reusable local-server lifecycle handling with assumptions specific to its one fixed daemon. The single-use server pool needs the same secure configuration seeding, process launch, discovery, and readiness handling, but with independent runtime directories and ephemeral ports. Sharing one launch path keeps those behaviors consistent and isolates the larger feature from the already-working reuse implementation.

### Does this PR introduce _any_ user-facing change?

No.

### How was this patch tested?

Added focused unit coverage to `pyspark.sql.tests.connect.test_connect_local_server.LocalConnectServerReuseTests` and ran the full suite, including real daemon startup, reuse, session isolation, and static configuration seeding:

```bash
python -m unittest -v pyspark.sql.tests.connect.test_connect_local_server
```

All 15 tests passed. Ruff check, Ruff format check, and `git diff --check` passed.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Fable 5) and OpenAI Codex (GPT-5)

Closes #57684 from ericm-db/local-connect-pool-refactor.

Authored-by: Eric Marnadi <eric.marnadi@databricks.com>
Signed-off-by: Daniel Tenedorio <daniel.tenedorio@databricks.com>
dtenedor pushed a commit that referenced this pull request Aug 10, 2026
### What changes were proposed in this pull request?

This is layer 1 of the six-PR local Connect pool stack:

#57684 -> #57685 -> #57686 -> #57687 -> #57102 -> #57688

This patch extracts the reusable pieces of local Spark Connect server startup from the persistent-server reuse path:

- expose the per-user runtime directory and startup seed configuration as module helpers;
- add `LocalConnectServer.start()` as the common lifecycle-owned launch entry point; and
- let the launcher use an ephemeral port and a precomputed startup configuration when requested.

The existing reuse path now calls the same `LocalConnectServer.start()` method. Focused tests cover seed configuration and delegation of the new launch options.

### Why are the changes needed?

The persistent reuse mode currently combines reusable local-server lifecycle handling with assumptions specific to its one fixed daemon. The single-use server pool needs the same secure configuration seeding, process launch, discovery, and readiness handling, but with independent runtime directories and ephemeral ports. Sharing one launch path keeps those behaviors consistent and isolates the larger feature from the already-working reuse implementation.

### Does this PR introduce _any_ user-facing change?

No.

### How was this patch tested?

Added focused unit coverage to `pyspark.sql.tests.connect.test_connect_local_server.LocalConnectServerReuseTests` and ran the full suite, including real daemon startup, reuse, session isolation, and static configuration seeding:

```bash
python -m unittest -v pyspark.sql.tests.connect.test_connect_local_server
```

All 15 tests passed. Ruff check, Ruff format check, and `git diff --check` passed.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Fable 5) and OpenAI Codex (GPT-5)

Closes #57684 from ericm-db/local-connect-pool-refactor.

Authored-by: Eric Marnadi <eric.marnadi@databricks.com>
Signed-off-by: Daniel Tenedorio <daniel.tenedorio@databricks.com>
(cherry picked from commit 343fbc3)
Signed-off-by: Daniel Tenedorio <daniel.tenedorio@databricks.com>
@ericm-db
ericm-db force-pushed the local-connect-pool-storage branch from 09033d8 to 2b9ae30 Compare August 10, 2026 18:14
@ericm-db
ericm-db force-pushed the local-connect-pool-storage branch from 2b9ae30 to 23f806b Compare August 10, 2026 20:15
@ericm-db ericm-db changed the title [SPARK-58021][CONNECT] Add local server pool storage model [SPARK-58021][CONNECT] Add local server pool filesystem storage Aug 10, 2026

@dtenedor dtenedor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Findings

1. Nesting the context manager deadlocks silently. I confirmed this by running it — sequential re-entry works as the docstring promises, but a nested with on the same object hangs forever (killed at a 10s timeout). The second os.open creates a new open file description, so flock(LOCK_EX) blocks against the fd the same process already holds.

Nothing in this PR nests it, but four more layers build on this foundation, and acquisition-with-polling is precisely where someone writes a helper that enters the lock while a caller already holds it. The failure mode is the bad kind: no exception, just a wedged getOrCreate(). A one-line guard converts it into an immediate, obvious error and makes the docstring's "entered again after exiting" contract enforceable:

def __enter__(self) -> "PoolDirectory":
    import fcntl

    assert self._lock_fd is None, "PoolDirectory is not reentrant"
    os.makedirs(self.path, mode=0o700, exist_ok=True)

2. parse_entry's member- branch is looser than its documented contract. The member- check runs before the .json gate, so it swallows anything with that prefix. Probe results:

Entry Result
member-abc ('member', 'abc')
member-abc.json.swp ('member', 'abc.json.swp')
server-abc.json.swp (None, None)
server-.json ('server', '')
claimed-1234-.json ('claimed', '')

The docstring says non-matching entries — "the lock file, editor droppings, ..." — return (None, None), and that holds for every JSON kind but not for member-. A stray member-* file becomes a phantom uid in uids(), and it doesn't self-heal: remove_member_dir calls shutil.rmtree on a file with ignore_errors=True, so the dropping survives and the phantom reappears on every subsequent scan. The empty-uid cases are unreachable with generated uids but point the same direction. Validating the uid shape once inside parse_entry fixes all of these together.

3. parse_entry has no test at all. It's the only nontrivial logic in the module and it's a pure static function, so it's the cheapest thing here to test. The three tests you have cover directory selection, permissions plus malformed JSON, and cross-process lock contention; parse_entry, claiming_pid, uids, states, paths_of_kind, rename, remove, remove_member_dir, and the _assert_locked guard are all uncovered. For a PR whose stated purpose is to make the filesystem contract "reviewable on its own," a table-driven test over the filename grammar is the single highest-value addition — and it's what would have caught finding 2.

4. The filename grammar is parsed twice. parse_entry and claiming_pid each hand-parse claimed-<pid>-<uid> independently, so a future naming change can drift between them. Having parse_entry surface the pid, or claiming_pid delegate to it, keeps one parser.

5. states() silently collapses duplicates. found[kind] = path keeps whichever entry sorts last, and claimed is the one kind where two entries for a uid is conceivable (two client pids). The rename-based claiming in #57907 should make that structurally impossible, but since this layer is the contract, it's worth an assert or an explicit note — picking the wrong file would make the reaper read the wrong pid via claiming_pid and reclaim a live client's member.

6. The test skips are over-restrictive. is_remote_only() and should_test_connect are copied from the sibling suite, but this one starts no server, and local_server_pool.py imports only stdlib at module scope (the runtime_dir import is deferred). These pure-filesystem tests would pass fine in a remote-only build. The skip message "Requires Spark Connect test dependencies" also doesn't describe what is_remote_only gates.

Follow-up to the local server pool filesystem storage layer, addressing
review feedback:

- Guard PoolDirectory against re-entrant locking (a nested `with` on the
  same object would deadlock silently) with an explicit assertion.
- Validate the uid shape (nonempty lowercase hex) in parse_entry for every
  entry kind, so editor droppings (e.g. member-<uid>.json.swp) and empty
  stems (server-.json) no longer surface as phantom uids.
- Factor the claimed-<pid>-<uid> filename grammar into one _split_claimed
  helper shared by parse_entry and claiming_pid. The helper returns the pid
  unparsed so parse_entry, which classifies every directory entry, never
  raises; the isascii() guard keeps claiming_pid's int() total (str.isdigit()
  otherwise accepts characters int() rejects, e.g. superscripts).
- Assert states() sees at most one entry per kind, so two claimed entries
  for one uid surface instead of silently collapsing to whichever sorts last.
- Add table-driven tests for the filename grammar plus coverage for
  claiming_pid, the locked accessors, rename/remove/remove_member_dir, the
  lock-required guard, and the reentrancy guard.
- Drop the over-restrictive is_remote_only() test gate (these tests start no
  server and use only stdlib) and correct the skip message.

Co-authored-by: Isaac
@ericm-db
ericm-db requested a review from dtenedor August 11, 2026 18:20

@dtenedor dtenedor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the test coverage again and ran the tests, the testing looks good now.

Comment thread python/pyspark/sql/connect/local_server_pool.py Outdated
Comment thread python/pyspark/sql/connect/local_server_pool.py Outdated
Comment thread python/pyspark/sql/connect/local_server_pool.py Outdated
@dtenedor

Copy link
Copy Markdown
Contributor

LGTM, merging to master + 4.x

@dtenedor dtenedor closed this in 001e89c Aug 12, 2026
dtenedor pushed a commit that referenced this pull request Aug 12, 2026
### What changes were proposed in this pull request?

This is layer 2 of the seven-PR local Connect pool stack:

#57684 -> #57685 -> #57907 -> #57686 -> #57687 -> #57102 -> #57688

The review unit introduced here is commit `23f806b64ad`.

This layer adds the filesystem-backed storage foundation for pool members:

- stable state-file paths keyed by member ID and per-member directories;
- an overridable private pool directory under the per-user runtime directory;
- a per-pool cross-process POSIX file lock;
- private directory, lock-file, and JSON state-file permissions; and
- locked helpers for listing, reading, writing, renaming, and removing member state.

Member validation, compatibility fingerprints, and atomic claiming are isolated in #57907.
Process lifecycle, acquisition, SparkSession integration, and JIT warmup remain in later PRs.

### Why are the changes needed?

The pool needs a small, independently reviewable state model before adding compatibility checks,
claiming, and process supervision. Keeping this layer limited to path layout, locking, and state
file access makes its filesystem and concurrency contract reviewable on its own.

### Does this PR introduce _any_ user-facing change?

No. The storage model is internal and is not wired into SparkSession in this layer.

### How was this patch tested?

Added three focused tests covering directory selection, private permissions and malformed JSON,
and cross-process lock contention.

```bash
python/run-tests --testnames pyspark.sql.tests.connect.test_connect_local_server_pool
```

These cases passed on Python 3.11 as part of the combined suite before the stack was split. The
rebuilt commit passed `git diff --check`, Python AST parsing, and changed-line ASCII and 100-column
checks.

### Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Fable 5) and OpenAI Codex (GPT-5)

Closes #57685 from ericm-db/local-connect-pool-storage.

Authored-by: Eric Marnadi <eric.marnadi@databricks.com>
Signed-off-by: Daniel Tenedorio <daniel.tenedorio@databricks.com>
(cherry picked from commit 001e89c)
Signed-off-by: Daniel Tenedorio <daniel.tenedorio@databricks.com>
@dtenedor

Copy link
Copy Markdown
Contributor

Merge Summary:

Posted by merge_spark_pr.py

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.

3 participants