From 181c6ca84b83f1316a74cc0aa224f7255f634087 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Fri, 6 Mar 2026 16:37:31 -0700 Subject: [PATCH] working on redundancy --- Dockerfile | 38 ++++++++++++++++++++++ assets/js/app.js | 9 ++++- config/config.exs | 14 ++++++++ config/runtime.exs | 32 +++++++++++++++--- docker-compose.yml | 47 +++++++++++++++++++++++++++ lib/elixir_ai/application.ex | 22 ++++++++++--- lib/elixir_ai/chat_runner.ex | 15 ++++++++- lib/elixir_ai/cluster_singleton.ex | 31 ++++++++++++++++++ lib/elixir_ai/conversation_manager.ex | 27 ++++++++++----- lib/elixir_ai/data/conversation.ex | 2 +- lib/elixir_ai/data/message.ex | 19 ++++++++--- mix.exs | 8 +++-- mix.lock | 5 +++ nginx/nginx.conf | 34 +++++++++++++++++++ postgres/pg_hba.conf | 5 +++ schema.sql | 3 +- 16 files changed, 282 insertions(+), 29 deletions(-) create mode 100644 Dockerfile create mode 100644 lib/elixir_ai/cluster_singleton.ex create mode 100644 nginx/nginx.conf create mode 100644 postgres/pg_hba.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3b128d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# ---- Build stage ---- +FROM elixir:1.19.5-otp-28-alpine AS build + +RUN apk add --no-cache build-base git nodejs npm + +WORKDIR /app + +ENV MIX_ENV=prod + +RUN mix local.hex --force && mix local.rebar --force + +COPY mix.exs mix.lock ./ +RUN mix deps.get --only prod +RUN mix deps.compile + +COPY config config +COPY priv priv +COPY lib lib +COPY assets assets + +RUN mix assets.deploy +RUN mix release + +# ---- Runtime stage ---- +FROM elixir:1.19.5-otp-28-alpine AS runtime + +RUN apk add --no-cache libstdc++ openssl ncurses-libs + +WORKDIR /app + +COPY --from=build /app/_build/prod/rel/elixir_ai ./ +RUN touch .env + +ENV PHX_SERVER=true + +EXPOSE 4000 + +CMD ["bin/elixir_ai", "start"] diff --git a/assets/js/app.js b/assets/js/app.js index db1f222..a664839 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -94,9 +94,16 @@ Hooks.ScrollBottom = { } let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + +// Retry very aggressively: 100ms, 250ms, 500ms, then cap at 1s indefinitely. +const reconnectAfterMs = (tries) => [100, 250, 500][tries - 1] || 1000 +const rejoinAfterMs = (tries) => [100, 250, 500][tries - 1] || 1000 + let liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken}, - hooks: Hooks + hooks: Hooks, + reconnectAfterMs, + rejoinAfterMs }) // Show progress bar on live navigation and form submits diff --git a/config/config.exs b/config/config.exs index 09f297c..2112925 100644 --- a/config/config.exs +++ b/config/config.exs @@ -51,6 +51,20 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# Lower the BEAM node-down detection window from the default 60s. +# Nodes send ticks every (net_ticktime / 4)s; a node is declared down +# after 4 missed ticks (net_ticktime total). 5s means detection in ≤5s. +config :kernel, net_ticktime: 2 + +# Libcluster — Gossip strategy works for local dev and Docker Compose +# (UDP multicast, zero config). Overridden to Kubernetes.DNS in runtime.exs for prod. +config :libcluster, + topologies: [ + gossip: [ + strategy: Cluster.Strategy.Gossip + ] + ] + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/runtime.exs b/config/runtime.exs index 2146aaf..8162a71 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -29,11 +29,6 @@ if System.get_env("PHX_SERVER") do end if config_env() == :prod do - # The secret key base is used to sign/encrypt cookies and other secrets. - # A default value is used in config/dev.exs and config/test.exs but you - # want to use a different value for prod and you most likely don't want - # to check this value into version control, so we use an environment - # variable instead. secret_key_base = System.get_env("SECRET_KEY_BASE") || raise """ @@ -41,6 +36,33 @@ if config_env() == :prod do You can generate one by calling: mix phx.gen.secret """ + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://user:password@host/database + """ + + config :elixir_ai, ElixirAi.Repo, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + + # In Kubernetes, switch from Gossip to DNS-based discovery via a headless service. + # Set K8S_NAMESPACE and optionally K8S_SERVICE_NAME in your pod spec. + if System.get_env("K8S_NAMESPACE") do + config :libcluster, + topologies: [ + k8s_dns: [ + strategy: Cluster.Strategy.Kubernetes.DNS, + config: [ + service: System.get_env("K8S_SERVICE_NAME") || "elixir-ai-headless", + application_name: "elixir_ai", + namespace: System.get_env("K8S_NAMESPACE") + ] + ] + ] + end + host = System.get_env("PHX_HOST") || "example.com" port = String.to_integer(System.get_env("PORT") || "4000") diff --git a/docker-compose.yml b/docker-compose.yml index 17f7aba..12daad0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,54 @@ services: POSTGRES_USER: elixir_ai POSTGRES_PASSWORD: elixir_ai POSTGRES_DB: elixir_ai_dev + command: postgres -c hba_file=/etc/postgresql/pg_hba.conf ports: - 5432:5432 volumes: - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql + - ./postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf + healthcheck: + test: ["CMD-SHELL", "pg_isready -U elixir_ai -d elixir_ai_dev"] + interval: 5s + timeout: 5s + retries: 10 + + node1: + build: . + hostname: node1 + env_file: .env + environment: + DATABASE_URL: ecto://elixir_ai:elixir_ai@db/elixir_ai_dev + PHX_HOST: localhost + PORT: 4000 + RELEASE_NODE: elixir_ai@node1 + RELEASE_COOKIE: secret_cluster_cookie + SECRET_KEY_BASE: F1nY5uSyD0HfoWejcuuQiaQoMQrjrlFigb3bJ7p4hTXwpTza6sPLpmd+jLS7p0Sh + depends_on: + db: + condition: service_healthy + + node2: + build: . + hostname: node2 + env_file: .env + environment: + DATABASE_URL: ecto://elixir_ai:elixir_ai@db/elixir_ai_dev + PHX_HOST: localhost + PORT: 4000 + RELEASE_NODE: elixir_ai@node2 + RELEASE_COOKIE: secret_cluster_cookie + SECRET_KEY_BASE: F1nY5uSyD0HfoWejcuuQiaQoMQrjrlFigb3bJ7p4hTXwpTza6sPLpmd+jLS7p0Sh + depends_on: + db: + condition: service_healthy + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - node1 + - node2 diff --git a/lib/elixir_ai/application.ex b/lib/elixir_ai/application.ex index 4df0b6c..dae08e4 100644 --- a/lib/elixir_ai/application.ex +++ b/lib/elixir_ai/application.ex @@ -7,13 +7,27 @@ defmodule ElixirAi.Application do children = [ ElixirAiWeb.Telemetry, ElixirAi.Repo, - {DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore}, + {Cluster.Supervisor, + [Application.get_env(:libcluster, :topologies, []), [name: ElixirAi.ClusterSupervisor]]}, {Phoenix.PubSub, name: ElixirAi.PubSub}, ElixirAi.ToolTesting, ElixirAiWeb.Endpoint, - {Registry, keys: :unique, name: ElixirAi.ChatRegistry}, - {DynamicSupervisor, name: ElixirAi.ChatRunnerSupervisor, strategy: :one_for_one}, - ElixirAi.ConversationManager + {Horde.Registry, + [ + name: ElixirAi.ChatRegistry, + keys: :unique, + members: :auto, + delta_crdt_options: [sync_interval: 100] + ]}, + {Horde.DynamicSupervisor, + [ + name: ElixirAi.ChatRunnerSupervisor, + strategy: :one_for_one, + members: :auto, + delta_crdt_options: [sync_interval: 100], + process_redistribution: :active + ]}, + ElixirAi.ClusterSingleton ] opts = [strategy: :one_for_one, name: ElixirAi.Supervisor] diff --git a/lib/elixir_ai/chat_runner.ex b/lib/elixir_ai/chat_runner.ex index 38e1e7c..aeea823 100644 --- a/lib/elixir_ai/chat_runner.ex +++ b/lib/elixir_ai/chat_runner.ex @@ -4,7 +4,7 @@ defmodule ElixirAi.ChatRunner do import ElixirAi.ChatUtils alias ElixirAi.{Conversation, Message} - defp via(name), do: {:via, Registry, {ElixirAi.ChatRegistry, name}} + defp via(name), do: {:via, Horde.Registry, {ElixirAi.ChatRegistry, name}} defp topic(name), do: "ai_chat:#{name}" defp message_topic(name), do: "conversation_messages:#{name}" @@ -32,6 +32,13 @@ defmodule ElixirAi.ChatRunner do _ -> [] end + last_message = List.last(messages) + + if last_message && last_message.role == :user do + Logger.info("Last message role was #{last_message.role}, requesting AI response for conversation #{name}") + request_ai_response(self(), messages, tools(self(), name)) + end + {:ok, %{ name: name, @@ -282,6 +289,12 @@ defmodule ElixirAi.ChatRunner do }} end + def handle_info({:ai_request_error, reason}, state) do + Logger.error("AI request error: #{inspect(reason)}") + broadcast_ui(state.name, {:ai_request_error, reason}) + {:noreply, %{state | streaming_response: nil, pending_tool_calls: []}} + end + def handle_call(:get_conversation, _from, state) do {:reply, state, state} end diff --git a/lib/elixir_ai/cluster_singleton.ex b/lib/elixir_ai/cluster_singleton.ex new file mode 100644 index 0000000..adf51a2 --- /dev/null +++ b/lib/elixir_ai/cluster_singleton.ex @@ -0,0 +1,31 @@ +defmodule ElixirAi.ClusterSingleton do + use GenServer + + @sync_delay_ms 200 + + @singletons [ElixirAi.ConversationManager] + + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + Process.send_after(self(), :start_singletons, @sync_delay_ms) + {:ok, :pending} + end + + @impl true + def handle_info(:start_singletons, state) do + for module <- @singletons do + case Horde.DynamicSupervisor.start_child(ElixirAi.ChatRunnerSupervisor, module) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + {:error, :already_present} -> :ok + {:error, reason} -> + require Logger + Logger.warning("ClusterSingleton: failed to start #{inspect(module)}: #{inspect(reason)}") + end + end + + {:noreply, :started} + end +end diff --git a/lib/elixir_ai/conversation_manager.ex b/lib/elixir_ai/conversation_manager.ex index 2026e0b..a74eade 100644 --- a/lib/elixir_ai/conversation_manager.ex +++ b/lib/elixir_ai/conversation_manager.ex @@ -2,7 +2,19 @@ defmodule ElixirAi.ConversationManager do use GenServer alias ElixirAi.{Conversation, Message} - def start_link(_opts), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__) + @name {:via, Horde.Registry, {ElixirAi.ChatRegistry, __MODULE__}} + + def start_link(_opts) do + GenServer.start_link(__MODULE__, nil, name: @name) + end + + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + restart: :transient + } + end def init(_) do names = Conversation.all_names() @@ -11,19 +23,19 @@ defmodule ElixirAi.ConversationManager do end def create_conversation(name) do - GenServer.call(__MODULE__, {:create, name}) + GenServer.call(@name, {:create, name}) end def open_conversation(name) do - GenServer.call(__MODULE__, {:open, name}) + GenServer.call(@name, {:open, name}) end def list_conversations do - GenServer.call(__MODULE__, :list) + GenServer.call(@name, :list) end def get_messages(name) do - GenServer.call(__MODULE__, {:get_messages, name}) + GenServer.call(@name, {:get_messages, name}) end def handle_call({:create, name}, _from, conversations) do @@ -64,10 +76,9 @@ defmodule ElixirAi.ConversationManager do def handle_info({:store_message, name, message}, conversations) do messages = Map.get(conversations, name, []) - position = length(messages) case Conversation.find_id(name) do - {:ok, conv_id} -> Message.insert(conv_id, message, position) + {:ok, conv_id} -> Message.insert(conv_id, message) _ -> :ok end @@ -76,7 +87,7 @@ defmodule ElixirAi.ConversationManager do defp start_and_subscribe(name) do result = - case DynamicSupervisor.start_child( + case Horde.DynamicSupervisor.start_child( ElixirAi.ChatRunnerSupervisor, {ElixirAi.ChatRunner, name: name} ) do diff --git a/lib/elixir_ai/data/conversation.ex b/lib/elixir_ai/data/conversation.ex index f7673e8..f372f10 100644 --- a/lib/elixir_ai/data/conversation.ex +++ b/lib/elixir_ai/data/conversation.ex @@ -7,7 +7,7 @@ defmodule ElixirAi.Conversation do end def create(name) do - case Repo.insert_all("conversations", [[id: Ecto.UUID.generate(), name: name, inserted_at: now(), updated_at: now()]]) do + case Repo.insert_all("conversations", [[name: name, inserted_at: now(), updated_at: now()]]) do {1, _} -> :ok _ -> {:error, :db_error} end diff --git a/lib/elixir_ai/data/message.ex b/lib/elixir_ai/data/message.ex index 6e20d54..d54e884 100644 --- a/lib/elixir_ai/data/message.ex +++ b/lib/elixir_ai/data/message.ex @@ -6,7 +6,7 @@ defmodule ElixirAi.Message do Repo.all( from m in "messages", where: m.conversation_id == ^conversation_id, - order_by: m.position, + order_by: m.id, select: %{ role: m.role, content: m.content, @@ -18,17 +18,15 @@ defmodule ElixirAi.Message do |> Enum.map(&decode_message/1) end - def insert(conversation_id, message, position) do + def insert(conversation_id, message) do Repo.insert_all("messages", [ [ - id: Ecto.UUID.generate(), conversation_id: conversation_id, role: to_string(message.role), content: message[:content], reasoning_content: message[:reasoning_content], tool_calls: encode_tool_calls(message[:tool_calls]), tool_call_id: message[:tool_call_id], - position: position, inserted_at: DateTime.truncate(DateTime.utc_now(), :second) ] ]) @@ -40,9 +38,22 @@ defmodule ElixirAi.Message do defp decode_message(row) do row |> Map.update!(:role, &String.to_existing_atom/1) + |> Map.update(:tool_calls, nil, fn + nil -> nil + json when is_binary(json) -> + json |> Jason.decode!() |> Enum.map(&atomize_keys/1) + already_decoded -> Enum.map(already_decoded, &atomize_keys/1) + end) |> drop_nil_fields() end + defp atomize_keys(map) when is_map(map) do + Map.new(map, fn + {k, v} when is_binary(k) -> {String.to_atom(k), v} + {k, v} -> {k, v} + end) + end + defp drop_nil_fields(map) do Map.reject(map, fn {_k, v} -> is_nil(v) end) end diff --git a/mix.exs b/mix.exs index eef9288..0d590ba 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule ElixirAi.MixProject do [ app: :elixir_ai, version: "0.1.0", - elixir: "~> 1.14", + elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), @@ -53,10 +53,12 @@ defmodule ElixirAi.MixProject do {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, - {:dns_cluster, "~> 0.1.1"}, + {:dns_cluster, "~> 0.1.1", only: :dev}, + {:libcluster, "~> 3.3"}, {:bandit, "~> 1.5"}, {:ecto_sql, "~> 3.11"}, - {:postgrex, ">= 0.0.0"} + {:postgrex, ">= 0.0.0"}, + {:horde, "~> 0.9"} ] end diff --git a/mix.lock b/mix.lock index 89abd68..24beea3 100644 --- a/mix.lock +++ b/mix.lock @@ -6,6 +6,7 @@ "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "delta_crdt": {:hex, :delta_crdt, "0.6.5", "c7bb8c2c7e60f59e46557ab4e0224f67ba22f04c02826e273738f3dcc4767adc", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6ae23a525d30f96494186dd11bf19ed9ae21d9fe2c1f1b217d492a7cc7294ae"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"}, "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, @@ -20,11 +21,15 @@ "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, + "horde": {:hex, :horde, "0.10.0", "31c6a633057c3ec4e73064d7b11ba409c9f3c518aa185377d76bee441b76ceb0", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.7", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "0b51c435cb698cac9bf9c17391dce3ebb1376ae6154c81f077fc61db771b9432"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"}, + "libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"}, + "merkle_map": {:hex, :merkle_map, "0.2.2", "f36ff730cca1f2658e317a3c73406f50bbf5ac8aff54cf837d7ca2069a6e251c", [:mix], [], "hexpm", "383107f0503f230ac9175e0631647c424efd027e89ea65ab5ea12eeb54257aaf"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..1860639 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,34 @@ +upstream backend { + least_conn; + server node1:4000 max_fails=1 fail_timeout=5s; + server node2:4000 max_fails=1 fail_timeout=5s; + keepalive 32; +} + +server { + listen 80; + + location / { + proxy_pass http://backend; + + proxy_http_version 1.1; + + # WebSocket support (required for LiveView) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Fast failure detection and automatic retry on the other node + proxy_connect_timeout 2s; + proxy_next_upstream error timeout http_502 http_503 http_504; + proxy_next_upstream_tries 2; + proxy_next_upstream_timeout 4s; + + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } +} diff --git a/postgres/pg_hba.conf b/postgres/pg_hba.conf new file mode 100644 index 0000000..790291c --- /dev/null +++ b/postgres/pg_hba.conf @@ -0,0 +1,5 @@ +# TYPE DATABASE USER ADDRESS METHOD +local all all trust +host all all 127.0.0.1/32 scram-sha-256 +host all all ::1/128 scram-sha-256 +host all all 0.0.0.0/0 scram-sha-256 diff --git a/schema.sql b/schema.sql index 5fe46d5..3e36581 100644 --- a/schema.sql +++ b/schema.sql @@ -6,13 +6,12 @@ CREATE TABLE conversations ( ); CREATE TABLE messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id BIGSERIAL PRIMARY KEY, conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')), content TEXT, reasoning_content TEXT, tool_calls JSONB, tool_call_id TEXT, - position INTEGER NOT NULL, inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );