Skip to content

Recovery for locations stuck after failed refresh#33642

Merged
gibsondan merged 16 commits into
dagster-io:masterfrom
jmoldow:jordan/server-watcher-error-recovery
Jun 30, 2026
Merged

Recovery for locations stuck after failed refresh#33642
gibsondan merged 16 commits into
dagster-io:masterfrom
jmoldow:jordan/server-watcher-error-recovery

Conversation

@jmoldow

@jmoldow jmoldow commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Summary & Motivation

Fixes #17319 and #32216.

A code location can be left permanently stuck in an error state after a failed refresh_code_location(). The gRPC server watcher detects a server change (new server ID, or server reachable again after a transient blip) and triggers a refresh, but the refresh itself fails — typically during a Kubernetes rolling deployment where the gRPC call routes to a dying pod via the EndpointSlice. After the failure, the watch thread keeps polling successfully, sees no further changes, and never retries. The location stays errored until something external (manual reload, daemon/webserver restart, or a new server_id change) forces a retry. The recent --fixed-server-id change (commit e32df69) removed pod-restart as a reliable recovery path, making this a long-lived failure mode in some configurations. (Manually clicking Reload in the UI can also recover, but in multi-replica webserver deployments it has to land on the affected pod, so it may take several clicks.)

Recovery is implemented as an independent periodic check inside the existing gRPC watch thread. On a fixed cadence (ERROR_RECOVERY_INTERVAL, default 10s), the watch thread inspects the workspace entry's load_error. If it is a DagsterUserCodeUnreachableError, it calls refresh_code_location directly. The check fires no on_* event. The server-id watch and the disconnect/reconnect loop continue to handle only server-id changes and actual server unreachability.

Recovery is intentionally scoped to DagsterUserCodeUnreachableError. Other load errors (e.g. DagsterUserCodeProcessError from a user-code syntax error) are non-transient and not retried, to avoid log spam on permanently broken locations. A related rolling-deploy race — where individual gRPC calls within a single _load_location route to different pods and produce a stale-but-not-errored version_key — is intentionally out of scope and tracked separately.

Other changes:

  • create_grpc_watch_thread now requires all on_* callbacks (no longer optional) and adds get_location_entry and refresh_code_location parameters.
  • The internal server_id dict is replaced with a typed ServerId inner class; full type annotations added throughout server_watcher.py.

Identification and fixing of the issue was tool assisted, as was writing this commit message, but both the code and the commit message have been significantly reviewed and edited by me.

How I Tested These Changes

New unit tests in dagster_tests/general_tests/grpc_tests/test_watch_server.py covering the periodic recovery check (asserting it refreshes a stuck location and fires no events). New integration tests in dagster_graphql_tests/graphql/test_watch_grpc_server.py exercising the full recovery flow end-to-end against a real gRPC server. Existing test suite.

Changelog

Fixed a bug where a code location could be left stuck in an error state after a transient gRPC failure during refresh (common during Kubernetes rolling deployments). The gRPC server watcher now periodically retries refresh for locations whose load error is a DagsterUserCodeUnreachableError.

A race condition can lead to a daemon or webserver getting stuck
in a permanent state where the workspace has registered a code
location as being in an errored state, but the watch thread
believes (correctly) that everything is healthy, so the watch
thread will not trigger the callback that is necessary for the
workspace to re-refresh the code location.

This can happen during Kubernetes rolling deployments (a code
location refresh can fail when the gRPC call routes to a dying
old pod via the EndpointSlice). But it can also happen for more
mundane reasons, such as a transient network failure during
`refresh_code_location()`. The failure sequence is something
like this:

- T+0: `dagster._grpc.server_watcher:watch_grpc_server_thread()`:
  - `client.get_server_id()` returns `"old-id"`
  - `set_server_id("old-id")`
- T+1: New code location pod starts.
- T+2: Watch thread poll hits new pod:
  - `client.get_server_id()` returns `"new-id"`
  - `"old-id" != "new-id"`
  - `set_server_id("new-id")`
  - `on_updated(name, "new-id")` → `LOCATION_UPDATED` event →
    `_send_state_event_to_subscribers()` →
    `_location_state_events_handler()` → `refresh_code_location()`
  - Note: `on_updated` is called synchronously on the watch
    thread, so `refresh_code_location()` (T+3) runs to
    completion before `watch_for_changes()` resumes looping (T+4).
- T+3: `dagster._core.workspace.context:refresh_code_location()`:
  - → `_load_location()` → `origin.create_location()`
  - → `GrpcServerCodeLocation()` → constructor makes gRPC calls
  - → at least one call routes to dying old pod via K8s EndpointSlice
  - → FAILS → the refreshed `CodeLocationEntry` is constructed
    with `load_error` set, `code_location=None`.
- T+4: The watch thread continues polling within
  `watch_for_changes()`. All calls to `client.get_server_id()`
  succeed and return `"new-id"` from the new pod.
  `curr == new_server_id == "new-id"`, so no callbacks are
  invoked. The `CodeLocationEntry` still has `load_error` set,
  `code_location=None`.
- T+5: The watch thread continues polling. Same result. Nothing
  happens. The `CodeLocationEntry` is still in an error state.
- T+∞: Location is stuck in error state forever. The watch
  thread is alive, the server is healthy, and the `server_id`
  isn't changing, so the watch thread has no reason to call a
  callback that would trigger a retry for
  `refresh_code_location()`.

The race condition doesn't have to involve a rolling deployment.
T+1 and T+2 can also be a gRPC failure that triggers the
`reconnect_loop()`, followed by triggering `LOCATION_ERROR` and
then `LOCATION_UPDATED`, which triggers
`refresh_code_location()` for T+3. Then another brief network
blip happens, causing T+3 to still fail while T+4 still
succeeds, and then control returns to the infinite
`watch_for_changes()` loop.

In practice, the error state doesn't literally last forever. If
the watch thread eventually detects a new server_id, or hits a
prolonged network blip followed by eventual recovery, then a
callback is invoked, and `refresh_code_location()` will have
another chance to succeed.

This change adds three layers of recovery:

(1) Event handler recovery: any non-disconnect event for an errored location
    triggers a refresh, so `LOCATION_RECONNECTED` events from the normal
    reconnect path also recover errored locations.

(2) Retry with exponential backoff in refresh_code_location (5
    attempts with 0.5s/1s/2s/4s backoff, ~7.5s worst-case sleep).
    This handles the common case where the underlying network
    issue resolves quickly.

(3) Watch thread error-aware polling, for cases of very bad luck
    where all `refresh_code_location` retries fail, but then the
    next and all subsequent watch thread calls all succeed. Inject a
    `needs_location_refresh` callback so the watch thread can detect
    when the workspace entry is errored or has a stale version key.
    When detected, it fires `on_disconnect()` + `on_reconnected()`
    to trigger a recovery refresh, with a double `get_server_id()`
    check to confirm the server is actually reachable.

Also make all callers provide all callbacks to
`create_grpc_watch_thread`, and make the callbacks required to
prevent accidental bugs.

With these improvements, the race condition is avoided as
follows:
- T+3 is less likely to fail, due to the retries with
  exponential backoff from fix (2).
- If T+3 does still fail and then the network recovers between
  T+3 and T+4, step T+4 will no longer be a no-op, but instead
  will invoke `on_disconnect()` followed by `on_reconnected()` due
  to fix (3). The latter will trigger `refresh_code_location()`,
  due to fix (1).

Fixes dagster-io#17319
Fixes dagster-io#32216

Identification and fixing of the issue was tool assisted, as was
writing this commit message, but both the code and the commit
message have been significantly reviewed and edited by me.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds recovery for gRPC code locations that get stuck after a transient refresh failure. The main changes are:

  • Periodic unreachable-error recovery inside the gRPC watch thread.
  • Workspace wiring for direct location-entry lookup and refresh.
  • Reconnect behavior that can recover after an exhausted outage.
  • Unit and integration tests for disconnect, error, and recovery flows.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
python_modules/dagster/dagster/_grpc/server_watcher.py Adds periodic stuck-error recovery and keeps reconnect polling alive after an exhausted outage.
python_modules/dagster/dagster/_core/workspace/context.py Passes workspace lookup and refresh callbacks into the watch thread.
python_modules/dagster/dagster_tests/general_tests/grpc_tests/test_watch_server.py Expands watcher tests for reconnect, error, and periodic recovery behavior.
python_modules/dagster-graphql/dagster_graphql_tests/graphql/test_watch_grpc_server.py Adds integration coverage for stuck-location recovery and post-error refresh signaling.

Reviews (6): Last reviewed commit: "Merge pull request #1 from dagster-io/gi..." | Re-trigger Greptile

Comment thread python_modules/dagster/dagster/_core/workspace/context.py Outdated
Comment thread python_modules/dagster/dagster/_grpc/server_watcher.py Outdated
Comment thread python_modules/dagster/dagster/_core/workspace/context.py
… test timeouts

Remove the retry-with-exponential-backoff loop from refresh_code_location,
reverting it to its original single-attempt behavior. The retry loop ran
synchronously within the _state_subscribers_lock callback chain, blocking
event delivery to all other locations for up to ~7.5s. Recovery is now
handled entirely by the watch thread's needs_location_refresh mechanism,
which runs on its own per-location thread and doesn't hold any shared locks.

Add outer-level backoff to the watch thread's recovery polling: wait 2 poll
cycles to confirm the error persists (giving get_server_id() a chance to
throw and trigger reconnect_loop instead), then retry every 10 cycles. This
prevents log spam and unnecessary gRPC calls when a location is permanently
stuck.

Increase test timeouts from watch_interval * 5 (0.5s) to a fixed 5 seconds
to avoid flakiness under CI load, since the recovery path involves multiple
gRPC calls with 2-second request timeouts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jmoldow

jmoldow commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai Please re-evaluate my PR, given the feedback that was addressed in commit f4e9fa7 . Does this fully address your prior concerns? Do you have any new concerns? Can you re-evaluate your confidence score and review comments?

@greptile-apps

greptile-apps Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Yes, commit f4e9fa7 fully addresses all my prior concerns! Here's what was successfully resolved:

✅ P2 - Lock contention removed: The retry loop is completely gone from refresh_code_location. Recovery now happens entirely in the watch thread (which runs on its own per-location thread without holding _state_subscribers_lock). This eliminates the 7.5s blocking issue.

✅ P2 - Second get_server_id() issue fixed: The new counter-based implementation is much cleaner. By tracking consecutive needs_location_refresh returns and only triggering callbacks after confirmation (2+ cycles), you've eliminated both the duplicate get_server_id() call and the concern about discarding its return value. The logic is more straightforward and handles server ID changes correctly (counter resets when set_server_id is called).

✅ P2 - Test timeout flakiness: Changing to fixed 5-second timeouts (from watch_interval * 5 = 0.5s) gives plenty of headroom for slow CI environments. This should prevent flakes.

✅ P2 - Outer-level backoff implemented: The 2-cycle confirmation + retry-every-10-cycles pattern is a good balance. The 2-cycle wait gives get_server_id() a chance to throw and trigger reconnect_loop naturally, and the 10-cycle spacing prevents log spam for permanently stuck locations while still being responsive enough (10s retry interval with default 1s watch_interval).

New observations:

The implementation is solid. The only edge case worth considering is the scenario you mentioned in your comment - where get_server_id() permanently succeeds but refresh_code_location() permanently fails. This seems very unlikely in practice (they'd be using different gRPC methods that both fail in different ways), but if it happens, the 10-cycle backoff keeps the impact minimal.

Confidence: 9/10 ⬆️ (previously 7/10)

The approach is well-tested, the concerns are fully addressed, and the code is cleaner than before. The integration test provides good end-to-end coverage of the recovery path. Ready for merge from my perspective!

@jmoldow

jmoldow commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai Can you provide an updated Summary and Sequence Diagram?

@greptile-apps

greptile-apps Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes a race condition where code locations get permanently stuck in an error state after a failed refresh. The issue occurs when the gRPC watch thread detects a server change and triggers a refresh, but the refresh fails due to a transient network error (e.g., during Kubernetes rolling deployments). After the failure, the watch thread continues polling the healthy server but never retries the refresh, leaving the location errored indefinitely.

The fix adds two recovery mechanisms:

  1. Event handler recovery: Any non-disconnect event (including LOCATION_RECONNECTED) for an errored location now triggers a refresh
  2. Watch thread error detection: A new needs_location_refresh callback allows the watch thread to detect errored/stale workspace entries and fire disconnect+reconnect events to trigger recovery

Sequence Diagram

sequenceDiagram
    participant WT as Watch Thread
    participant WS as Workspace
    participant gRPC as gRPC Server
    
    Note over WT,gRPC: PROBLEM: Race Condition (Before Fix)
    WT->>gRPC: poll() - detect server ID change
    WT->>WS: trigger refresh_code_location()
    WS->>gRPC: load location (network fails)
    gRPC--xWS: transient error
    WS->>WS: mark location as ERRORED
    loop Subsequent polls
        WT->>gRPC: poll() - server healthy ✓
        Note over WT: Server looks good,<br/>no refresh triggered
        Note over WS: Location stuck ERRORED
    end
    
    Note over WT,gRPC: SOLUTION: Two-Layer Recovery
    
    rect rgb(200, 240, 200)
        Note over WT,WS: Layer 1: Event Handler Recovery
        WT->>WS: LOCATION_RECONNECTED event
        WS->>WS: check: location errored? YES
        WS->>gRPC: trigger refresh_code_location()
        gRPC-->>WS: success
        WS->>WS: clear error state
    end
    
    rect rgb(200, 220, 240)
        Note over WT,WS: Layer 2: Watch Thread Error Detection
        WT->>WS: needs_location_refresh() callback
        WS-->>WT: location is errored
        Note over WT: Wait 2 cycles to confirm
        WT->>WS: needs_location_refresh()
        WS-->>WT: still errored
        WT->>WS: fire on_disconnect + on_reconnected
        WS->>gRPC: trigger refresh_code_location()
        gRPC-->>WS: success
        WS->>WS: clear error state
    end
Loading

The diagram shows how a transient network error during refresh leaves the location permanently stuck (top section), and how the two-layer recovery mechanism solves it through either normal reconnect events or watch thread error detection (bottom sections).

@jmoldow

jmoldow commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@gibsondan @cmpadden @greptile-apps[bot] @greptileai With the recent commit e32df69, I believe that reviewing and merging this PR has become even more important.

Before commit e32df69, the server_id would change on every container start/restart, even if the underlying image or config hadn't changed. That was one of the few recovery paths for issues #17319 and #32216 , which this PR tries to fix. Killing the existing code location pod and bringing up a new one - or restarting the container via a liveness probe - would cause the daemon/webserver to see a new server_id, and re-register the code location as being in a healthy state. With --fixed-server-id, that recovery path no longer works. The only recovery path now is to kill the impacted daemon/webserver pod(s).

Issues #17319 and #32216 are particularly likely to be triggered during a rolling deployment/restart. I created a very hacky (and extremely non-ideal) set of probes to specifically avoid the race condition in that specific case (but doesn't mitigate #17319 and #32216 in the more general sense), but that hack relies on the server_id changing.

I don't want commit e32df69 to be reverted, as I would like to take advantage of replicaCount in the future. I would like to see this PR reviewed and merged, both as a mitigation to the regression from commit e32df69, and for its own sake: issues #17319 and #32216 are really bad bugs that need to be addressed.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Your analysis is exactly right, and this is an important escalation. Here's a clear summary of the regression impact for maintainer context:

Before --fixed-server-id (e32df69):

After --fixed-server-id (e32df69):

  • Pod restart → same server_id (checksum-derived) → watch thread sees no change → stuck location stays stuck
  • The only remaining recovery path is killing the daemon/webserver pods, which is a manual, disruptive intervention

This means --fixed-server-id (a correct fix for the replicaCount > 1 flip-flop problem) has inadvertently made the stuck-location race condition from #17319 / #32216 a permanent failure mode rather than a recoverable one. The fix in this PR directly addresses that gap.

From a code review standpoint, this PR is in good shape. All three P2 concerns I raised in the original review were addressed in commit f4e9fa7:

  • ✅ Lock contention from retry loop removed
  • ✅ Counter-based approach replaces the double get_server_id() call
  • ✅ Test timeouts increased to 5s
  • ✅ 10-cycle backoff prevents log spam for permanently stuck locations

The one remaining open review thread (backoff for persistent failures) was addressed by the needs_location_refresh_count >= 2 && (count - 2) % 10 == 0 logic in server_watcher.py.

This PR is ready for maintainer review and merge. @gibsondan @cmpadden — the urgency here has materially increased since e32df69 landed. Without this fix, any Kubernetes user on a version with --fixed-server-id who hits the race condition during a rolling deployment has no self-service recovery path.

@gibsondan gibsondan self-requested a review June 12, 2026 01:26
@gibsondan

gibsondan commented Jun 12, 2026

Copy link
Copy Markdown

@jmoldow thanks for sending this out and sorry for the delay, will take a look at this. Wanted to confirm one thing about the recovery though first to make sure i fully understand - manually reloading the location in the UI does work to get it out of the bad state, right? (but i agree ideally it would be automatic and retry)

And the daemon should automatically retry and get out of the bad state automatically either way, before and after this change, since it periodically refreshes the whole workspace every 60 seconds? https://github.com/dagster-io/dagster/blob/master/python_modules/dagster/dagster/_daemon/controller.py#L275-L282

@gibsondan

gibsondan commented Jun 12, 2026

Copy link
Copy Markdown

i.e. i'm confused by the claim that "the only recovery path now is to kill the impacted daemon/webserver pod(s)". It's certainly not ideal, but you can press this Reload button in the UI to recover too i think?
image

@gibsondan gibsondan 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.

couple of questions for you inline

Comment thread python_modules/dagster/dagster/_core/workspace/context.py Outdated
Comment thread python_modules/dagster/dagster/_core/workspace/context.py Outdated
@jmoldow

jmoldow commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@gibsondan We definitely observed it more for the webserver than the daemon. I'm not sure if that's a function of the webserver just being more obvious to observe; or if the daemon was successfully auto-recovering per the controller.py#L275-L282 snippet you linked. I thought we observed it stuck on the daemon once, but I haven't been able to find any old logs / chat messages about that, so it's possible I'm mis-remembering. The most I could find was a commit message from myself, saying "This could explain the multi-hour outage of scheduled jobs that we saw once", but alas I didn't document when that occurred or save any evidence for why I believed this was a potential hypothesis.

Regarding the Reload button: here's two things I vaguely remember (it's been a few months since I did these investigations and made some of these changes):

  • I think the efficacy of the Reload button may have been different, depending on if we were using dagsterApiGrpcArgs or codeServerArgs. I vaguely recall (but I'm not confident in this memory) that the Reload button couldn't get out of this particular problem when using codeServerArgs. I switched back to using dagsterApiGrpcArgs in December and haven't tried codeServerArgs since then.
  • We run multiple replicas of the webserver deployment, so that we don't have a SPOF pod. These issues aren't deterministic, so it's possible to get into a stuck state in one webserver while being healthy in another webserver. This, combined with the typical round-robin nature of a k8s Service+Ingress, manifests as a "flickering" issue: sometimes you would see the code location in a healthy state, sometimes you would see it being reported as failed. Needless to say, this was very confusing for us, and for a long time we thought the code locations were actually flapping between healthy/unhealthy somehow. The Reload button did fix the issues, but only if the Reload landed on the pod that had the issue. Continuing to click Reload multiple times would eventually hit all of the webserver pods, and fix the issue.

So you're right, I was incorrect in my last message to say that killing the webserver pod is the only recovery method. But it's a bit more deterministic than hitting Reload an unknown number of times.

And for what it's worth, we're on Dagster 1.12.8 at the moment. I know that issues #17319 and #32216 haven't been addressed yet in Dagster 1.13, but I don't know if anything about the Reload button or codeServerArgs or controller.py#L275-L282 may have been improved since 1.12.8.

@gibsondan gibsondan self-requested a review June 12, 2026 20:53

@gibsondan gibsondan 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.

ok cool - if you can make that change - (which i think largely just involves not passing in the version_key to _should_recover_location and taking out the version_key mismatch checking) and rebase I can prioritize getting this in. I think starting with just the transient error makes sense too, put more on that inline. Sorry again for the delay and thanks for pushing on this

Comment thread python_modules/dagster/dagster/_core/workspace/context.py Outdated
jmoldow added 10 commits June 22, 2026 22:39
…or and direct-refresh

Replace the prior needs_location_refresh(name, version_key) callback — which triggered
recovery on both error-state and stale-version-key mismatches — with two lower-level hooks:
get_location_entry(name) and refresh_code_location(name). The watch thread now re-raises
DagsterUserCodeUnreachableError only when the workspace entry's load_error matches that
class, and within reconnect_loop it calls refresh_code_location directly once GetServerId
succeeds (rather than firing synthetic on_disconnect/on_reconnected events).

This narrows recovery to transient gRPC failures, per PR review feedback: permanent user
code errors (e.g. DagsterUserCodeProcessError from a syntax error) will no longer be retried,
and the multi-pod rolling-deploy race that produces a stale-but-not-errored version_key is
intentionally out of scope (tracked separately).

Also in this commit:
- Workspace: rename _should_recover_location(name, version_key) →
  _get_location_entry_without_locking(name); drop the version_key parameter.
- Workspace event handler: drop the LOCATION_DISCONNECTED special case so that any event on
  an errored location triggers a refresh.
- Server watcher: replace the server_id dict with a typed ServerId inner class; add type
  annotations throughout.
- Server watcher: rate-limit recovery refresh attempts to one per max_reconnect_attempts
  cycles to avoid log spam on permanently stuck locations.
- Server watcher: keep the thread alive after on_error so eventual recovery is signaled via
  on_updated (the stored server_id is cleared on error to force the on_updated branch).
Polishing pass on server_watcher.py from a docstring/comment audit:

- watch_grpc_server_thread docstring: tighten "at most once per disconnect
  cycle" to "at most once per reconnect_loop invocation".
- reconnect_loop: replace contradictory comment block on the
  server-ID-changed/post-on_error branch with a single explanation that
  flapping is prevented by get_location_entry_or_raise_unreachable_error.
- reconnect_loop: clarify retry-cadence comment to distinguish the
  pre-on_error (retry every poll) and post-on_error (retry every Nth poll)
  paths.

Comments/docstrings only; no behavior change.
…mments

Polishing pass on the watch-server test files:

- test_watch_grpc_server.py: replace the misleading "should have arrived
  before" comment (the assertion only checks presence, not ordering) with
  an explanation of why both events are expected by the time LOCATION_ERROR
  is observed.
- test_watch_server.py: add a docstring to wait_for_condition explaining
  the min(5, interval * 60) default-timeout formula (cap at 5s for the
  long-interval case, cap at interval * 60 for the short-interval case).
- test_watch_server.py: explain the >= 2 poll-count check and the chained
  on_reconnected == on_disconnect <= 1 assertion in the
  create_server_process_and_watch_thread fixture, and note that the
  trailing counter reset establishes a clean baseline for each test.

Comments/docstrings only; no behavior change.
…rrored[20]

The post-recovery assertion at the end of test_grpc_watch_thread_recovery_when_errored
was timing-racy: after firing on_updated to recover from the errored state, the test
captured a counter snapshot and then slept watch_interval * 2 (0.2s) before asserting
that get_location_entry_count had advanced by >= 2.

In the [20] parametrization (recovery via on_updated after on_error fired) the watch
thread can already be mid-shutdown_event.wait() by the time the test captures the
snapshot, so only one full poll cycle reliably completes in the 0.2s sleep window.
Observed ~30% failure rate locally (3/10 runs) with `assert 26 >= (25 + 2)`.

Replace the fixed sleep + assert with wait_for_condition (the pattern used elsewhere
in the same test file), polling for the +2 increment with a timeout of
watch_interval * 5. The meaningful post-recovery invariant — that
refresh_code_location is not called again — is preserved as a hard assert after the
liveness check.

Verified: 10/10 passes on the previously-flaky [20] parametrization.
…rtion

Follow-up to the previous de-flake commit, which inadvertently dropped the
`assert called_event == called_event_expected` check at the end of
test_grpc_watch_thread_recovery_when_errored. That assertion catches a different
regression than the get_location_entry_count liveness check and the
refresh_code_location_count == snapshot check: it verifies that no *additional*
events (on_updated, on_disconnect, on_reconnected, on_error) fired after recovery
— i.e. the system has truly settled, not just continued polling.

Place it after the liveness wait_for_condition so any latent extra event has at
least 2 poll cycles to surface.
…d tighten logging

- Rename set_server_id → update_server_id to reflect that it can also fire on_updated.
- Move the module logger to module scope so it can be reused without re-creating per call.
- Switch caught-exception logging from logger.warning to logger.exception to capture
  tracebacks.
- Annotate the reconnect_loop "new server ID before on_error" branch with a comment
  explaining why it deliberately skips the unreachable-error gate.
- Convert the test-suite should_not_be_called helper into a pytest fixture backed by a
  shared list, so unexpected callback invocations fail the test on teardown rather than
  raising from inside the watch thread.
…est type annotations

Condense the brand-new portions of the watch_grpc_server_thread docstring and inline
reconnect_loop comments to read as documentation rather than review responses, while
preserving the original master-era sentences. Add missing type annotations to test
fixtures and test function signatures in test_watch_server.py.

No behavior change.
…rror recovery test

- Shorten WATCH_INTERVAL via patch.object inside a class-scoped graphql_context fixture
  so the recovery tests don't wait on the default 1s poll.
- Use DagsterUserCodeUnreachableError (not RuntimeError) as the injected load_error so
  the watch thread's get_location_entry_or_raise_unreachable_error path actually fires.
- Assert update_timestamp advances and LOCATION_RECONNECTED is observed after recovery.
- Add test_grpc_server_on_error_then_recovery_via_on_updated: patch
  DagsterGrpcClient.get_server_id to fail past MAX_RECONNECT_ATTEMPTS, then succeed, and
  assert recovery routes through LOCATION_UPDATED (not LOCATION_RECONNECTED) so the
  workspace handler clears the error state.
…odeUnreachableError

Note in the docstring that non-transient load errors (e.g. DagsterUserCodeProcessError from
a user-code syntax error) are intentionally not retried, to avoid log spam on permanently
broken locations.
@jmoldow

jmoldow commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai Please re-evaluate my PR, given the feedback that was addressed in the commits that have been added. Do these commits fully address all prior concerns? Do you have any new concerns? Can you re-evaluate your confidence score and review comments?

Please provide an updated Summary and Sequence Diagram.

@gibsondan gibsondan self-requested a review June 23, 2026 14:22
logger.exception(
f"In gRPC watch server thread for location {location_name}, on_disconnect raised {exc.__class__.__name__}: {exc}"
)
except Exception as exc:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I noticed that we had no protection from any other exceptions besides DagsterUserCodeUnreachableError being raised. That seems like an issue waiting to happen: the on_* calls, and the refresh_code_location(), can raise other Dagster* errors. And since it is possible to register other callbacks besides just _location_state_events_handler(), theoretically any exception could be thrown.

If that were to happen before, the watcher thread would die, and the code location would be stuck in whatever state it was in, until/unless a different mechanism refreshed the code location state.

These new except Exception blocks prevent the watcher thread from completely dying.

During iteration on the recent changes, this already came in handy. I had made a mistake in some code I had written, and an exception was being thrown all the way up to here. Before I added the logging, I had no idea why my test was failing. After the logging, the cause was made clear.

logger.exception(
f"In gRPC watch server thread for location {location_name}, watch_for_changes raised {exc.__class__.__name__}: {exc}"
)
shutdown_event.wait(watch_interval * max_reconnect_attempts)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The unknown exception could potentially be spammy and permanent, or at least long lived. We don't really know the nature of the exception. Since the previous behavior was for the thread to completely die forever, it seems fine to have a "long" sleep before repeating.

The original code used a dict. I changed it to Counter because I
thought I might end up using one or more Counter methods, but
that ended up not being necessary.

@greptileai flagged a major footgun that I never knew before
about `Counter.update()`:
> Like `dict.update()` but adds counts instead of replacing them
https://docs.python.org/3/library/collections.html#collections.Counter.update

I also realized that using `Counter` instead of `dict` could
lead to bugs or false-positives if a key is typo'd, since it
would return 0 instead of throwing a `KeyError`.
@jmoldow

jmoldow commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

The Counter.update({k: 0}) reset in create_server_process_and_watch_thread is a no-op, meaning any startup disconnect/reconnect counts are never cleared. When the server process starts slowly under CI load, on_disconnect_count and on_reconnected_count remain at 1, causing tests like test_grpc_watch_thread_recovery_when_errored to fail on the assert called_event == {on_disconnect_count: 0, ...} line. This is a test-reliability defect, not a production bug.

The create_server_process_and_watch_thread fixture in test_watch_server.py needs the Counter reset corrected; production files (server_watcher.py, context.py) look solid.

Reviews (3): Last reviewed commit: "docs(server_watcher): clarify that recov..." | Re-trigger Greptile

@greptileai Thanks, I didn't know this about Counter.update. I fixed this in commit 143ab0d.

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Good fix — reverting to dict is the right call. The commit message explains it well too: beyond the update() footgun, Counter's silent-zero behavior on missing keys was a latent source of false-passing tests (a typo'd key would silently return 0 instead of raising KeyError, making assertions vacuously true). dict gives you the strict key semantics you actually want in test fixtures.

@gibsondan gibsondan 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.

ok I promise this is extremely close and I am on the verge of merging this. I have one hopefully final question for you about whether we still need this _location_state_events_handler business in this new world or whether we can just rely on claling refresh_code_location directly in the watcher thread now?

Comment on lines +1188 to +1190
Called from the watch thread without holding self._lock. This is safe because
_current_workspace is replaced atomically (single reference assignment) and we only need a
consistent-enough snapshot — correctness doesn't depend on reading the latest value.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

i suspect this same logic actually applies anywhere we read self._current_workspace currently - but I can clean that up separately

Comment on lines +1272 to +1276
# Refresh on update/error events, or on any event when the location is errored (to attempt
# recovery).
should_refresh = location_is_errored or event.event_type in refresh_event_types

if should_refresh:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am 100% on board with the watch thread change now but I am still a little confused about what this other recovery layer is buying us exactly now. Do you have a specific example/event in mind where it would be useful to refresh whenever the location is in an error state? Also just noting that the summary says this one is also scoped to the UnreachableError but i'm not seeing that here - seems like it will refresh on any location that's on an error state whenever it either disconnects or reconnects.

Are you sure this wasn't an artifact of the old logic before where we were implementing the refresh via a disconnect-then-reconnect call, is it possible that we no longer need this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Also just noting that the summary says this one is also scoped to the UnreachableError but i'm not seeing that here - seems like it will refresh on any location that's on an error state whenever it either disconnects or reconnects.

Thanks, that's a mistake in the summary. This was not intended to be scoped to any particular type of error.

I am 100% on board with the watch thread change now but I am still a little confused about what this other recovery layer is buying us exactly now. Do you have a specific example/event in mind where it would be useful to refresh whenever the location is in an error state?

A few thoughts:

(general thought) Since the error recovery in the watcher has been scoped specifically to UnreachableError, and since the old (and new) code will trigger a refresh on every on_error/on_update event, I thought it could be useful to have something in the middle: something that triggers a refresh on other events, but only when an error is already present, and for any error not just UnreachableError.

(specific scenario)

  • The watcher enters a reconnect loop (from its direct GetServerId call)
  • Some other thread triggers a refresh for the code location, and it is registered as being in an error state (maybe an UnreachableError, but maybe it progressed to a different transient error state)
  • The watcher detects a reconnect, and fires an on_reconnected event
  • (with the new code) A refresh will be triggered, and the code location will recover (unless a non-unreachable error still persists)
  • (with the old code for _location_state_events_handler and the new code for the watcher thread, and if the error state is UnreachableError)
    • No refresh is triggered
    • Watcher exits the reconnect loop and enters the watch_for_changes loop
    • Watcher sees the error state, triggers on_disconnect, and enters the reconnect loop
    • Watcher triggers a refresh, and the code location will recover
    • Watcher triggers on_reconnect, exits the reconnect loop, and enters the watch_for_changes loop

