Skip to content

feat: opt-in channel-scoped PostgreSQL temp table state store - #1994

Draft
filipecabaco wants to merge 2 commits into
mainfrom
feat/temp-table-per-channel
Draft

feat: opt-in channel-scoped PostgreSQL temp table state store#1994
filipecabaco wants to merge 2 commits into
mainfrom
feat/temp-table-per-channel

Conversation

@filipecabaco

Copy link
Copy Markdown
Member

What kind of change does this PR introduce?

Add a per-channel key/value store backed by a PostgreSQL TEMP TABLE, opted into via config.state.enabled on private channels. Each channel gets a dedicated session/temp table exposed over a new "state" message (put/insert/update/delete/get/clear) with optimistic versioning. Bounded by input limits and a per-tenant cap; best-effort so it never blocks the join.

Add a per-channel key/value store backed by a PostgreSQL TEMP TABLE, opted
into via `config.state.enabled` on private channels. Each channel gets a
dedicated session/temp table exposed over a new "state" message
(put/insert/update/delete/get/clear) with optimistic versioning. Bounded by
input limits and a per-tenant cap; best-effort so it never blocks the join.
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

CRAP Score Report

==> forum
Generated forum app
==> realtime
Compiling 257 files (.ex)
Generated realtime app
xxxxxxxxxxxxxxxxxxxxxxxxxxxxx✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓
Summary: files=229 functions=1077 scored=1077 worst_score=15944.76

** (Mix) CRAP threshold failed: max_score=30.00
High scores: 29
  lib/extensions/postgres_cdc_rls/subscription_manager.ex Extensions.PostgresCdcRls.SubscriptionManager.handle_info/2 score=37.16
  lib/realtime/adapters/postgres/oid_database.ex Realtime.Adapters.Postgres.OidDatabase.name_for_type_id/1 score=15944.76
  lib/realtime/application.ex Realtime.Application.setup_region_mapping/0 score=47.11
  lib/realtime/nodes.ex Realtime.Nodes.default_region_mapping/1 score=157.20
  lib/realtime/operations.ex Realtime.Operations.rebalance/0 score=42.00
  lib/realtime/operations.ex Realtime.Operations.kill_connections_to_tenant_id/2 score=90.00
  lib/realtime/tenants/connect.ex Realtime.Tenants.Connect.handle_info/2 score=31.86
  lib/realtime/tenants/temp_state_store.ex Realtime.Tenants.TempStateStore.run/2 score=38.24
  lib/realtime_web/channels/auth/jwt_verification.ex RealtimeWeb.JwtVerification.generate_signer/3 score=31.09
  lib/realtime_web/channels/realtime_channel.ex RealtimeWeb.RealtimeChannel.handle_info/2 score=68.63
  lib/realtime_web/channels/realtime_channel.ex RealtimeWeb.RealtimeChannel.handle_in/3 score=183.51
  lib/realtime_web/dashboard/feature_flags.ex RealtimeWeb.Dashboard.FeatureFlags.handle_event/3 score=420.00
  lib/realtime_web/dashboard/node_info.ex RealtimeWeb.Dashboard.NodeInfo.fetch_node_data/2 score=42.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.handle_event/3 score=552.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.handle_info/2 score=210.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.render_value/2 score=42.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.load_module_functions/1 score=42.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.parse_and_start/2 score=110.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.format_value/1 score=702.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.sort_entries/2 score=42.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.parse_module/1 score=90.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.handle_event/3 score=72.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.execute_read_only/1 score=72.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.mask_sensitive_columns/1 score=42.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.compare_cells/2 score=42.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.format_cell/1 score=72.00
  lib/realtime_web/dashboard/tenant_migrations.ex RealtimeWeb.Dashboard.TenantMigrations.handle_info/2 score=118.65
  lib/realtime_web/dashboard/tenant_migrations.ex RealtimeWeb.Dashboard.TenantMigrations.run_pgdelta/1 score=61.39
  lib/realtime_web/live/inspector_live/conn_component.ex RealtimeWeb.InspectorLive.ConnComponent.handle_event/3 score=132.00

@coveralls

Copy link
Copy Markdown

Coverage Status

Coverage is 91.446%feat/temp-table-per-channel into main. No base build found for main.

…ssues

Applies the confirmed findings from the review of the temp state store:

- Multi-node correctness: the capacity check no longer queries the tenant's
  shared pooled connection (which can live on another node, where DBConnection
  checkout cannot work). start/3 drops the db_conn argument entirely.
- Capacity enforcement in two layers: an exact node-local gate via
  Realtime.Registry inside init (store starts are serialized through the
  DynamicSupervisor), plus a cluster-wide backstop where the store counts
  pg_stat_activity sessions from its own connection after connecting and stops
  itself when over the limit. Removes the check-then-act race on the shared
  pool and the overclaiming "authoritative" moduledoc.
- Non-blocking starts: init is IO-free; the database connection, session setup
  and capacity backstop run in handle_continue, so a slow tenant database can
  no longer stall the shared supervisor or block channel joins node-wide.
- Lifecycle: the store monitors the tenant's Connect process and stops when it
  goes down, so rebalancing and db-settings changes no longer leave dedicated
  sessions behind. Sessions opened on failure paths are closed via terminate.
- Connection budgeting: check_tenant_connection reserves the temp state store
  capacity (capacity_limit(max_connections)) on top of pooled requirements so
  these sessions can't silently eat the headroom Connect needs to re-provision.
- Encoding: values are bound as JSON text and cast server-side (::text::jsonb),
  fixing the double-JSON-encoding that stored every value as a jsonb scalar
  string and under-counted the size limit; the compensating decode/1 is gone.
- Limits: the per-write count(*) scan is replaced by an exact app-side key
  count (the owner process serializes all mutations); at the cap, puts to
  existing keys go through an update-only path.
- Timeouts: queries run with an explicit 15s timeout and GenServer.call uses
  20s, so slow writes report their real outcome instead of :unavailable for a
  write that actually committed. Idle sessions ping at 30s instead of 1s and
  the connection keeps the tenant's queue_target.
- Input validation: keys must be strings (non-binary keys previously crashed
  the store), JSON null for state.enabled/private/expected no longer raises
  or is rejected, and the no-store "state" fallback is rate-counted.
- The channel clears the store assign when the store dies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg7KHUrgo3zf7omHkoNQEX
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.

3 participants