Skip to content

Backfill silently nulls columns of custom composite types (no Jason.Encoder), while streamed changes correctly serialize them as text #2158

Description

@roberto-palloni

Summary

For a table column whose type is a custom Postgres composite type (e.g.

CREATE TYPE money_with_currency AS
  (currency_code varchar, amount numeric)

),
TableReader's backfill path always emits null for that column, while the same column streamed via a live insert/update/delete correctly emits Postgres's text representation of the value (e.g. "(EUR,1234.56)"). This means every row loaded via backfill silently loses data in that column — deterministically, on every backfill (initial, retried, or manually re-triggered), not just intermittently.

Environment

  • Self-hosted Sequin, v0.14.6
  • RabbitMQ sink, default (non-transform) message envelope
  • Postgres source, logical replication

Reproduction

  CREATE TYPE public.money_with_currency AS (currency_code varchar, amount numeric);

  CREATE TABLE public.payments (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    amount public.money_with_currency NOT NULL
  );

  INSERT INTO public.payments (amount) VALUES (ROW('EUR', 42.00)::public.money_with_currency);
  1. Create a sink with initial_backfill: true (or manually trigger a backfill) against a table containing a money_with_currency column with existing data.
  2. Inspect the resulting action: "read" message for that row.
  3. Separately, UPDATE the same row (or insert a new one) so it's captured by the live replication stream instead, and inspect that message.

Expected

Both messages represent amount the same way — ideally the same text form the live path already produces
("(EUR,42.00)"), or some structured equivalent, but consistently.

Actual

  • Backfill (action: "read"): "amount": null
  • Live (action: "insert"/"update"): "amount": "(EUR,42.00)"

Root cause

Sequin.Postgres.load_rows/2
(https://github.com/sequinstream/sequin/blob/v0.14.6/lib/sequin/postgres/postgres.ex#L890-L936), which every
backfilled row passes through in TableReader.fetch_batch/6, special-cases a fixed list of Postgrex value types
(Pgvector, bytea/bit/varbit, uuid, ranges, intervals) and falls through to this catch-all for everything else:

This is the catch-all when encode is not implemented

  Jason.Encoder.impl_for(value) == Jason.Encoder.Any ->
    Logger.debug("[Postgres] No Jason.Encoder for #{inspect(value)}", column: col.name, table: table.name)
    nil

Postgrex decodes a custom composite type into a struct with no registered Jason.Encoder, so it silently becomes nil here — logged only at debug, easy to miss in production. This code path is specific to the backfill's typed SELECT (Postgres.result_to_maps → Postgres.load_rows); the live replication path takes Postgres's own WAL text representation directly and never goes through Postgrex's typed decoding or this function at all, which is why it doesn't hit the same gap.

Impact

Any table backfilled (initial load, a table added to include_tables after already having data, or a manually
re-triggered catch-up backfill) loses every column of a custom composite type, silently, for every row the
backfill touches. This isn't a transient failure — it will reproduce identically on every future backfill of the
same table until a decoder is registered for that type. We found it affects a double-digit percentage of rows
across multiple tables in production before noticing, since nothing in the pipeline (or Sequin itself) surfaces
it above debug log level.

Suggested fix direction

  • At minimum, log this at warning (or expose it via Sequin's health/metrics) rather than debug, so it's not silently discoverable only by downstream data audits.
  • Ideally, fall back to Postgres's own text representation of the value (the same thing the WAL/live path already produces) for any type without a registered Jason.Encoder, instead of nil — consistent behavior between backfill and streaming, and no data loss for types Sequin doesn't have first-class support for.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions