Skip to content

Commit 6a220e7

Browse files
authored
feat: realtime v2 serializer (#1606)
New V2 serializer that is a super set of the Phoenix V2 JSON Serializer. It has 2 special types of messages: * User Broadcast Push * User Broadcast The clients will be able to send a new User Broadcast Push which allows for binary or JSON payloads. It allows for a more efficient handling of the user payload as well. The backend will be able to then broadcast the User Broadcast Push as User Broadcast for the serializers with V2 while also being able to convert to the V1 Broadcast that Phoenix V1 JSON Serializer supports as long as the user payload is JSON. If a binary payload is sent and there is a websocket using V1 listening it won't receive such message as only JSON is supported by V1. This PR also add a new GitHub workflow to run integration tests with deno. Currently pointing to a realtime-js preview release for now.
1 parent 9120f89 commit 6a220e7

File tree

21 files changed

+2012
-607
lines changed

21 files changed

+2012
-607
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Integration Tests
2+
on:
3+
pull_request:
4+
paths:
5+
- "lib/**"
6+
- "test/**"
7+
- "config/**"
8+
- "priv/**"
9+
- "assets/**"
10+
- "rel/**"
11+
- "mix.exs"
12+
- "Dockerfile"
13+
- "run.sh"
14+
- "docker-compose.test.yml"
15+
16+
push:
17+
branches:
18+
- main
19+
20+
concurrency:
21+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
22+
cancel-in-progress: true
23+
24+
jobs:
25+
tests:
26+
name: Tests
27+
runs-on: blacksmith-8vcpu-ubuntu-2404
28+
29+
steps:
30+
- uses: actions/checkout@v2
31+
- name: Run integration test
32+
run: docker compose -f docker-compose.tests.yml up --abort-on-container-exit --exit-code-from test-runner
33+

docker-compose.tests.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
services:
2+
# Supabase Realtime service
3+
test_db:
4+
image: supabase/postgres:14.1.0.105
5+
container_name: test-realtime-db
6+
ports:
7+
- "5532:5432"
8+
volumes:
9+
- ./dev/postgres:/docker-entrypoint-initdb.d/
10+
command: postgres -c config_file=/etc/postgresql/postgresql.conf
11+
environment:
12+
POSTGRES_HOST: /var/run/postgresql
13+
POSTGRES_PASSWORD: postgres
14+
healthcheck:
15+
test: ["CMD-SHELL", "pg_isready -U postgres"]
16+
interval: 10s
17+
timeout: 5s
18+
retries: 5
19+
test_realtime:
20+
depends_on:
21+
- test_db
22+
build: .
23+
container_name: test-realtime-server
24+
ports:
25+
- "4100:4100"
26+
extra_hosts:
27+
- "host.docker.internal:host-gateway"
28+
environment:
29+
PORT: 4100
30+
DB_HOST: host.docker.internal
31+
DB_PORT: 5532
32+
DB_USER: postgres
33+
DB_PASSWORD: postgres
34+
DB_NAME: postgres
35+
DB_ENC_KEY: 1234567890123456
36+
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
37+
API_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long
38+
SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
39+
ERL_AFLAGS: -proto_dist inet_tcp
40+
RLIMIT_NOFILE: 1000000
41+
DNS_NODES: "''"
42+
APP_NAME: realtime
43+
RUN_JANITOR: true
44+
JANITOR_INTERVAL: 60000
45+
LOG_LEVEL: "info"
46+
SEED_SELF_HOST: true
47+
networks:
48+
test-network:
49+
aliases:
50+
- realtime-dev.local
51+
- realtime-dev.localhost
52+
healthcheck:
53+
test: ["CMD", "curl", "-f", "http://localhost:4100/"]
54+
interval: 10s
55+
timeout: 5s
56+
retries: 5
57+
start_period: 5s
58+
59+
# Deno test runner
60+
test-runner:
61+
image: denoland/deno:alpine-2.5.6
62+
container_name: deno-test-runner
63+
depends_on:
64+
test_realtime:
65+
condition: service_healthy
66+
test_db:
67+
condition: service_healthy
68+
volumes:
69+
- ./test/integration/tests.ts:/app/tests.ts:ro
70+
working_dir: /app
71+
command: >
72+
sh -c "
73+
echo 'Running tests...' &&
74+
deno test tests.ts --allow-import --no-check --allow-read --allow-net --trace-leaks --allow-env=WS_NO_BUFFER_UTIL
75+
"
76+
networks:
77+
- test-network
78+
extra_hosts:
79+
- "realtime-dev.localhost:host-gateway"
80+
81+
networks:
82+
test-network:
83+
driver: bridge

lib/realtime_web/channels/realtime_channel/broadcast_handler.ex

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do
1515
alias Realtime.Tenants.Authorization.Policies
1616
alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies
1717

18+
@type payload :: map | {String.t(), :json | :binary, binary}
19+
1820
@event_type "broadcast"
19-
@spec handle(map(), Socket.t()) :: {:reply, :ok, Socket.t()} | {:noreply, Socket.t()}
21+
@spec handle(payload, Socket.t()) :: {:reply, :ok, Socket.t()} | {:noreply, Socket.t()}
2022
def handle(payload, %{assigns: %{private?: false}} = socket), do: handle(payload, nil, socket)
2123

22-
@spec handle(map(), pid() | nil, Socket.t()) :: {:reply, :ok, Socket.t()} | {:noreply, Socket.t()}
24+
@spec handle(payload, pid() | nil, Socket.t()) :: {:reply, :ok, Socket.t()} | {:noreply, Socket.t()}
2325
def handle(payload, db_conn, %{assigns: %{private?: true}} = socket) do
2426
%{
2527
assigns: %{
@@ -101,7 +103,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do
101103
end
102104

103105
defp send_message(tenant_id, self_broadcast, tenant_topic, payload) do
104-
broadcast = %Phoenix.Socket.Broadcast{topic: tenant_topic, event: @event_type, payload: payload}
106+
broadcast = build_broadcast(tenant_topic, payload)
105107

106108
if self_broadcast do
107109
TenantBroadcaster.pubsub_broadcast(
@@ -123,6 +125,23 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do
123125
end
124126
end
125127

128+
# No idea why Dialyzer is complaining here
129+
@dialyzer {:nowarn_function, build_broadcast: 2}
130+
131+
# Message payload was built by V2 Serializer which was originally UserBroadcastPush
132+
defp build_broadcast(topic, {user_event, user_payload_encoding, user_payload}) do
133+
%RealtimeWeb.Socket.UserBroadcast{
134+
topic: topic,
135+
user_event: user_event,
136+
user_payload_encoding: user_payload_encoding,
137+
user_payload: user_payload
138+
}
139+
end
140+
141+
defp build_broadcast(topic, payload) do
142+
%Phoenix.Socket.Broadcast{topic: topic, event: @event_type, payload: payload}
143+
end
144+
126145
defp increment_rate_counter(%{assigns: %{policies: %Policies{broadcast: %BroadcastPolicies{write: false}}}} = socket) do
127146
socket
128147
end

lib/realtime_web/channels/realtime_channel/message_dispatcher.ex

Lines changed: 85 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,92 +4,138 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do
44
"""
55

66
require Logger
7+
alias Phoenix.Socket.Broadcast
8+
alias RealtimeWeb.Socket.UserBroadcast
79

810
def fastlane_metadata(fastlane_pid, serializer, topic, log_level, tenant_id, replayed_message_ids \\ MapSet.new()) do
911
{:rc_fastlane, fastlane_pid, serializer, topic, log_level, tenant_id, replayed_message_ids}
1012
end
1113

14+
@presence_diff "presence_diff"
15+
1216
@doc """
1317
This dispatch function caches encoded messages if fastlane is used
1418
It also sends an :update_rate_counter to the subscriber and it can conditionally log
15-
"""
16-
@spec dispatch(list, pid, Phoenix.Socket.Broadcast.t()) :: :ok
17-
def dispatch(subscribers, from, %Phoenix.Socket.Broadcast{event: event} = msg) do
18-
# fastlane_pid is the actual socket transport pid
19-
# This reduce caches the serialization and bypasses the channel process going straight to the
20-
# transport process
21-
22-
message_id = message_id(msg.payload)
2319
20+
fastlane_pid is the actual socket transport pid
21+
"""
22+
@spec dispatch(list, pid, Broadcast.t() | UserBroadcast.t()) :: :ok
23+
def dispatch(subscribers, from, %Broadcast{event: @presence_diff} = msg) do
2424
{_cache, count} =
2525
Enum.reduce(subscribers, {%{}, 0}, fn
2626
{pid, _}, {cache, count} when pid == from ->
2727
{cache, count}
2828

29-
{pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, log_level, tenant_id, replayed_message_ids}},
29+
{_pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, log_level, tenant_id, _replayed_message_ids}},
3030
{cache, count} ->
31+
maybe_log(log_level, join_topic, msg, tenant_id)
32+
33+
cache = do_dispatch(msg, fastlane_pid, serializer, join_topic, cache, tenant_id, log_level)
34+
{cache, count + 1}
35+
36+
{pid, _}, {cache, count} ->
37+
send(pid, msg)
38+
{cache, count}
39+
end)
40+
41+
tenant_id = tenant_id(subscribers)
42+
increment_presence_counter(tenant_id, msg.event, count)
43+
44+
:ok
45+
end
46+
47+
def dispatch(subscribers, from, msg) do
48+
message_id = message_id(msg)
49+
50+
_ =
51+
Enum.reduce(subscribers, %{}, fn
52+
{pid, _}, cache when pid == from ->
53+
cache
54+
55+
{pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, log_level, tenant_id, replayed_message_ids}},
56+
cache ->
3157
if already_replayed?(message_id, replayed_message_ids) do
3258
# skip already replayed message
33-
{cache, count}
59+
cache
3460
else
35-
if event != "presence_diff", do: send(pid, :update_rate_counter)
61+
send(pid, :update_rate_counter)
3662

3763
maybe_log(log_level, join_topic, msg, tenant_id)
3864

39-
cache = do_dispatch(msg, fastlane_pid, serializer, join_topic, cache)
40-
{cache, count + 1}
65+
do_dispatch(msg, fastlane_pid, serializer, join_topic, cache, tenant_id, log_level)
4166
end
4267

43-
{pid, _}, {cache, count} ->
68+
{pid, _}, cache ->
4469
send(pid, msg)
45-
{cache, count}
70+
cache
4671
end)
4772

48-
tenant_id = tenant_id(subscribers)
49-
increment_presence_counter(tenant_id, event, count)
50-
5173
:ok
5274
end
5375

54-
defp increment_presence_counter(tenant_id, "presence_diff", count) when is_binary(tenant_id) do
55-
tenant_id
56-
|> Realtime.Tenants.presence_events_per_second_key()
57-
|> Realtime.GenCounter.add(count)
76+
defp maybe_log(:info, join_topic, msg, tenant_id) when is_struct(msg) do
77+
log = "Received message on #{join_topic} with payload: #{inspect(msg, pretty: true)}"
78+
Logger.info(log, external_id: tenant_id, project: tenant_id)
5879
end
5980

60-
defp increment_presence_counter(_tenant_id, _event, _count), do: :ok
61-
62-
defp maybe_log(:info, join_topic, msg, tenant_id) do
63-
log = "Received message on #{join_topic} with payload: #{inspect(msg, pretty: true)}"
81+
defp maybe_log(:info, join_topic, msg, tenant_id) when is_binary(msg) do
82+
log = "Received message on #{join_topic}. #{msg}"
6483
Logger.info(log, external_id: tenant_id, project: tenant_id)
6584
end
6685

6786
defp maybe_log(_level, _join_topic, _msg, _tenant_id), do: :ok
6887

69-
defp message_id(%{"meta" => %{"id" => id}}), do: id
70-
defp message_id(_), do: nil
71-
72-
defp already_replayed?(nil, _replayed_message_ids), do: false
73-
defp already_replayed?(message_id, replayed_message_ids), do: MapSet.member?(replayed_message_ids, message_id)
74-
75-
defp do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) do
88+
defp do_dispatch(msg, fastlane_pid, serializer, join_topic, cache, tenant_id, log_level) do
7689
case cache do
77-
%{^serializer => encoded_msg} ->
90+
%{^serializer => {:ok, encoded_msg}} ->
7891
send(fastlane_pid, encoded_msg)
7992
cache
8093

