Skip to content

Normalize shape key generation from where clause #3256

Open

Description

@KyleAMathews

A user spent several days trying to debug why a big shape was frequently getting recreated (409s) when they were navigating around the app and doing other actions. They weren't doing any of the normal things that trigger 409s it seemed like changing the shape def in their proxy or pushing schema updates to their db.

After adding lots of logging to the URLs generated in their proxy, they eventually noticed that under certain conditions, the order of the where in clauses changed, triggering Electric to think it's a new shape and return a 409 to force a reload of the shape.

This was both a very noticeable performance problem w/ their app and a head-scratcher to debug.

We should prevent this issue by normalizing all non-essential parts of the where clauses. I assume we do some already but sorting where in & array operations makes sense. I had Claude come up with a long list of other potential ways to normalize https://claude.ai/share/3230d2fa-95fb-40ed-9565-d50471c44698

Activity

  1. added theissue type on Oct 7, 2025
  2. balegas commented on Feb 16, 2026

    @balegas
    Contributor

    Have we done this?

  3. KyleAMathews commented on Feb 16, 2026

    @KyleAMathews
    ContributorAuthor

    Not afaik

  4. alco commented on Apr 23, 2026

    @alco
    Contributor

    PR #4051 adds a round-trippable AST-to-SQL deparser (Electric.Replication.Eval.SqlGenerator). That's the missing piece that makes a clean fix for this tractable — once it merges.

    The current shape-identity equivalence goes through Expr.Comparable in eval/expr.ex:

    def comparable(%Expr{} = expr), do: {:eval_expr, expr.query, expr.returns}

    expr.query is the raw user-supplied SQL text, which is why whitespace/order/paren changes in the WHERE clause reshape the hash and force a 409. After PR #4051 it should be straightforward to swap the source text for a canonical deparse of the AST:

    def comparable(%Expr{} = expr), do: {:eval_expr, SqlGenerator.to_sql(expr.eval), expr.returns}

    SqlGenerator is deterministic and precedence-aware, so two WHEREs that parse to the same AST will produce identical normalized SQL — which kills off whitespace, redundant parens, and casing differences for free. Sorting/canonicalization for commutative ops (IN lists, AND/OR chains, array operations) would still need to be added at the AST level to fix the original report's WHERE IN order-churn, but that's a much smaller change to layer on top of the deparser.

    A secondary bonus: PR #4051 also uses this same comparable to dedup repeated subqueries within one shape's WHERE (build_shape_dependencies/2 in shape.ex), so the same normalization would collapse duplicate-but-textually-different subqueries into a single inner materializer at no extra cost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions