|
| 1 | +# Crash Groups |
| 2 | + |
| 3 | +Crash groups provide a mechanism to manage the lifecycle of children (elements or bins) within a pipeline when one of them fails. By grouping children together, you can ensure that a crash in one part of the pipeline triggers a coordinated restart or termination of related elements and bins, maintaining system consistency. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +In Membrane, elements and bins are Elixir processes. By default, if a child (element or bin) that is not inside a crash group crashes, it leads to the crash of the whole pipeline. |
| 8 | + |
| 9 | +The fundamental purpose of crash groups is to isolate the crash of a specific child from the rest of the pipeline. If an child is likely to crash (e.g., it interacts with unstable external resources), it is usually assigned to a crash group containing all children inextricably linked to its operation. This prevents a localized failure from bringing down the entire system and allows for the controlled recovery of specific logical units. This approach also ensures that components generally needing a restart or termination are cleaned up correctly. |
| 10 | + |
| 11 | +## Defining Crash Groups |
| 12 | + |
| 13 | +Crash groups are defined in the `spec` within your pipeline or bin. They are built upon the concept of **Children Groups**, which allow aggregating spawned children into easily identifiable groups. |
| 14 | + |
| 15 | +To create a crash group, you must assign children to a group using the `group` option and set the `crash_group_mode` to `:temporary`. This turns a regular group into a crash group, enabling the crash handling behavior. |
| 16 | + |
| 17 | +```elixir |
| 18 | +defmodule MyPipeline do |
| 19 | + use Membrane.Pipeline |
| 20 | + |
| 21 | + @impl true |
| 22 | + def handle_init(_ctx, _opts) do |
| 23 | + spec = |
| 24 | + child(:source, MySource) |
| 25 | + |> child(:filter, MyFilter) |
| 26 | + |> child(:sink, MySink) |
| 27 | + |
| 28 | + {[spec: {spec, group: :my_group, crash_group_mode: :temporary}], %{}} |
| 29 | + end |
| 30 | +end |
| 31 | +``` |
| 32 | + |
| 33 | +In the example above, `:source`, `:filter`, and `:sink` are all assigned to the same group `:my_group`. Because `crash_group_mode` is set to `:temporary`, this group functions as a crash group. |
| 34 | + |
| 35 | +## Behaviour |
| 36 | + |
| 37 | +When a child belonging to a crash group crashes: |
| 38 | +1. All other children in the same crash group are terminated by the pipeline. |
| 39 | +2. The pipeline's `c:Membrane.Pipeline.handle_crash_group_down/3` callback is invoked. |
| 40 | +3. You can decide whether to restart the group, ignore the failure, or handle the situation in another way. |
| 41 | + |
| 42 | +## Flow of callbacks triggered by a crash within a crash group |
| 43 | + |
| 44 | +Let's assume a `:filter` element spawned in `MyPipeline` raises an error with the message `"internal error"`. |
| 45 | + |
| 46 | +### Handling termination of the crash initiator |
| 47 | + |
| 48 | +The first callback executed in `MyPipeline` will be: |
| 49 | + |
| 50 | +```elixir |
| 51 | +@impl true |
| 52 | +def handle_child_terminated(:filter, context, state) do |
| 53 | + # ... |
| 54 | +end |
| 55 | +``` |
| 56 | + |
| 57 | +The `context` passed to this callback will contain a few extra fields: |
| 58 | + * `context.exit_reason` - in this case, it equals `{%RuntimeError{message: "internal error"}, _stacktrace}`. |
| 59 | + * `context.group_name` - because `:filter` was spawned inside the `:my_group` group, this equals `:my_group`. If a child is spawned outside any crash group and terminates gracefully, the value of this field is `nil`. |
| 60 | + * `context.crash_initiator` - the same as the child's reference, which is `:filter`. |
| 61 | + |
| 62 | +Since `:filter` is no longer present in `context.children`, you could potentially respawn it here. However, it is recommended to do so later in `c:Membrane.Pipeline.handle_crash_group_down/3`. |
| 63 | + |
| 64 | +### Terminating other children within the failing crash group |
| 65 | + |
| 66 | +Because one child from the crash group `:my_group` crashed ungracefully, the remaining children in that group will also be terminated. |
| 67 | + |
| 68 | +Therefore, Membrane will terminate `:source` and `:sink` (in random order). After each termination, `MyPipeline` will execute the following callback: |
| 69 | + |
| 70 | +```elixir |
| 71 | +@impl true |
| 72 | +def handle_child_terminated(child, context, state) do |
| 73 | + # ... |
| 74 | +end |
| 75 | +``` |
| 76 | + |
| 77 | +Each time, the `context` will contain the following extra fields: |
| 78 | + * `context.exit_reason` - equals `{:shutdown, :membrane_crash_group_kill}`. |
| 79 | + * `context.group_name` - equals `:my_group`. |
| 80 | + * `context.crash_initiator` - equals `:filter`. |
| 81 | + |
| 82 | +Note that the `context.children` map always contains only the children that are still alive. For example, if `:source` is terminated first, `handle_child_terminated(:source, context, state)` will contain only `:sink` in the `context.children` map. Subsequently, for `handle_child_terminated(:sink, context, state)`, `context.children` will be empty. |
| 83 | + |
| 84 | +`MyPipeline` could potentially spawn children other than `:source`, `:filter`, and `:sink` - either in different crash groups or outside any crash group. In such cases, `context.children` would contain all of them normally, and these children would not be interrupted by the crash of `:my_group` members. The main goal of crash groups is to limit the consequences of a child's crash to only those children within the same group. |
| 85 | + |
| 86 | +### Recovering from a crash group failure |
| 87 | + |
| 88 | +Finally, when all members of the crash group are terminated, `MyPipeline` will execute: |
| 89 | + |
| 90 | +```elixir |
| 91 | +@impl true |
| 92 | +def handle_crash_group_down(:my_group, context, state) do |
| 93 | + # ... |
| 94 | +end |
| 95 | +``` |
| 96 | + |
| 97 | +The `context` passed as the third argument to the `handle_crash_group_down/3` callback contains three additional fields: |
| 98 | + - `context.crash_initiator` - the name or reference of the child that crashed first and caused the group to fail. In this case, it equals `:filter`. |
| 99 | + - `context.crash_reason` - the reason with which `context.crash_initiator` crashed. In this case, it equals `{%RuntimeError{message: "internal error"}, _stacktrace}`. |
| 100 | + - `context.members` - names/references of all children that were in the crash group. In this case, it equals `[:source, :filter, :sink]`. |
| 101 | + |
| 102 | +When `handle_crash_group_down/3` is executed, you can be sure that all group members have already been terminated. This is the suggested place to recover from a group failer, e.g. by respawning all crash group members. Doing so in `handle_child_terminated/3` might lead to issues because the termination order of group members can vary. Moreover, if a pipeline or bin terminates its children gracefully (using the `t:Membrane.Pipeline.Action.remove_children()` action), the `c:Membrane.Pipeline.handle_child_terminated/3` callback will also be executed, but with `context.exit_reason` set to `normal`. |
| 103 | + |
| 104 | + |
| 105 | +## Callback Contexts |
| 106 | + |
| 107 | +For more information about callback contexts, refer to the documentation for `t:Membrane.Pipeline.CallbackContext.t()`, `t:Membrane.Bin.CallbackContext.t()`, and `t:Membrane.Element.CallbackContext.t()`. |
| 108 | + |
| 109 | +## Bins |
| 110 | + |
| 111 | +Although the example above demonstrates using crash groups within a `Membrane.Pipeline`, they function in the same way within a `Membrane.Bin`. |
0 commit comments