Skip to content

Commit 7ffc8ac

Browse files
mat-hekjgonet
andauthored
[Lang tour] supervision, bump popcorn to 0.2.1 (#518)
Co-authored-by: Jakub Gonet <jakub.gonet@swmansion.com>
1 parent 25d88f7 commit 7ffc8ac

File tree

13 files changed

+518
-23
lines changed

13 files changed

+518
-23
lines changed

.github/workflows/langtour_e2e_test.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ on:
55
branches: ["**"]
66
paths:
77
- "language-tour/**"
8-
- "lib/**/wasm.ex"
98
- ".github/workflows/langtour_e2e_test.yml"
109

1110
permissions:

language-tour/elixir_tour/lib/elixir_tour.ex

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ defmodule ElixirTour do
3939
{:noreply, state}
4040
end
4141

42-
4342
defp handle_wasm({:wasm_call, ["eval_elixir", editor_id, code, editor_order]}, state) do
4443
%{bindings: bindings_map} = state
4544

@@ -55,7 +54,8 @@ defmodule ElixirTour do
5554
editor_bindings = get_changed(preceding_bindings, new_bindings)
5655
updated_bindings = Map.put(bindings_map, editor_id, editor_bindings)
5756

58-
{:resolve, inspect(result), %{state | editor_order: editor_order, bindings: updated_bindings}}
57+
{:resolve, inspect(result),
58+
%{state | editor_order: editor_order, bindings: updated_bindings}}
5959

6060
{:error, error_message} ->
6161
{:reject, error_message, state}
@@ -70,14 +70,9 @@ defmodule ElixirTour do
7070
@spec get_changed(Evaluator.bindings(), Evaluator.bindings()) :: Evaluator.bindings()
7171
defp get_changed(base_kw, new_kw) do
7272
unchanged? = fn {key, value} ->
73-
safe_equal?(Keyword.get(base_kw, key), value)
73+
Keyword.get(base_kw, key) == value
7474
end
7575

7676
Enum.reject(new_kw, unchanged?)
7777
end
78-
79-
# AtomVM crashes when comparing function terms, so functions are always marked as changed.
80-
defp safe_equal?(a, _b) when is_function(a), do: false
81-
defp safe_equal?(_a, b) when is_function(b), do: false
82-
defp safe_equal?(a, b), do: a == b
8378
end

language-tour/elixir_tour/mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ defmodule ElixirTour.MixProject do
2626
defp deps do
2727
[
2828
# {:popcorn, "~> 0.1.0"}
29-
{:popcorn, path: "../../popcorn/elixir"}
29+
{:popcorn, "~> 0.2.1"}
3030
# {:dep_from_hexpm, "~> 0.3.0"},
3131
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
3232
]

language-tour/elixir_tour/mix.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
%{
22
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
3-
"popcorn": {:hex, :popcorn, "0.1.0", "3f57a61af88a9de9008c054bbd64bd710eec8e02b15d84552df64dc23711f1ca", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "593e242171419fb4f5d18e1daa3b5c815b3faad06f8b26a50609bbd9e9caf56a"},
3+
"popcorn": {:hex, :popcorn, "0.2.1", "125965c99cb74f17066d1726d531c9e3f72889f2a1a7a22001556a2046f73840", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "73ec8d64946011bdea066585e78989bdf09f458dd547d858a3e8062a6df8bb48"},
44
}

language-tour/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

language-tour/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"@mdx-js/react": "^3.1.0",
2222
"@mdx-js/rollup": "^3.1.0",
2323
"@sentry/react": "^10.17.0",
24-
"@swmansion/popcorn": "^0.2.0",
24+
"@swmansion/popcorn": "^0.2.1",
2525
"@tailwindcss/vite": "^4.1.11",
2626
"@types/xxhashjs": "^0.2.4",
2727
"@uiw/codemirror-theme-solarized": "^4.24.2",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Dynamic supervision
2+
3+
In the previous chapter, we had a constant number of processes running simultaneously. In many use cases, we want to dynamically spawn processes as we need. For that, we can use `Supervisor.start_child`. Firstly, we spawn a supervisor. It doesn't have any child processes for now:
4+
5+
```elixir
6+
child_specs = []
7+
{:ok, supervisor} = Supervisor.start_link(child_specs, strategy: :one_for_one)
8+
```
9+
10+
Then, we spawn a task under the supervisor. `Task` module implements a `child_spec/1` function, so we can pass `{Task, fn -> ... end}` as a child spec:
11+
12+
```elixir
13+
Supervisor.start_child(supervisor, {
14+
Task,
15+
fn ->
16+
IO.puts("Hello from a task spawned dynamically under a supervisor")
17+
end
18+
})
19+
```
20+
21+
💡 Run the above snippet a few times to spawn more tasks.
22+
23+
As you can see, the `Supervisor` allows dynamically spawning children. However, due to the performance characteristics, it's better to use `DynamicSupervisor` for such use cases, especially if there can be a lot of child processes at some point. From the API perspective, the `DynamicSupervisor` is similar to the `Supervisor`. Here are the main differences:
24+
- `DynamicSupervisor` doesn't allow spawning any children at startup - `DynamicSupervisor.start_child/2` is the only option.
25+
- The only supported strategy is `:one_for_one` - that's because other strategies don't make much sense and would reduce performance.
26+
27+
💡 Change the above snippets to use `DynamicSupervisor`. Note that `DynamicSupervisor.start_link/1` doesn't accept the `child_specs` argument, and `DynamicSupervisor.start_child/2` must be used instead of `Supervisor.start_child/2`.
28+
29+
## Example: Job queue
30+
31+
As an example for dynamic supervision, we'll create a very simple job queue. It's going to be a GenServer receiving calls with jobs (which are just anonymous functions). For each job, the queue spawns a task, runs the job in there and sends the result back.
32+
33+
```elixir
34+
defmodule JobQueue do
35+
use GenServer
36+
37+
@type job_result :: any()
38+
@type job :: (() -> job_result())
39+
40+
def start_link(options) do
41+
# Using the module name as a name for the process
42+
# is a common pattern.
43+
GenServer.start_link(__MODULE__, options, name: __MODULE__)
44+
end
45+
46+
@spec schedule_job(job()) :: job_result()
47+
def schedule_job(data) do
48+
GenServer.call(__MODULE__, {:schedule_job, data})
49+
end
50+
51+
@impl true
52+
def init(_options) do
53+
{:ok, %{}}
54+
end
55+
56+
@impl true
57+
def handle_call({:schedule_job, job}, from, state) do
58+
# Prepare a spec for the task that will handle the job
59+
# and send the result back.
60+
# We don't want to do that in this GenServer,
61+
# as it could become a bottleneck.
62+
task_spec = {Task, fn ->
63+
# Note that we're passing `from`, the second argument
64+
# of handle_call/3. It allows replying the call
65+
# from another process.
66+
run_job(from, job)
67+
end}
68+
69+
# Start the task under a dynamic supervisor
70+
DynamicSupervisor.start_child(JobSupervisor, task_spec)
71+
72+
# Despite it's handle_call, we return :noreply tuple,
73+
# because run_job/2 takes care of replying.
74+
{:noreply, state}
75+
end
76+
77+
defp run_job(from, job) do
78+
# Run the actual job
79+
result = job.()
80+
81+
# This is equivalent of returning a :reply
82+
# tuple from handle_call/3, but we can call
83+
# it from anywhere.
84+
GenServer.reply(from, result)
85+
end
86+
end
87+
```
88+
89+
A real-world job queues have a lot of features we didn't implement, but the core idea is the same: there's a job scheduler that delegates work to short-lived processes. Thanks to the Erlang VM, this simple architecture scales very well.
90+
91+
Let's start our queue:
92+
93+
```elixir
94+
# Check if the supervisor is already running and if so, stop it.
95+
# This makes it possible to avoid a name conflict when you rerun this cell.
96+
if Process.whereis(:my_app_supervisor) do
97+
Supervisor.stop(:my_app_supervisor)
98+
end
99+
100+
child_specs = [{DynamicSupervisor, name: JobSupervisor}, JobQueue]
101+
Supervisor.start_link(child_specs, strategy: :one_for_one, name: :my_app_supervisor)
102+
```
103+
104+
Note that job queue and job supervisor are spawned under another, top-level supervisor. Our architecture now forms a tree:
105+
106+
```text
107+
my_app_supervisor
108+
| |
109+
V V
110+
JobSupervisor JobQueue
111+
| | |
112+
V V V
113+
Job1 Job2 Job3 ...
114+
```
115+
116+
Such a tree is called a _supervision tree_. The nodes are supervisors, the leafs are workers, and the edges represent supervision relationship. Supervision trees are common and convenient way of organizing Elixir applications in a fault-tolerant way.
117+
118+
Since we started our queue, let's make it run some jobs:
119+
120+
```elixir
121+
JobQueue.schedule_job(
122+
fn ->
123+
IO.puts("#{inspect(self())}: Running a job")
124+
Process.sleep(100)
125+
"Job result"
126+
end
127+
)
128+
```
129+
130+
This job is quite simple, but we could run more complex jobs, like querying a database, that could potentially fail. Let's simulate that: the cell below runs a job that has ~30% failure rate:
131+
132+
<!-- langtour:{"test_replace_code":"JobQueue.schedule_job(fn -> \"Job result\" end)"} -->
133+
```elixir
134+
JobQueue.schedule_job(
135+
fn ->
136+
IO.puts("#{inspect(self())}: Running a job")
137+
138+
# :rand.uniform() returns a value from 0 to 1
139+
# from a uniform distribution
140+
if :rand.uniform() > 0.7 do
141+
raise "Job failure"
142+
end
143+
144+
"Job result"
145+
end
146+
)
147+
```
148+
149+
💡 Keep re-running the cell above until it fails
150+
151+
As you can see, when the job fails, the caller fails with a timeout. The task failed and the call was never replied to. It's not our desired behavior - we'd want the supervisor to restart the job. Do you have an idea why it didn't?
152+
153+
The reason is `Task.child_spec/1` - it sets the restart mode to `:temporary`, which makes tasks not restarted by default.
154+
155+
💡 Let's fix it by changing the code of the JobQueue where it spawns the task under the dynamic supervisor. Use [Supervisor.child_spec/2](https://hexdocs.pm/elixir/Supervisor.html#child_spec/2) to convert the task spec, so that restart mode is `:transient`. Rerun the above cell again, until the task fails - it should now be restarted as expected.

language-tour/src/content/8-processes/message_passing.livemd

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ after
6868
end
6969
```
7070

71+
## Process registration
72+
73+
With `Process.register/2`, we can register a process under a custom name, and use this name instead of a PID. The name registered with `Process.register/2` must be an atom and must be unique in the system. More flexible process registration is possible with Registry - we'll learn that in a chapter about dynamic supervisors.
74+
75+
```elixir
76+
pid = spawn(fn ->
77+
receive do
78+
:hello -> IO.puts("Received hello!")
79+
end
80+
end)
81+
82+
Process.register(pid, :my_process)
83+
send(:my_process, :hello)
84+
85+
# Wait for the process to receive the message
86+
Process.sleep(100)
87+
```
88+
89+
💡 Try to register `self()` as `:my_process` at the beginning of the previous snippet. What happens?
90+
91+
7192
## 💡 Exercise: send and receive
7293

7394
Let's practice sending messages between processes.

0 commit comments

Comments
 (0)