Skip to content

fix(sync-service): start the consumer of a dependency shape on demand when its materializer starts - #4774

Open
Jacob-Clark-Tipalti wants to merge 1 commit into
electric-sql:mainfrom
Jacob-Clark-Tipalti:fix/start-dependency-consumer-on-demand
Open

fix(sync-service): start the consumer of a dependency shape on demand when its materializer starts#4774
Jacob-Clark-Tipalti wants to merge 1 commit into
electric-sql:mainfrom
Jacob-Clark-Tipalti:fix/start-dependency-consumer-on-demand

Conversation

@Jacob-Clark-Tipalti

@Jacob-Clark-Tipalti Jacob-Clark-Tipalti commented Aug 26, 2026

Copy link
Copy Markdown

The problem

In production (sync-service 1.7.11) we observed requests for a shape with a subquery failing with a 500 on every attempt. The flow for each request:

  1. The request creates the parent shape, which resolves its subquery to an already-registered dependency shape handle.

  2. A materializer is started for the dependency, but its startup crashes because the dependency shape has no running consumer:

    13:09:59.266 shape_handle=71390006-1787076343081033 [warning] Materializer startup failed with exit reason: {:noproc, {GenServer, :call, [nil, :await_snapshot_start, :infinity]}}
    
  3. The parent consumer's initialization then finds no live materializer to subscribe to and invalidates itself:

    13:09:59.268 shape_handle=71390006-1787076343081033 [warning] Materializer for shape is not alive, invalidating shape
    
  4. The request process, blocked in await_snapshot_start on the parent consumer, crashes when the consumer stops, producing the 500:

    13:09:59.271 request_id=GM8OY-MeYP1uOKAAlDMC [error] ** (exit) exited in: GenServer.call(#PID<0.444658.0>, :await_snapshot_start, 45000)
    

This path is problematic because nothing in it restarts the dependency's consumer or removes the dependency's registration: only the parent is cleaned up. The client's retry creates a new parent, resolves the subquery to the same dependency handle, and fails identically. We observed a single user looping through this for 17 minutes (28 requests, 28 500s) with no recovery, and the same loop affecting tens of users per day.

Why is the dependency's consumer not running?

We do not know. What we can say is that the state exists in production: the dependency handle is registered in ShapeStatus with a completed snapshot, but no consumer process is running and the ConsumerRegistry lookup returns nil (visible in the GenServer.call(nil, ...) above).

One guess: the dependency was orphaned before a restart (parents are removed by several cleanup paths that leave their dependencies registered), and prune_subquery_shapes/1 only matches dependencies through a surviving parent, so an orphan is restored as a plain shape, whose consumer only starts lazily on transaction routing. The timeline fits (the failing handles predate the pod's last restart), but we have not confirmed this is the producer, and there may be others.

The (partial) solution

Have the materializer start the missing consumer via ShapeCache.start_consumer_for_handle/3, the same mechanism the transaction routing path uses to lazily start consumers, instead of assuming one is running. The dependency's snapshot and log are intact on disk, so this recovers the existing shape rather than discarding it, and the parent request succeeds.

Adds a regression test that constructs the registered-but-no-consumer state and asserts a new parent request recovers the existing dependency (same handle, consumer restarted) instead of looping.

This does not address whatever root cause leaves a registered dependency shape without a running consumer in the first place; it makes the state recoverable when it is encountered.

… when its materializer starts

A dependency (subquery) shape can be registered in ShapeStatus with a
completed snapshot but no running consumer: consumers for restored
shapes start lazily on their first transaction, and a dependency shape
orphaned by its parent's removal is restored as a plain shape on
restart (prune_subquery_shapes/1 only matches dependencies through a
surviving parent).

A new parent shape then resolves its subquery to the orphaned handle,
its dependency materializer crashes calling the missing consumer, and
the parent is invalidated while the dependency stays registered, so
every retry fails the same way.

Have the materializer start the missing consumer via
ShapeCache.start_consumer_for_handle/3, the same mechanism the
transaction routing path uses, instead of assuming one is running.
@netlify

netlify Bot commented Aug 26, 2026

Copy link
Copy Markdown

Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit 7404b5d
🔍 Latest deploy log https://app.netlify.com/projects/electric-next/deploys/6a8eb5ecf298290008a293e2
😎 Deploy Preview https://deploy-preview-4774--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

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.

1 participant