Skip to content

Commit d29c993

Browse files
committed
distribute v3.0.0
0 parents  commit d29c993

30 files changed

Lines changed: 3931 additions & 0 deletions

.github/workflows/test.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: erlef/setup-beam@v1
14+
with:
15+
otp-version: "27.0"
16+
gleam-version: "1.14.0"
17+
- run: gleam deps download
18+
- run: gleam test
19+
- run: gleam format --check src test

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
*.beam
2+
*.ez
3+
/build
4+
erl_crash.dump
5+
**/build
6+
*.log
7+

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Changelog
2+
3+
## v3.0.0 — 2026-02-11
4+
5+
Ground-up rewrite. Smaller API, proper OTP actors, compile-time type safety
6+
via `TypedName(msg)`. Not compatible with v2.
7+
8+
### Removed
9+
10+
Everything outside the core: crypto, discovery, settings, groups, monitoring,
11+
connection pool, retry. Also removed `whereis_global(name, encoder, decoder)`.
12+
13+
### Changed
14+
15+
- Actors are real `gen_statem` via `gleam/otp/actor``observer`,
16+
`sys:get_status`, supervision trees all work.
17+
- Registry uses binary names with `:global`. No atoms created, no atom
18+
table exhaustion.
19+
- `start`, `start_registered`, `start_supervised`, `pool` all take
20+
`TypedName` instead of separate name + encoder + decoder.
21+
- `register` and `lookup` take `TypedName`. The compiler enforces that
22+
both sides use the same `msg` type.
23+
- Each `GlobalSubject` gets a unique or deterministic tag (was shared
24+
`Nil` in v2 — caused message mixing).
25+
- Orphaned actors are killed on registration failure in `start_supervised`.
26+
27+
### Added
28+
29+
- `TypedName(msg)` — opaque type binding a name to an encoder/decoder pair.
30+
- `Codec(a)` — bundles encoder + decoder + sized decoder. Shorthand
31+
constructors: `int()`, `string()`, `float()`, `bool()`, `bitarray()`,
32+
`nil()`, `list(c)`.
33+
- `codec.map(c, wrap, unwrap)` — derive a codec for a custom type.
34+
- `codec.subject()` — serialize a `Subject(BitArray)` for cross-node
35+
request/response.
36+
- `global.call(target, make_request, response_decoder, timeout)`
37+
synchronous request/response across nodes.
38+
- `global.reply(reply_to, response, encoder)` — send a response back.
39+
- `composite.option(c)`, `composite.result(ok, err)`,
40+
`composite.tuple2(a, b)`, `composite.tuple3(a, b, c)`.
41+
- `registry.named(name, codec)` — short form of `typed_name`.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 lupodevelop
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# distribute
2+
3+
<p align="center">
4+
<img src="assets/img/distribute.png" alt="distribute logo" width="200" />
5+
</p>
6+
7+
Typed distributed messaging for Gleam on the BEAM.
8+
9+
[![Package Version](https://img.shields.io/hexpm/v/distribute)](https://hex.pm/packages/distribute)
10+
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/distribute/)
11+
12+
## What this is
13+
14+
Erlang already gives you distribution, but from Gleam you lose type info at
15+
the node boundary — everything crosses the wire as raw terms. `distribute`
16+
puts binary codecs in front of `:global` and `Subject` so the compiler can
17+
catch mismatches before messages leave the process.
18+
19+
"Typed" here means checked at encode/decode boundaries. There is no shared
20+
type system across nodes — the BEAM doesn't work that way.
21+
22+
## Install
23+
24+
```sh
25+
gleam add distribute
26+
```
27+
28+
## Usage
29+
30+
### Fire-and-forget
31+
32+
Define a `TypedName(msg)` that pairs a name with a codec, then use it on
33+
both sides. The compiler won't let you register a `String` actor and look
34+
it up as `Int`.
35+
36+
```gleam
37+
import distribute
38+
import distribute/codec
39+
import distribute/registry
40+
import distribute/receiver
41+
42+
// one TypedName, shared across the codebase
43+
let greeter = registry.named("greeter", codec.string())
44+
45+
// start + register
46+
let assert Ok(gs) = distribute.start_actor(greeter, Nil, fn(msg, _state) {
47+
io.println("Got: " <> msg)
48+
receiver.Continue(Nil)
49+
})
50+
let assert Ok(Nil) = distribute.register(greeter, gs)
51+
52+
// from any node
53+
let assert Ok(remote) = distribute.lookup(greeter)
54+
let assert Ok(Nil) = distribute.send(remote, "hello")
55+
```
56+
57+
### Request / response
58+
59+
Include a reply `Subject(BitArray)` in your message type and use
60+
`codec.subject()` to serialize it. The `Subject` carries node info, so
61+
replies route back across nodes automatically.
62+
63+
```gleam
64+
import distribute/codec
65+
import distribute/global
66+
import distribute/receiver
67+
import gleam/erlang/process
68+
69+
type CounterMsg {
70+
Inc(Int)
71+
Get(reply: process.Subject(BitArray))
72+
}
73+
74+
// handler side
75+
fn handle(msg, state) {
76+
case msg {
77+
Inc(n) -> receiver.Continue(state + n)
78+
Get(reply) -> {
79+
let _ = global.reply(reply, state, codec.int_encoder())
80+
receiver.Continue(state)
81+
}
82+
}
83+
}
84+
85+
// caller side
86+
let assert Ok(count) = global.call(counter, Get, codec.int_decoder(), 5000)
87+
```
88+
89+
`global.call` creates a temporary subject, sends the request, waits for
90+
the response, decodes it. Same idea as `gen_server:call`.
91+
92+
### Codecs
93+
94+
Primitives: `codec.int()`, `codec.string()`, `codec.float()`, `codec.bool()`,
95+
`codec.bitarray()`, `codec.nil()`.
96+
97+
Composites: `codec.list(c)`, `codec.subject()`, `codec.map(c, wrap, unwrap)`,
98+
`composite.option(c)`, `composite.result(ok, err)`,
99+
`composite.tuple2(a, b)`, `composite.tuple3(a, b, c)`.
100+
101+
For your own types, use `codec.map`:
102+
103+
```gleam
104+
type UserId { UserId(Int) }
105+
106+
let user_id_codec = codec.map(codec.int(), UserId, fn(uid) {
107+
let UserId(n) = uid
108+
n
109+
})
110+
```
111+
112+
Gleam has no derive macros or reflection, so codecs for complex types
113+
are manual. The combinators handle the serialization — you just wire
114+
the fields together.
115+
116+
## Modules
117+
118+
| Module | Does |
119+
|--------|------|
120+
| `distribute` | Facade — start node, connect, send, lookup |
121+
| `distribute/actor` | Named actors, supervision, pools |
122+
| `distribute/cluster` | `net_kernel` start/connect/ping |
123+
| `distribute/codec` | Binary codecs for primitives + `subject()` |
124+
| `distribute/codec/composite` | Option, Result, Tuple codecs |
125+
| `distribute/codec/tagged` | Tagged messages with version field |
126+
| `distribute/global` | `GlobalSubject(msg)`, `call`, `reply` |
127+
| `distribute/registry` | `TypedName(msg)`, `:global` registration |
128+
| `distribute/receiver` | Typed receive, OTP actor wrappers |
129+
130+
## Caveats
131+
132+
**What the types catch** — within one codebase, `TypedName` and
133+
`GlobalSubject` prevent mixing up message types at compile time.
134+
135+
**What they don't** — two separate codebases using different codecs for
136+
the same name. The codec will reject the binary at runtime, not at compile
137+
time. Same for Erlang code sending raw terms to a `distribute` actor.
138+
139+
**Subject construction** — Gleam's `Subject` is opaque. To build one from
140+
a remote PID and a deterministic tag (how registry lookup works), we
141+
construct the `{subject, Pid, Tag}` tuple in
142+
[one Erlang function](src/distribute_ffi_utils.erl). If `gleam_erlang`
143+
changes the internal representation, that single function needs updating.
144+
145+
**No auto-derive** — Gleam doesn't have macros. Complex message codecs
146+
are manual. The combinators (`map`, `list`, `option`, `tuple2`, etc.)
147+
keep it manageable, but it's not zero-boilerplate.
148+
149+
## Development
150+
151+
```sh
152+
gleam test
153+
gleam docs build
154+
```

assets/img/distribute.png

61 KB
Loading

gleam.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name = "distribute"
2+
version = "3.0.0"
3+
description = "Typed distributed messaging for Gleam on the BEAM."
4+
licences = ["MIT"]
5+
repository = { type = "github", user = "lupodevelop", repo = "distribute" }
6+
links = [
7+
{ title = "GitHub", href = "https://github.com/lupodevelop/distribute" },
8+
]
9+
10+
target = "erlang"
11+
12+
[dependencies]
13+
gleam_stdlib = ">= 0.60.0 and < 2.0.0"
14+
gleam_erlang = ">= 1.0.0 and < 2.0.0"
15+
gleam_otp = ">= 1.0.0 and < 2.0.0"
16+
17+
[dev-dependencies]
18+
gleeunit = ">= 1.0.0 and < 2.0.0"

manifest.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This file was generated by Gleam
2+
# You typically do not need to edit this file
3+
4+
packages = [
5+
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
6+
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
7+
{ name = "gleam_stdlib", version = "0.68.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F7FAEBD8EF260664E86A46C8DBA23508D1D11BB3BCC6EE1B89B3BC3E5C83FF1E" },
8+
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
9+
]
10+
11+
[requirements]
12+
gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" }
13+
gleam_otp = { version = ">= 1.0.0 and < 2.0.0" }
14+
gleam_stdlib = { version = ">= 0.60.0 and < 2.0.0" }
15+
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }

src/cluster_ffi.erl

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
-module(cluster_ffi).
2+
-export([start_node/2, connect/1, nodes/0, self_node/0, ping/1,
3+
is_ok_atom/1, get_error_reason/1, is_true/1, is_ignored/1]).
4+
5+
-import(distribute_ffi_utils, [to_atom_safe/1]).
6+
7+
%% Start a distributed BEAM node.
8+
%% Node names are created via binary_to_atom since they MUST be new atoms.
9+
%% Input is validated (max 512 bytes, no null bytes) before creation.
10+
start_node(Name, Cookie) ->
11+
NameAtom = to_atom_force(Name),
12+
Type = case string:tokens(atom_to_list(NameAtom), "@") of
13+
[_, Host] ->
14+
case lists:member($., Host) of
15+
true -> longnames;
16+
false -> shortnames
17+
end;
18+
_ -> shortnames
19+
end,
20+
try
21+
net_kernel:start([NameAtom, Type]),
22+
erlang:set_cookie(node(), to_atom_force(Cookie)),
23+
ok
24+
catch
25+
Class:Reason ->
26+
{error, iolist_to_binary(io_lib:format("~p:~p", [Class, Reason]))}
27+
end.
28+
29+
connect(Node) ->
30+
case to_atom_safe(Node) of
31+
{ok, A} ->
32+
case net_kernel:connect_node(A) of
33+
true -> true;
34+
false -> false;
35+
ignored -> ignored
36+
end;
37+
_ -> false
38+
end.
39+
40+
nodes() ->
41+
[atom_to_binary(N, utf8) || N <- erlang:nodes()].
42+
43+
self_node() ->
44+
atom_to_binary(node(), utf8).
45+
46+
ping(Node) ->
47+
case to_atom_safe(Node) of
48+
{ok, A} ->
49+
case net_adm:ping(A) of
50+
pong -> true;
51+
_ -> false
52+
end;
53+
_ -> false
54+
end.
55+
56+
%% Helpers for Gleam FFI result classification (delegate to shared utils)
57+
is_ok_atom(V) -> distribute_ffi_utils:is_ok_atom(V).
58+
get_error_reason(V) -> distribute_ffi_utils:get_error_reason(V).
59+
60+
is_true(true) -> true;
61+
is_true(_) -> false.
62+
63+
is_ignored(ignored) -> true;
64+
is_ignored(_) -> false.
65+
66+
%% Internal: create atom for node names/cookies (validated input only).
67+
to_atom_force(Bin) when is_binary(Bin) ->
68+
case is_valid_node_input(Bin) of
69+
true -> binary_to_atom(Bin, utf8);
70+
false ->
71+
try binary_to_existing_atom(Bin, utf8)
72+
catch _:_ -> binary_to_atom(Bin, utf8)
73+
end
74+
end;
75+
to_atom_force(Bin) when is_list(Bin) ->
76+
to_atom_force(list_to_binary(Bin));
77+
to_atom_force(Atom) when is_atom(Atom) ->
78+
Atom.
79+
80+
is_valid_node_input(Bin) when is_binary(Bin) ->
81+
byte_size(Bin) =< 512 andalso
82+
binary:match(Bin, <<"\0">>) =:= nomatch.

0 commit comments

Comments
 (0)