diff --git a/syncsrv/.formatter.exs b/syncsrv/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/syncsrv/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/syncsrv/.gitignore b/syncsrv/.gitignore new file mode 100644 index 0000000..1379078 --- /dev/null +++ b/syncsrv/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +syncsrv-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/syncsrv/COMMANDS b/syncsrv/COMMANDS new file mode 100644 index 0000000..848e42f --- /dev/null +++ b/syncsrv/COMMANDS @@ -0,0 +1,68 @@ +Sigils: + +client-initiated ++INFORMATIVE (do not change state, casts) +!CAST +@ACKED/WITH-RESPONSE/CALL + (responses start with @ too) +?QUERY (=acked without state change) + (responses start with ? too) + (maybe even: c→s: ?QUEUE => s→c ?QUEUE song song song …; see continuations below) + +server-initiated +$STATE ++INFORMATIVE +!CAST +If we ever need server to query clients, it might use * sigil ig (both ways). But the server should not have a need to… + +additionally, responses keep sigils, so there are C→S sigils (@?), S→C sigils ($) and sigils with no replies (+!). Technically, $COMMANDS probably do not have replies either. + - the distinction between S→C $ and ! is that ! does not mutate internal state w.r.t. playing songs? + - Also +PING has reply in +PONG lol :-D + +The timing/transaction semantics are as follows: each sigil forms its own queue. Responses to messages for each sigil are in order and for the time being, it is forbidden to send another message to the other party when a same-sigilled message is waiting for reply. On the other hand, messages with different sigils can be interleaved arbitrarily. +e.g. the following is a *valid* order of messages (note how the +COMMANDS are not technically linked, so they are interleaved completely arbitrarily): +C: @BROADCAST hello everyone +C: ?QUEUE +S: ?QUEUE citrónky amazonka +C: +PING +S: +A-COMMAND-JUST-FOR-EXAMPLE +S: +PONG +S: @ACK broadcast ok +C: @QUEUE markytánka +S: !MESSAGE hello everyone +S: @ACK markytánka added +S: $QUEUE ADDED markytánka + +The standard protocol words are all low-ascii and uppercase and none of them start with X or Y; additionally, only interpunction are valid sigils. However, the protocol itself and esp. arguments of commands are UTF-8 (no BOM), so extensions may use letters as sigils, custom commands with existing sigils starting with X or Y. (It is probably a better idea to reuse sigils than to add new ones, it is very implementation defined as of now how to handle unknown sigils. (However, handling of unknown commands (except +COMMANDS) is done the same way :-D)) + +The expectation is that each side has a “router” that “sorts” commands according to their sigils to different parts of code. The router needs to listen ~all the time anyway (we have casts) + +INFORMATIVE = can be safely ignored. STATE and CAST are very similar in s→c scenario, but only STATE needs to be kept (iow, s→c CASTs are only for side effects) + +TODO: continuation: lines starting with - are continuation, the actual sigil is in the last message. If the next message does not follow in T_CONT = 100ms, the transaction did not happen at all? + - continued messages must be informative? (will probably be used as query responses anyway?) + ALTERNATIVE: ?-QUEUE song1 \n ?QUEUE song_last + - **alternative is better, because it allows message interleaving!** + +Known commands ++PING/+PONG +?HELO [ident?]/?HELO [ident?] +!BYE/!KICKED +@QUEUE +@CAPO +@ACK [optional info msg?] – universal reply to stuff. + +@BROADCAST /!MESSAGE + + +---- +cmd reqs: +conn, disconn tracking +current song+capo tracking, querying the state, casting the state +queue management: add to queue, remove from queue, list the queue, maybe casting the queue +message broadcasting +service messages (rate limits)? +TODO: versioning? + +FUTURE: proxying? – if we implement appl-layer mcast, we need to distinguish multiplexed users and directed vs broadcasted messages + - Wrap it in protocol layer: #PROXY user $COMMAND? diff --git a/syncsrv/NOTES b/syncsrv/NOTES new file mode 100644 index 0000000..1e253b6 --- /dev/null +++ b/syncsrv/NOTES @@ -0,0 +1,6 @@ +app +registry +plug (only one?) -- with path to id instance in future +ws handlers +syncer registry +syncer diff --git a/syncsrv/NOTES2 b/syncsrv/NOTES2 new file mode 100644 index 0000000..2d1b553 --- /dev/null +++ b/syncsrv/NOTES2 @@ -0,0 +1,37 @@ +Handling = business logic +Handling in WS: ++ per client – does not hang others +- hard to argue about? – very async +- race prone? ((partly?) avoided by having state elsewhere = PubSub + - NOTE: inherently racy anyway, as the order of commands arriving and being process is not guaranteed. (it might be possible to add arbitrary serialisation, but too much work ig) + +Handling in PubSub: +- blocking? ++ at one place – if state is elsewhere, we do not need to forward a lot +* We believe in one pubsub per netzpevnik instance +* We expect ~few connections and ~low-pace of events, so perf should not be an issue ++ Most requests still have to go through PubSub, bc they need state or influence other clients + * The PubSub has to do map/list traversals and calls anyway. ++ We expect most logic be reducible to ops on top of the state kept/ref'd by PubSub + + We could probably tolerate having some preprocessing in WS handler and PubSub “only” doing the shared stuff. +=> This feels reasonable. + + +Handling elsewhere: +* if it is per client, it does have the benefits and disadvantages of ws worker and vice versa? ++ we could have the logic separated in another part of code for clarity. + The code organisation does not influence the process model much, though + +Random alternative: WS handler dispatching Tasks that do stuff – in a sense, WS behaves as a compiler and PS/independent ad-hoc process executes it +- Probably has no advantages as long as the load is managable by PubSub. + - And even then splitting the PubSub into multiple low-load servers seems like a better idea. + (is that possible, though?) + +----- +URLS: +netzpevnik.local — statically served SPA +netzpevnik.local/instance +netzpevnik.local/new/instance → create → temp redirect to /instance (possibly auth'd) +instance = [a-zA-Z0-9][a-zA-Z0-9.-_]* +netzpevnik.local/api/instances → JSON array of currently existing (public) instances? + Expose from SPA for easy discovery; also allow creating from SPA ig diff --git a/syncsrv/README.md b/syncsrv/README.md new file mode 100644 index 0000000..9ae6d74 --- /dev/null +++ b/syncsrv/README.md @@ -0,0 +1,21 @@ +# Syncsrv + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `syncsrv` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:syncsrv, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/syncsrv/lib/syncsrv.ex b/syncsrv/lib/syncsrv.ex new file mode 100644 index 0000000..bcc7cc4 --- /dev/null +++ b/syncsrv/lib/syncsrv.ex @@ -0,0 +1,18 @@ +defmodule Netzpevnik.SyncSrv do + @moduledoc """ + Documentation for `SyncSrv`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> Netzpevnik.SyncSrv.hello() + :world + + """ + def hello do + :world + end +end diff --git a/syncsrv/lib/syncsrv/application.ex b/syncsrv/lib/syncsrv/application.ex new file mode 100644 index 0000000..ea1ea7c --- /dev/null +++ b/syncsrv/lib/syncsrv/application.ex @@ -0,0 +1,17 @@ +defmodule Netzpevnik.SyncSrv.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # {SyncSrv.Worker, arg} + # {SyncSrv.PubSub, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + opts = [strategy: :one_for_one, name: Syncsrv.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/syncsrv/lib/syncsrv/pubsub.ex b/syncsrv/lib/syncsrv/pubsub.ex new file mode 100644 index 0000000..7d7e139 --- /dev/null +++ b/syncsrv/lib/syncsrv/pubsub.ex @@ -0,0 +1,55 @@ +defmodule Netzpevnik.SyncSrv.PubSub do + # this will be a genserver ig + use GenServer + @moduledoc """todo""" + + # API + # should this just be Erlang msgs? Would perform PID resolution automatically. + # call would do this too, so use that ig + + # These two are ~special: they get called by WS workers in init/terminate. Idk how the kill procedure should work yet (TODO) + def register(pid) do + :todo + end + def unregister(pid) do: + :todo + end + + # general handler of commands, will return *some* verdict synchronously + # For long-running, we cast on the server and return synchronously. That might lead to the actual asynchrnonous replies being returned before the ack. + # but those would be rare – this server still has to process everything. And requests from other clients should only yield asynchronous messages, so they won't be mixed + # also, the docs says that calls should be preferred from casts, as a backpressure mechanism. + def command(server, cmd) do + GenServer.call(server, {:command, cmd}) + end + + def start_link(opts) do + GenServer.start_link(__MODULE__, [], opts) + end + + def init(_opts) do + {:ok, %{queue: [], clients: []}} + end + + @impl true + def handle_call({:command, cmd}, from, state) do + :todo + # :reply sth, :noreply, :stop reason [reply] (casts do not have :reply, obvs) + case cmd do + "+PING" -> {:reply, {:text, "+PONG"}, state} + "+PONG" -> {:reply, :ok, state} + "@BROADCAST " <> message -> + broadcast(message, state, except: from) + {:reply, {:text, "@ACK"}, state} + "more matches here" -> :todo + end + end + + defp broadcast(message, %{clients: clients}, opts // []) do + from = Keyword.get(opts, :from) + Enum.map(clients, fn client -> case client == from do + true -> nil + false -> SyncSrv.WsHandler.send(client, message) + end) + end +end diff --git a/syncsrv/lib/syncsrv/ws_handler.ex b/syncsrv/lib/syncsrv/ws_handler.ex new file mode 100644 index 0000000..1b9d6c1 --- /dev/null +++ b/syncsrv/lib/syncsrv/ws_handler.ex @@ -0,0 +1,33 @@ +defmodule Netzpevnik.SyncSrv.WsHandler do + @moduledoc """todo""" + + def init(pubsub_pid) do + # the plug will find that for us based on the path + {:ok, pubsub_pid} + # NOTE: we never change the state – should we wrap all the functions into keeping the pid automatically? + end + + def send(pid, msg) do + GenServer.cast(pid, {:send, msg}) + end + + def handle_cast({:send, data}, pubsub) do + {:push, {:text, data}, pubsub} + end + + def handle_in({cmd, [opcode: :text]}, pubsub) do + # Do not try to understand command here, just forward to the PubSub + resp = SyncSrv.PubSub.process_cmd(cmd) + # TODO: What is the correct GenServer protocol about this? And should we at least enforce sigils? + case resp do + :ok -> {:ok, pubsub} + {:text, data} -> {:push, {:text, data}, pubsub} + end + end + + def handle_info(msg, state) + IO.inspect(msg) + raise + end +end + diff --git a/syncsrv/mix.exs b/syncsrv/mix.exs new file mode 100644 index 0000000..e945372 --- /dev/null +++ b/syncsrv/mix.exs @@ -0,0 +1,29 @@ +defmodule Netzpevnik.SyncSrv.MixProject do + use Mix.Project + + def project do + [ + app: :syncsrv, + version: "0.1.0", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {Netzpevnik.SyncSrv.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/syncsrv/test/syncsrv_test.exs b/syncsrv/test/syncsrv_test.exs new file mode 100644 index 0000000..6e5b299 --- /dev/null +++ b/syncsrv/test/syncsrv_test.exs @@ -0,0 +1,8 @@ +defmodule Netzpevnik.SyncSrvTest do + use ExUnit.Case + doctest Netzpevnik.SyncSrv + + test "greets the world" do + assert Netzpevnik.SyncSrv.hello() == :world + end +end diff --git a/syncsrv/test/test_helper.exs b/syncsrv/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/syncsrv/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()