HyukjinKwon commented on code in PR #59540:
URL: https://github.com/apache/spark/pull/59540#discussion_r4324072429
##########
python/pyspark/sql/connect/dataframe.py:
##########
@@ -2417,26 +2418,47 @@ def offset(self, num: int) -> ParentDataFrame:
return DataFrame(plan.Offset(child=self._plan, offset=num),
session=self._session)
def checkpoint(self, eager: bool = True) -> ParentDataFrame:
- cmd = plan.Checkpoint(child=self._plan, local=False, eager=eager)
+ relation_id = str(uuid.uuid4())
+ cmd = plan.Checkpoint(
+ child=self._plan,
+ local=False,
+ eager=eager,
+ relation_id=relation_id,
+ )
_, properties, self._execution_info =
self._session.client.execute_command(
cmd.command(self._session.client)
)
assert "checkpoint_command_result" in properties
- checkpointed = properties["checkpoint_command_result"]
- assert isinstance(checkpointed._plan, plan.CachedRemoteRelation)
- return checkpointed
+ assert properties["checkpoint_command_result"] == relation_id, (
Review Comment:
**Nit (P3):** If the response carries a different ID than the one requested
(for example from a released server that ignores the new field), the server has
already run the checkpoint and cached the result under the returned ID. This
`assert` fails before anything wraps that ID in a `CachedRemoteRelation`, and
that relation's finalizer is the client's only release path, so the server-side
entry is never removed and stays pinned until the session closes. For an eager
`localCheckpoint()` those are persisted blocks on the executors, and every
retry adds another.
Because this is a bare `assert`, `python -O` removes the check: the method
then returns a DataFrame over the requested ID, which the server never cached,
so its first action fails with `CONNECT_INVALID_PLAN.DATAFRAME_NOT_FOUND` while
the real entry is still orphaned. The same applies to `localCheckpoint()` at
line 2455. This applies as long as the methods keep rejecting a mismatched ID.
**Verification:**
- **Regression:** When the response carries a relation ID other than the
requested one (for example, because the server did not honor the request's
relation_id), checkpoint() and localCheckpoint() each raise
PySparkAssertionError, and afterwards the server-side session cache no longer
holds the returned ID.
- **Inspection:** Neither method relies on an assert statement to validate
the checkpoint response, so Python's optimization mode cannot remove the
rejection.
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala:
##########
@@ -4083,6 +4083,14 @@ class SparkConnectPlanner(
private def handleCheckpointCommand(
checkpointCommand: CheckpointCommand,
responseObserver: StreamObserver[proto.ExecutePlanResponse]): Unit = {
+ val dfId = if (checkpointCommand.hasRelationId) {
+ if (checkpointCommand.getRelationId.isEmpty) {
+ throw
InvalidPlanInput("CONNECT_INVALID_PLAN.CHECKPOINT_RELATION_ID_EMPTY", Map.empty)
+ }
+ checkpointCommand.getRelationId
Review Comment:
**Non-blocking (P2):** This accepts any non-empty supplied ID, including one
that is already cached in this session. The handler then plans the relation and
runs the checkpoint (eager by default), and only
`sessionHolder.cacheDataFrameById` notices the existing entry, via
`putIfAbsent`, and throws `SparkException.internalError`. The client gets
`INTERNAL_ERROR` (SQLSTATE XX000), which Spark's error guide reserves for bugs
in Spark itself, even though `commands.proto` now documents uniqueness as a
client precondition and the empty-ID case just above gets a
`CONNECT_INVALID_PLAN` condition. Clients and libraries can't tell this apart
from a genuine internal failure without matching the message text, and a client
that resends a checkpoint after losing the first response recomputes the whole
checkpoint (reliable-checkpoint files or persisted local-checkpoint blocks)
only to have it discarded. The existing entry is not overwritten, so nothing is
corrupted.
The description says the existing duplicate-ID error is kept on purpose, but
clients could not reach it before because the server always chose the ID. Once
this ships, the error becomes part of the new field's observable contract.
Requests without `relation_id` are unaffected.
**Recommended change:** In handleCheckpointCommand, when relation_id is
supplied and already identifies a DataFrame in the session cache, reject it
before the relation is transformed or checkpointed. Use a new user-facing
CONNECT_INVALID_PLAN subcondition that names the ID. If the final atomic
insertion of a supplied ID loses a race, raise that same condition instead of
INTERNAL_ERROR. Server-generated IDs (absent field and foreachBatch) keep
cacheDataFrameById's INTERNAL_ERROR. Add the subcondition to the error catalog
next to CHECKPOINT_RELATION_ID_EMPTY, and add a regression test for the Spark
Connect server checkpoint command.
**Why this works:** The handler is the only layer that knows an ID was
client-supplied. Before any work, it performs a side-effect-free lookup in the
session cache, which rejects serial reuse without paying for a checkpoint. The
session holder then reports whether the atomic insertion happened, as an
explicit outcome rather than through the internal-error message. The handler
turns a failed insertion of a supplied ID into the same structured
InvalidPlanInput. Because putIfAbsent remains the sole uniqueness decision, no
entry is ever overwritten, and collisions of server-generated IDs keep their
internal-bug meaning.
**Scope:** Move duplicate-ID admission for client-supplied checkpoint
relation IDs into the Connect checkpoint handler. The handler raises a
structured user-facing condition before any work, and also when it loses an
insertion race. The condition is added to the error catalog, with a server
regression test.
**Compatibility:** Requests that omit relation_id, and foreachBatch caching,
keep server-generated UUIDs and raise INTERNAL_ERROR on an unexpected
collision. Rejecting a duplicate never replaces or removes the entry already
cached under that ID.
**Risks:** The pre-check is not atomic, so on its own it would let a
concurrent duplicate reach INTERNAL_ERROR. Mapping the insertion outcome is
therefore required, not optional. The new subcondition becomes part of the
released relation_id protocol contract, so its name and parameters should be
settled before release. The PR description says the existing duplicate-ID error
is kept intentionally; this repair still rejects duplicates and changes only
the error's classification and when it is raised.
**Constraints:** Never overwrite or evict an existing dataFrameCache entry
when rejecting a duplicate. Keep INTERNAL_ERROR for collisions of
server-generated IDs (absent field and foreachBatch).
**Success:** A CheckpointCommand whose supplied relation_id already
identifies a cached DataFrame in the session fails with a CONNECT_INVALID_PLAN
subcondition that names the ID, rather than INTERNAL_ERROR. It fails before its
relation is planned or checkpointed, and the existing entry is unchanged. When
two requests race with the same supplied ID, exactly one is cached, and the
other fails with the same CONNECT_INVALID_PLAN subcondition. Requests that omit
relation_id, and foreachBatch caching, keep server-generated IDs and their
existing INTERNAL_ERROR on collision.
**Verification:**
- **Regression:** First, a checkpoint command caches a DataFrame under a
supplied ID. A second checkpoint command in the same session with that ID then
fails with the new CONNECT_INVALID_PLAN subcondition and its parameters, before
its relation is planned or checkpointed. The first cached DataFrame remains
resolvable by that ID.
- **Inspection:** A failed atomic insertion of a client-supplied ID reports
the same structured subcondition, and the insertion path for server-generated
IDs keeps INTERNAL_ERROR.
- **Compatibility:** A checkpoint command without relation_id still returns
and caches a server-generated ID, and foreachBatch DataFrame caching behaves as
before.
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala:
##########
@@ -4083,6 +4083,14 @@ class SparkConnectPlanner(
private def handleCheckpointCommand(
checkpointCommand: CheckpointCommand,
responseObserver: StreamObserver[proto.ExecutePlanResponse]): Unit = {
+ val dfId = if (checkpointCommand.hasRelationId) {
+ if (checkpointCommand.getRelationId.isEmpty) {
Review Comment:
**Nit (P3):** The cited `test_connect_plan` suite has no checkpoint test,
and the existing Connect checkpoint tests only cover the happy path: the Python
client always sends a fresh UUID and the Scala client never sends one. Nothing
sends an empty or already-cached ID, checks that a supplied ID is honored or
that an omitted one is still server-generated, or builds `plan.Checkpoint` with
and without `relation_id`. So deleting this guard (an empty ID would then be
cached under `""`), misspelling or re-parameterizing
`CHECKPOINT_RELATION_ID_EMPTY` (its message is resolved only when the error is
thrown), changing what a duplicate ID produces, or making the builder set the
field when it is `None` would all leave CI green. This is the contract the PR
offers other protocol clients, and today it is protected only indirectly by the
Python happy path.
**Verification:**
- **Behavior:** A checkpoint command with a fresh supplied ID caches the
DataFrame under exactly that ID and returns it in the result. A command without
the field returns a server-generated ID that is cached.
- **Regression:** A checkpoint command with an empty supplied ID fails with
CONNECT_INVALID_PLAN.CHECKPOINT_RELATION_ID_EMPTY and leaves the session cache
unchanged. A command with an already-cached supplied ID fails with the
duplicate-ID condition, and the existing entry remains resolvable.
- **Compatibility:** When no ID is given, the Python checkpoint builder
leaves relation_id unset (field presence false), so callers that omit it keep
server-generated IDs. When an ID is given, the builder sets the field to it.
##########
python/pyspark/sql/connect/dataframe.py:
##########
@@ -2417,26 +2418,47 @@ def offset(self, num: int) -> ParentDataFrame:
return DataFrame(plan.Offset(child=self._plan, offset=num),
session=self._session)
def checkpoint(self, eager: bool = True) -> ParentDataFrame:
- cmd = plan.Checkpoint(child=self._plan, local=False, eager=eager)
+ relation_id = str(uuid.uuid4())
+ cmd = plan.Checkpoint(
+ child=self._plan,
+ local=False,
+ eager=eager,
+ relation_id=relation_id,
+ )
_, properties, self._execution_info =
self._session.client.execute_command(
cmd.command(self._session.client)
)
assert "checkpoint_command_result" in properties
- checkpointed = properties["checkpoint_command_result"]
- assert isinstance(checkpointed._plan, plan.CachedRemoteRelation)
- return checkpointed
+ assert properties["checkpoint_command_result"] == relation_id, (
+ "Checkpoint relation ID does not match the requested ID"
+ )
+ return DataFrame(
+ plan=plan.CachedRemoteRelation(relation_id, self._session),
Review Comment:
**Nit (P3):** `test_parity_frame_spark.py` skips `test_checkpoint` and the
`df.checkpoint(False)` doctest is `+SKIP`, so in Connect mode only
`localCheckpoint()` is ever exercised. Each method now has its own copy of ID
generation, propagation, echo check, and result construction, so dropping
`relation_id=relation_id` from the builder call above would make every Connect
`df.checkpoint()` and `psdf.spark.checkpoint()` fail against the in-tree server
without any test noticing.
The binding to `self._session` on this line and at line 2459 is not checked
either, because every checkpoint test uses a single session. Reverting it to
`SparkSession.active()` would hand a second session's checkpoint back bound to
the first session, so its first action fails with `DATAFRAME_NOT_FOUND` and the
release goes to the wrong session. The description mentions smoke checks for
UUID and session retention in both methods, but they were not committed. The
parity skip reason (no SparkContext in Connect) does not apply to suites like
`test_connect_basic.py`, which already reach the local server's SparkContext
through the classic session. This is independent of whether the methods keep
enforcing the echoed ID.
**Verification:**
- **Behavior:** In Connect mode, reliable checkpoint() returns a usable
DataFrame whose relation ID is present in the server session's DataFrame cache,
and the ID is removed after the DataFrame is garbage-collected.
- **Regression:** With another session active, a newly created second
Connect session uses both checkpoint variants. Each variant caches its result
in the second session and returns a DataFrame bound to it that executes
successfully, so binding to SparkSession.active() instead fails the test.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]