The code location ends up recovering in the end either way, but there is more thrashing and a slower recovery if we're using the old _location_state_events_handler code.

Let me know what you think about that scenario.

Are you sure this wasn't an artifact of the old logic before where we were implementing the refresh via a disconnect-then-reconnect call, is it possible that we no longer need this?

It's true that this logic was originally written for the first approach. But it was somewhat intentional (though not strictly required) that I kept it even with the new approach.

I will think about your suggestion and the diff you sent me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The specific scenario isn't relevant under the structure of your suggested code changes. I merged all your suggestions as-is into this branch. I did leave some comments in jmoldow#1 , if you want to consider making any suggested improvements into this branch (you should have permissions) before you merge it.

# max_reconnect_attempts cycles — spacing out retries on persistently-stuck
# locations avoids log spam, excess gRPC traffic, and repeated wasted refresh
# work that we already know is likely to fail again.
refresh_code_location(location_name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I like this a lot more than the previous disconnect+reconnect thing - seems more clear what is going on

Copy link
Copy Markdown

one thing that I am thinking through currently is whether things would get simpler if this 'when the location is in a transient error state, refresh it automatically preiodically' logic was in a totally separate periodic check within the watch thread, instead of being integrated into the existing disconnect/reconnect loop, which ends up doing similar things but is a bit different

Daniel Gibson and others added 2 commits June 24, 2026 15:33
This reverts the change to _location_state_events_handler that triggers a
refresh on any event (including LOCATION_RECONNECTED / LOCATION_DISCONNECTED)
when the location is already in an error state.

The watch-thread recovery already clears the stuck error: once GetServerId
succeeds but the workspace entry is still errored, recovery is signaled via
on_updated / on_reconnected, and the original handler already refreshes on
LOCATION_UPDATED. The handler-side refresh on reconnect/disconnect is
redundant and untested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gRPC watch thread previously detected a location stuck in a transient
unreachable-error state by re-raising DagsterUserCodeUnreachableError from
inside the server-id poll, routing it through the on_disconnect/reconnect
loop, and calling refresh_code_location from within reconnect_loop with
rate-limiting tied to max_reconnect_attempts. Folding the load_error
recovery into the reconnect machinery made both hard to follow.

This decouples the two concerns. The server-id watch and reconnect loop go
back to handling only server-id changes and actual server unreachability
(firing on_updated / on_disconnect / on_reconnected / on_error). A new,
independent check runs inside the same watch thread on a fixed cadence
(ERROR_RECOVERY_INTERVAL, default 10s): if the location entry's load_error
is a DagsterUserCodeUnreachableError, it calls refresh_code_location
directly. It fires no on_* event - the refresh emits whatever state events
are warranted - so recovery is fully separate from the reconnect path.

Tests updated: the watch-thread recovery test now asserts the periodic
check refreshes a stuck location and fires no events; the integration test
asserts recovery happens with no disconnect/reconnect events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gibsondan

Copy link
Copy Markdown

@jmoldow i took a stab on top of this change with what I was imagining there here: jmoldow#1 - i think it accomplishes the same goal, but I find it a lot easier to wrap my head around what's going on there when it is separate from the existing disconnect/reconnect loop since its a bit different (even though both can result in a refresh/update). What do you think?

…ry-redesign

Decouple stuck-location recovery into a separate periodic check (proposal on top of dagster-io#33642)

## Summary & Motivation

A proposal on top of your dagster-io#33642 (`jordan/server-watcher-error-recovery`), opened against your branch so the diff here is exactly the two commits I'm suggesting. Opening it for visibility/discussion — not necessarily to merge as-is; feel free to squash into dagster-io#33642, cherry-pick, or push back.

Two changes:

1. **Revert the `_location_state_events_handler` change (recovery layer #1).** Restores the handler to refreshing only on `LOCATION_UPDATED` / `LOCATION_ERROR`. The watch-thread recovery already clears the stuck error (recovery is signaled via `on_updated` / `on_reconnected`, and the original handler already refreshes on `LOCATION_UPDATED`), so the handler-side refresh on reconnect/disconnect is redundant and currently untested — both recovery tests pass with it reverted.

2. **Move stuck-location recovery into a separate periodic check (recovery layer dagster-io#2).** Instead of weaving the `load_error` detection into the server-id poll and the reconnect loop (re-raising `DagsterUserCodeUnreachableError`, refreshing from inside `reconnect_loop`, rate-limiting via `max_reconnect_attempts`), the gRPC watch + reconnect loop goes back to handling only server-id changes and actual server unreachability. A new independent check runs inside the same watch thread on a fixed cadence (`ERROR_RECOVERY_INTERVAL`, default 10s): if the entry's `load_error` is a `DagsterUserCodeUnreachableError`, it calls `refresh_code_location` directly. It fires no `on_*` event, so recovery is fully decoupled from the reconnect path.

The motivation is readability: the layer-dagster-io#2 integration interleaved two unrelated concerns (server-id watching vs. error recovery) in a way that was hard to follow. A separate fixed-interval check expresses "retry a stuck location every 10s" directly.

## How I Tested These Changes

Updated the existing tests:
- `test_watch_server.py` — the watch-thread recovery test now asserts the periodic check refreshes a stuck location and fires no events; the server-id watch/reconnect/error tests are unchanged.
- `test_watch_grpc_server.py` — the integration recovery test asserts a stuck location recovers with no disconnect/reconnect events; the on_error/reconnect-path test is unchanged.

All of `test_watch_server.py` and `test_watch_grpc_server.py` pass locally; `ruff` and `ty` clean.

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

jmoldow commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

I merged all of @gibsondan's suggestions as-is into this branch. I did leave some comments in jmoldow#1 , if you want to consider making any suggested improvements into this branch (you should have permissions) before you merge it. I'm fine with merging immediately, or making some/all/none of the suggested changes first.

@greptileai Please fully re-review this PR.

@gibsondan gibsondan 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.

i think this turned out great - thanks for pushing on it!

@gibsondan gibsondan added this pull request to the merge queue Jun 30, 2026
Merged via the queue into dagster-io:master with commit 91061e5 Jun 30, 2026
1 of 2 checks passed
@jmoldow jmoldow deleted the jordan/server-watcher-error-recovery branch June 30, 2026 19:02
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.

Locations can end up being unloaded in spite of the watch thread

2 participants