Skip to content

Commit 2161d1f

Browse files
authored
Merge pull request #7 from davydog187/support-multiple-queries
Support multiple DNS queries
2 parents 11bd821 + 1dac479 commit 2161d1f

File tree

2 files changed

+154
-32
lines changed

2 files changed

+154
-32
lines changed

lib/dns_cluster.ex

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@ defmodule DNSCluster do
33
Simple DNS based cluster discovery.
44
55
A DNS query is made every `:interval` milliseconds to discover new ips.
6-
Nodes will only be joined if their node basename matches the basename of the
7-
current node. For example if `node()` is `myapp-123@fdaa:1:36c9:a7b:198:c4b1:73c6:1`,
8-
a `Node.connect/1` attempt will be made against every IP returned by the DNS query,
9-
but will only be successful if there is a node running on the remote host with the same
10-
basename, for example `myapp-123@fdaa:1:36c9:a7b:198:c4b1:73c6:2`. Nodes running on
11-
remote hosts, but with different basenames will fail to connect and will be ignored.
6+
7+
## Default node discovery
8+
Nodes will only be joined if their node basename matches the basename of the current node.
9+
For example, if `node()` is `myapp-123@fdaa:1:36c9:a7b:198:c4b1:73c6:1`, it will try to connect
10+
to every IP from the DNS query with `Node.connect/1`. But this will only work if the remote node
11+
has the same basename, like `myapp-123@fdaa:1:36c9:a7b:198:c4b1:73c6:2`. If the remote node's
12+
basename is different, the nodes will not connect.
13+
14+
## Specifying remote basenames
15+
If you want to connect to nodes with different basenames, use a tuple with the basename and query.
16+
For example, to connect to a node named `remote`, use `{"remote", "remote-app.internal"}`.
17+
18+
## Multiple queries
19+
Sometimes you might want to cluster apps with different domain names. Just pass a list of queries
20+
for this. For instance: `["app-one.internal", "app-two.internal", {"other-basename", "other.internal"}]`.
21+
Remember, all nodes need to share the same secret cookie to connect successfully.
1222
1323
## Examples
1424
@@ -56,7 +66,9 @@ defmodule DNSCluster do
5666
## Options
5767
5868
* `:name` - the name of the cluster. Defaults to `DNSCluster`.
59-
* `:query` - the required DNS query for node discovery, for example: `"myapp.internal"`.
69+
* `:query` - the required DNS query for node discovery, for example:
70+
`"myapp.internal"` or `["foo.internal", "bar.internal"]`. If the basename
71+
differs between nodes, a tuple of `{basename, query}` can be provided as well.
6072
The value `:ignore` can be used to ignore starting the DNSCluster.
6173
* `:interval` - the millisec interval between DNS queries. Defaults to `5000`.
6274
* `:connect_timeout` - the millisec timeout to allow discovered nodes to connect.
@@ -80,24 +92,26 @@ defmodule DNSCluster do
8092
{:ok, :ignore} ->
8193
:ignore
8294

83-
{:ok, query} when is_binary(query) ->
84-
warn_on_invalid_dist()
85-
resolver = Keyword.get(opts, :resolver, Resolver)
86-
87-
state = %{
88-
interval: Keyword.get(opts, :interval, 5_000),
89-
basename: resolver.basename(node()),
90-
query: query,
91-
log: Keyword.get(opts, :log, false),
92-
poll_timer: nil,
93-
connect_timeout: Keyword.get(opts, :connect_timeout, 10_000),
94-
resolver: resolver
95-
}
96-
97-
{:ok, state, {:continue, :discover_ips}}
98-
99-
{:ok, other} ->
100-
raise ArgumentError, "expected :query to be a string, got: #{inspect(other)}"
95+
{:ok, query} ->
96+
if valid_query?(query) do
97+
warn_on_invalid_dist()
98+
resolver = Keyword.get(opts, :resolver, Resolver)
99+
100+
state = %{
101+
interval: Keyword.get(opts, :interval, 5_000),
102+
basename: resolver.basename(node()),
103+
query: List.wrap(query),
104+
log: Keyword.get(opts, :log, false),
105+
poll_timer: nil,
106+
connect_timeout: Keyword.get(opts, :connect_timeout, 10_000),
107+
resolver: resolver
108+
}
109+
110+
{:ok, state, {:continue, :discover_ips}}
111+
else
112+
raise ArgumentError,
113+
"expected :query to be a string, {basename, query}, or list, got: #{inspect(query)}"
114+
end
101115

102116
:error ->
103117
raise ArgumentError, "missing required :query option in #{inspect(opts)}"
@@ -127,7 +141,7 @@ defmodule DNSCluster do
127141

128142
_results =
129143
ips
130-
|> Enum.map(fn ip -> "#{state.basename}@#{ip}" end)
144+
|> Enum.map(fn {basename, ip} -> "#{basename}@#{ip}" end)
131145
|> Enum.filter(fn node_name -> !Enum.member?(node_names, node_name) end)
132146
|> Task.async_stream(
133147
fn new_name ->
@@ -151,11 +165,38 @@ defmodule DNSCluster do
151165
%{state | poll_timer: Process.send_after(self(), :discover_ips, state.interval)}
152166
end
153167

154-
defp discover_ips(%{resolver: resolver, query: query}) do
168+
defp discover_ips(%{resolver: resolver, query: queries} = state) do
155169
[:a, :aaaa]
156-
|> Enum.flat_map(&resolver.lookup(query, &1))
170+
|> Enum.flat_map(fn type ->
171+
Enum.flat_map(queries, fn query ->
172+
{basename, query} =
173+
case query do
174+
{basename, query} ->
175+
# use the user-specified basename
176+
{basename, query}
177+
178+
query when is_binary(query) ->
179+
# no basename specified, use host basename
180+
{state.basename, query}
181+
end
182+
183+
for addr <- resolver.lookup(query, type) do
184+
{basename, addr}
185+
end
186+
end)
187+
end)
157188
|> Enum.uniq()
158-
|> Enum.map(&to_string(:inet.ntoa(&1)))
189+
|> Enum.map(fn {basename, addr} -> {basename, to_string(:inet.ntoa(addr))} end)
190+
end
191+
192+
defp valid_query?(list) do
193+
list
194+
|> List.wrap()
195+
|> Enum.all?(fn
196+
string when is_binary(string) -> true
197+
{basename, query} when is_binary(basename) and is_binary(query) -> true
198+
_ -> false
199+
end)
159200
end
160201

161202
defp warn_on_invalid_dist do

test/dns_cluster_test.exs

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ defmodule DNSClusterTest do
44
@ips %{
55
already_known: ~c"fdaa:0:36c9:a7b:db:400e:1352:1",
66
new: ~c"fdaa:0:36c9:a7b:db:400e:1352:2",
7-
no_connect_diff_base: ~c"fdaa:0:36c9:a7b:db:400e:1352:3"
7+
no_connect_diff_base: ~c"fdaa:0:36c9:a7b:db:400e:1352:3",
8+
connect_diff_base: ~c"fdaa:0:36c9:a7b:db:400e:1352:4"
89
}
910

1011
@new_node :"app@#{@ips.new}"
@@ -18,14 +19,23 @@ defmodule DNSClusterTest do
1819
false
1920
end
2021

22+
@specified_base_node :"specified@#{@ips.connect_diff_base}"
23+
def connect_node(@specified_base_node) do
24+
send(__MODULE__, {:try_connect, @specified_base_node})
25+
true
26+
end
27+
28+
def connect_node(_), do: false
29+
2130
def basename(_node_name), do: "app"
2231

2332
def lookup(_query, _type) do
2433
{:ok, dns_ip1} = :inet.parse_address(@ips.already_known)
2534
{:ok, dns_ip2} = :inet.parse_address(@ips.new)
2635
{:ok, dns_ip3} = :inet.parse_address(@ips.no_connect_diff_base)
36+
{:ok, dns_ip4} = :inet.parse_address(@ips.connect_diff_base)
2737

28-
[dns_ip1, dns_ip2, dns_ip3]
38+
[dns_ip1, dns_ip2, dns_ip3, dns_ip4]
2939
end
3040

3141
def list_nodes do
@@ -47,7 +57,43 @@ defmodule DNSClusterTest do
4757

4858
wait_for_node_discovery(cluster)
4959

50-
{:ok, cluster: cluster}
60+
new_node = :"app@#{@ips.new}"
61+
no_connect_node = :"app@#{@ips.no_connect_diff_base}"
62+
assert_receive {:try_connect, ^new_node}
63+
refute_receive {:try_connect, ^no_connect_node}
64+
refute_receive _
65+
end
66+
67+
test "discovers nodes with differing basenames if specified", config do
68+
Process.register(self(), __MODULE__)
69+
70+
{:ok, cluster} =
71+
start_supervised(
72+
{DNSCluster,
73+
name: config.test,
74+
query: ["app.internal", {"specified", "app.internal"}],
75+
resolver: __MODULE__}
76+
)
77+
78+
wait_for_node_discovery(cluster)
79+
80+
new_node = :"app@#{@ips.new}"
81+
specified_base_node = :"specified@#{@ips.connect_diff_base}"
82+
assert_receive {:try_connect, ^new_node}
83+
assert_receive {:try_connect, ^specified_base_node}
84+
refute_receive _
85+
end
86+
87+
test "discovers nodes with a list of queries", config do
88+
Process.register(self(), __MODULE__)
89+
90+
{:ok, cluster} =
91+
start_supervised(
92+
{DNSCluster, name: config.test, query: ["app.internal"], resolver: __MODULE__}
93+
)
94+
95+
wait_for_node_discovery(cluster)
96+
5197
new_node = :"app@#{@ips.new}"
5298
no_connect_node = :"app@#{@ips.no_connect_diff_base}"
5399
assert_receive {:try_connect, ^new_node}
@@ -58,4 +104,39 @@ defmodule DNSClusterTest do
58104
test "query with :ignore does not start child" do
59105
assert DNSCluster.start_link(query: :ignore) == :ignore
60106
end
107+
108+
describe "query forms" do
109+
test "query can be a string", config do
110+
assert {:ok, _cluster} =
111+
start_supervised(
112+
{DNSCluster, name: config.test, query: "app.internal", resolver: __MODULE__}
113+
)
114+
end
115+
116+
test "query can be a {basename, query}", config do
117+
assert {:ok, _cluster} =
118+
start_supervised(
119+
{DNSCluster,
120+
name: config.test, query: {"basename", "app.internal"}, resolver: __MODULE__}
121+
)
122+
end
123+
124+
test "query can be a list", config do
125+
assert {:ok, _cluster} =
126+
start_supervised(
127+
{DNSCluster,
128+
name: config.test,
129+
query: ["query", {"basename", "app.internal"}],
130+
resolver: __MODULE__}
131+
)
132+
end
133+
134+
test "query can't be other terms", config do
135+
for bad <- [1234, :atom, %{a: 1}, [["query"]]] do
136+
assert_raise RuntimeError, ~r/expected :query to be a string/, fn ->
137+
start_supervised!({DNSCluster, name: config.test, query: bad, resolver: __MODULE__})
138+
end
139+
end
140+
end
141+
end
61142
end

0 commit comments

Comments
 (0)