94+
%{^serializer => {:error, _reason}} ->
95+
# We do nothing at this stage. It has been already logged depending on the log level
96+
cache
97+
8198
%{} ->
8299
# Use the original topic that was joined without the external_id
83100
msg = %{msg | topic: join_topic}
84-
encoded_msg = serializer.fastlane!(msg)
85-
send(fastlane_pid, encoded_msg)
86-
Map.put(cache, serializer, encoded_msg)
101+
102+
result =
103+
case fastlane!(serializer, msg) do
104+
{:ok, encoded_msg} ->
105+
send(fastlane_pid, encoded_msg)
106+
{:ok, encoded_msg}
107+
108+
{:error, reason} ->
109+
maybe_log(log_level, join_topic, reason, tenant_id)
110+
end
111+
112+
Map.put(cache, serializer, result)
87113
end
88114
end
89115

90-
defp tenant_id([{_pid, {:rc_fastlane, _, _, _, _, tenant_id, _}} | _]) do
91-
tenant_id
116+
# We have to convert because V1 does not know how to process UserBroadcast
117+
defp fastlane!(Phoenix.Socket.V1.JSONSerializer = serializer, %UserBroadcast{} = msg) do
118+
with {:ok, msg} <- UserBroadcast.convert_to_json_broadcast(msg) do
119+
{:ok, serializer.fastlane!(msg)}
120+
end
92121
end
93122

123+
defp fastlane!(serializer, msg), do: {:ok, serializer.fastlane!(msg)}
124+
125+
defp tenant_id([{_pid, {:rc_fastlane, _, _, _, _, tenant_id, _}} | _]), do: tenant_id
94126
defp tenant_id(_), do: nil
127+
128+
defp increment_presence_counter(tenant_id, "presence_diff", count) when is_binary(tenant_id) do
129+
tenant_id
130+
|> Realtime.Tenants.presence_events_per_second_key()
131+
|> Realtime.GenCounter.add(count)
132+
end
133+
134+
defp increment_presence_counter(_tenant_id, _event, _count), do: :ok
135+
136+
defp message_id(%Broadcast{payload: %{"meta" => %{"id" => id}}}), do: id
137+
defp message_id(_), do: nil
138+
139+
defp already_replayed?(nil, _replayed_message_ids), do: false
140+
defp already_replayed?(message_id, replayed_message_ids), do: MapSet.member?(replayed_message_ids, message_id)
95141
end

lib/realtime_web/endpoint.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ defmodule RealtimeWeb.Endpoint do
2525
# the expense of potentially higher memory being used.
2626
active_n: 100,
2727
# Skip validating UTF8 for faster frame processing.
28-
# Currently all text frames as handled only with JSON which already requires UTF-8
28+
# Currently all text frames are handled only with JSON which already requires UTF-8
2929
validate_utf8: false,
3030
serializer: [
3131
{Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"},
32-
{Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"}
32+
{RealtimeWeb.Socket.V2Serializer, "~> 2.0.0"}
3333
]
3434
],
3535
longpoll: [

lib/realtime_web/plugs/auth_tenant.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ defmodule RealtimeWeb.AuthTenant do
4242
[] ->
4343
nil
4444

45+
[""] ->
46+
nil
47+
4548
[value | _] ->
4649
[bearer, token] = value |> String.split(" ")
4750
bearer = String.downcase(bearer)

0 commit comments

Comments
 (0)