“tick_recurring” nodes
# [Optional] Setting Build Key, see https://gojourney.dev/your_keys
# (Using "Journey Livebook Demo" build key)
System.put_env("JOURNEY_BUILD_KEY", "B27AXHMERm2Z6ehZhL49v")
Mix.install(
[
{:ecto_sql, "~> 3.13"},
{:postgrex, "~> 0.22"},
{:jason, "~> 1.4"},
{:journey, "~> 0.10"},
{:kino, "~> 0.19"}
],
start_applications: false
)
Application.put_env(:journey, :log_level, :warning)
# Configure more frequent background sweeper runs (the default is 60 seconds).
# The precision of the timer is determined by the granularity of the sweeper.
Application.put_env(:journey, :background_sweeper, period_seconds: 5)
# This livebook requires a PostgreSQL database.
# If you don't have one running, you can start one with Docker:
# docker run --rm --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres:16
# Update this configuration to point to your database server
Application.put_env(:journey, Journey.Repo,
database: "journey_tick_recurring_nodes",
username: "postgres",
password: "postgres",
hostname: "localhost",
log: false,
port: 5432
)
Application.put_env(:journey, :ecto_repos, [Journey.Repo])
Journey.Repo.__adapter__().storage_up(Journey.Repo.config())
Application.loaded_applications()
|> Enum.map(fn {app, _, _} -> app end)
|> Enum.each(&Application.ensure_all_started/1)
DB Setup
This livebook requires a PostgreSQL database. If you don’t have one running, you can start one with Docker:
docker run --rm --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres:16
What We’ll Cover
This tutorial focuses on the tick_recurring node type.
A tick_recurring node is like tick_once, but instead of firing once, it fires repeatedly. Each time, it computes the next future timestamp, and when that time arrives, its downstream nodes fire again. Then it schedules the next tick, and so on.
We will
- define a hydration reminder graph,
- start an execution of the graph,
-
set the
:namevalue, -
use the value of
:enable_remindersto control whether new reminders fire or get scheduled, -
watch
tick_recurringperiodically schedule the next:remind_to_hydrate, and -
watch
:remind_to_hydratekeep firing at the scheduled time.
Define the Graph
import Journey.Node
import Journey.Node.Conditions
import Journey.Node.UpstreamDependencies
reminder_interval_seconds = 15
graph =
Journey.new_graph(
"Hydration Reminder",
"v1",
[
input(:name),
input(:enable_reminders),
tick_recurring(
:reminder_tick,
unblocked_when({
:and,
[
{:name, &provided?/1},
{:enable_reminders, &true?/1}
]
}),
fn %{name: name} ->
scheduled_time = System.os_time(:second) + reminder_interval_seconds
scheduled_time_str =
scheduled_time
|> DateTime.from_unix!()
|> Calendar.strftime("%H:%M:%S UTC")
IO.puts("reminder_tick: scheduling a reminder for #{name} for #{scheduled_time_str}")
{:ok, scheduled_time}
end
),
compute(
:remind_to_hydrate,
unblocked_when({
:and,
[
{:reminder_tick, &provided?/1},
{:enable_reminders, &true?/1}
]
}),
fn values ->
count = Map.get(values, :remind_to_hydrate, 0) + 1
{:ok, count}
end
)
]
)
:ok
:ok
Four nodes:
-
:nameand:enable_remindersare inputs, -
:reminder_tickis atick_recurringnode — it fires repeatedly, every 15 seconds, but only while both:nameis provided and:enable_remindersistrue, -
:remind_to_hydratefires each time:reminder_tickticks, incrementing a counter and printing a reminder.
Note the unblocked_when condition: it uses {:and, [...]} to require multiple conditions. &provided?/1 checks that a value is set, and &true?/1 checks that a value is exactly true. These are imported from Journey.Node.Conditions.
Visualize the graph:
graph
|> Journey.Tools.generate_mermaid_graph()
|> Kino.Mermaid.new()
graph TD
%% Graph
subgraph Graph["🧩 'Hydration Reminder', version v1"]
execution_id[execution_id]
last_updated_at[last_updated_at]
name[name]
enable_reminders[enable_reminders]
reminder_tick[["reminder_tick
(anonymous fn)
tick_recurring node"]]
remind_to_hydrate[["remind_to_hydrate
(anonymous fn)"]]
name --> reminder_tick
enable_reminders --> |true?| reminder_tick
reminder_tick --> remind_to_hydrate
enable_reminders --> |true?| remind_to_hydrate
end
%% Styling
classDef defaultNode fill:#f8f9fa,stroke:#495057,stroke-width:2px,color:#000000
%% Apply styles to nodes
class execution_id,last_updated_at,name,enable_reminders,reminder_tick,remind_to_hydrate defaultNode
Start an Execution
execution =
graph
|> Journey.start()
|> Journey.set(:name, "Luigi"); :ok
:ok
:name is set, but :enable_reminders is not — so :reminder_tick is still blocked:
execution.id
|> Journey.Tools.generate_mermaid_execution()
|> Kino.Mermaid.new()
graph TD
%% Graph
subgraph Graph["🧩 'Hydration Reminder', version v1, EXECX2MGJDVJYAJ5RX0X8R64"]
execution_id["✅ execution_id"]
last_updated_at["✅ last_updated_at"]
name["✅ name"]
enable_reminders["⬜ enable_reminders"]
reminder_tick[["🚫 reminder_tick
(anonymous fn)
tick_recurring node"]]
remind_to_hydrate[["🚫 remind_to_hydrate
(anonymous fn)"]]
name --> reminder_tick
enable_reminders --> |true?| reminder_tick
reminder_tick --> remind_to_hydrate
enable_reminders --> |true?| remind_to_hydrate
end
%% Styling
classDef setNode fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000000
classDef computingNode fill:#fff8e1,stroke:#f57f17,stroke-width:2px,color:#000000
classDef errorNode fill:#f8bbd0,stroke:#b71c1c,stroke-width:2px,color:#000000
classDef neutralNode fill:#f8f9fa,stroke:#495057,stroke-width:2px,color:#000000
%% Apply styles to nodes
class name,last_updated_at,execution_id setNode
class remind_to_hydrate,reminder_tick,enable_reminders neutralNode
Journey.Tools.introspect(execution.id) |> IO.puts()
Execution summary:
- ID: 'EXECX2MGJDVJYAJ5RX0X8R64'
- Graph: 'Hydration Reminder' | 'v1'
- Archived at: not archived
- Created at: 2026-04-16 09:22:40Z UTC | 0 seconds ago
- Last updated at: 2026-04-16 09:22:40Z UTC | 0 seconds ago
- Duration: 0 seconds
- Revision: 1
- # of Values: 3 (set) / 6 (total)
- # of Computations: 2
Values:
- Set:
- last_updated_at: '1776331360' | :input
set at 2026-04-16 09:22:40Z | rev: 1
- name: '"Luigi"' | :input
set at 2026-04-16 09:22:40Z | rev: 1
- execution_id: 'EXECX2MGJDVJYAJ5RX0X8R64' | :input
set at 2026-04-16 09:22:40Z | rev: 0
- Not set:
- enable_reminders: | :input
- remind_to_hydrate: | :compute
- reminder_tick: | :tick_recurring
Computations:
- Completed:
- Outstanding:
- reminder_tick: ⬜ :not_set (not yet attempted) | :tick_recurring
:and
├─ ✅ :name | &provided?/1 | rev 1
└─ 🛑 :enable_reminders | &true?/1
- remind_to_hydrate: ⬜ :not_set (not yet attempted) | :compute
:and
├─ 🛑 :reminder_tick | &provided?/1
└─ 🛑 :enable_reminders | &true?/1
:ok
Enable Reminders
execution = Journey.set(execution, :enable_reminders, true); :ok
:ok
The tick_recurring is now unblocked, and it computes the time for the reminder.
{:ok, scheduled_time, revision} = Journey.get(execution, :reminder_tick, wait: :any)
now = System.os_time(:second)
scheduled_at_string = scheduled_time |> DateTime.from_unix!() |> Calendar.strftime("%H:%M:%S UTC")
"scheduled_time: #{scheduled_at_string}, in #{scheduled_time-now} seconds"
reminder_tick: scheduling a reminder for Luigi for 09:22:55 UTC
"scheduled_time: 09:22:55 UTC, in 14 seconds"
Wait for the First Reminder
"Waiting for #{scheduled_at_string}..."
"Waiting for 09:22:55 UTC..."
This will block until it’s time…
{:ok, count, revision} = Journey.get(execution, :remind_to_hydrate, wait: :any, timeout: 60_000)
now = DateTime.utc_now() |> Calendar.strftime("%H:%M:%S UTC")
"Reminder to hydrate fired: #{now}: count: #{count}"
"Reminder to hydrate fired: 09:22:58 UTC: count: 1"
execution.id
|> Journey.Tools.generate_mermaid_execution()
|> Kino.Mermaid.new()
graph TD
%% Graph
subgraph Graph["🧩 'Hydration Reminder', version v1, EXECX2MGJDVJYAJ5RX0X8R64"]
execution_id["✅ execution_id"]
last_updated_at["✅ last_updated_at"]
name["✅ name"]
enable_reminders["✅ enable_reminders"]
reminder_tick[["✅ reminder_tick
(anonymous fn)
tick_recurring node"]]
remind_to_hydrate[["🚫 remind_to_hydrate
(anonymous fn)"]]
name --> reminder_tick
enable_reminders --> |true?| reminder_tick
reminder_tick --> remind_to_hydrate
enable_reminders --> |true?| remind_to_hydrate
end
%% Styling
classDef setNode fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000000
classDef computingNode fill:#fff8e1,stroke:#f57f17,stroke-width:2px,color:#000000
classDef errorNode fill:#f8bbd0,stroke:#b71c1c,stroke-width:2px,color:#000000
classDef neutralNode fill:#f8f9fa,stroke:#495057,stroke-width:2px,color:#000000
%% Apply styles to nodes
class reminder_tick,enable_reminders,name,last_updated_at,execution_id setNode
class remind_to_hydrate neutralNode
Wait for the Second Reminder
Now we wait for the next tick. The tick_recurring has already scheduled it:
{:ok, scheduled_time, revision} = Journey.get(execution, :reminder_tick, wait: {:newer_than, revision})
now = System.os_time(:second)
scheduled_at_string = scheduled_time |> DateTime.from_unix!() |> Calendar.strftime("%H:%M:%S UTC")
"Waiting for scheduled_time: #{scheduled_at_string}, in #{scheduled_time-now} seconds"
"Waiting for scheduled_time: 09:23:11 UTC, in 13 seconds"
{:ok, count, _revision} = Journey.get(execution, :remind_to_hydrate, wait: {:newer_than, revision}, timeout: 60_000)
now = DateTime.utc_now() |> Calendar.strftime("%H:%M:%S UTC")
"Reminder to hydrate fired: #{now}: count: #{count}"
"Reminder to hydrate fired: 09:23:14 UTC: count: 2"
As long as reminders are enabled, this will keep happening every 15+ seconds (the granularity is subject to sweeper’s configuration).
Disable Reminders
execution = Journey.set(execution, :enable_reminders, false); :ok
:ok
Setting :enable_reminders to false means the unblocked_when condition on both :reminder_tick and :remind_to_hydrate is no longer satisfied. The tick stops firing, no more reminders will be sent.
New Execution State
Journey.values(execution)
%{
name: "Luigi",
last_updated_at: 1776331394,
execution_id: "EXECX2MGJDVJYAJ5RX0X8R64",
enable_reminders: false,
reminder_tick: 1776331406
}
execution.id
|> Journey.Tools.generate_mermaid_execution()
|> Kino.Mermaid.new()
graph TD
%% Graph
subgraph Graph["🧩 'Hydration Reminder', version v1, EXECX2MGJDVJYAJ5RX0X8R64"]
execution_id["✅ execution_id"]
last_updated_at["✅ last_updated_at"]
name["✅ name"]
enable_reminders["✅ enable_reminders"]
reminder_tick[["🚫 reminder_tick
(anonymous fn)
tick_recurring node"]]
remind_to_hydrate[["🚫 remind_to_hydrate
(anonymous fn)"]]
name --> reminder_tick
enable_reminders --> |true?| reminder_tick
reminder_tick --> remind_to_hydrate
enable_reminders --> |true?| remind_to_hydrate
end
%% Styling
classDef setNode fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000000
classDef computingNode fill:#fff8e1,stroke:#f57f17,stroke-width:2px,color:#000000
classDef errorNode fill:#f8bbd0,stroke:#b71c1c,stroke-width:2px,color:#000000
classDef neutralNode fill:#f8f9fa,stroke:#495057,stroke-width:2px,color:#000000
%% Apply styles to nodes
class enable_reminders,name,last_updated_at,execution_id setNode
class remind_to_hydrate,reminder_tick neutralNode
Journey.Tools.introspect(execution.id) |> IO.puts()
Execution summary:
- ID: 'EXECX2MGJDVJYAJ5RX0X8R64'
- Graph: 'Hydration Reminder' | 'v1'
- Archived at: not archived
- Created at: 2026-04-16 09:22:40Z UTC | 34 seconds ago
- Last updated at: 2026-04-16 09:23:14Z UTC | 0 seconds ago
- Duration: 34 seconds
- Revision: 14
- # of Values: 5 (set) / 6 (total)
- # of Computations: 6
Values:
- Set:
- last_updated_at: '1776331394' | :input
set at 2026-04-16 09:23:14Z | rev: 14
- enable_reminders: 'false' | :input
set at 2026-04-16 09:23:14Z | rev: 13
- reminder_tick: '1776331406' | :tick_recurring
computed at 2026-04-16 09:23:11Z | rev: 12
- name: '"Luigi"' | :input
set at 2026-04-16 09:22:40Z | rev: 1
- execution_id: 'EXECX2MGJDVJYAJ5RX0X8R64' | :input
set at 2026-04-16 09:22:40Z | rev: 0
- Not set:
- remind_to_hydrate: | :compute
Computations:
- Completed:
- :reminder_tick (CMP35ZY49079RLT4DHH51J5): ✅ :success | :tick_recurring | rev 12
started: 2026-04-16 09:23:11Z | completed: 2026-04-16 09:23:11Z (0s)
inputs used:
:name (rev 1)
:enable_reminders (rev 2)
- :remind_to_hydrate (CMPXY31HT7YT39R5MHRG9Y5): ✅ :success | :compute | rev 10
started: 2026-04-16 09:23:11Z | completed: 2026-04-16 09:23:11Z (0s)
inputs used:
:enable_reminders (rev 2)
:reminder_tick (rev 8)
- :reminder_tick (CMPV1D80ERG7L27YEGZ2J02): ✅ :success | :tick_recurring | rev 8
started: 2026-04-16 09:22:56Z | completed: 2026-04-16 09:22:56Z (0s)
inputs used:
:name (rev 1)
:enable_reminders (rev 2)
- :remind_to_hydrate (CMPZRH28ADBA82THDRMT7A0): ✅ :success | :compute | rev 6
started: 2026-04-16 09:22:56Z | completed: 2026-04-16 09:22:56Z (0s)
inputs used:
:enable_reminders (rev 2)
:reminder_tick (rev 4)
- :reminder_tick (CMPJ8XVJZ96TG0HD0B4H7GY): ✅ :success | :tick_recurring | rev 4
started: 2026-04-16 09:22:40Z | completed: 2026-04-16 09:22:40Z (0s)
inputs used:
:name (rev 1)
:enable_reminders (rev 2)
- Outstanding:
- remind_to_hydrate: ⬜ :not_set (not yet attempted) | :compute
:and
├─ 🛑 :reminder_tick | &provided?/1
└─ 🛑 :enable_reminders | &true?/1
:ok
History Growing? keep_latest_completed_computations: 100
You may notice that execution introspection above has multiple historical records for computations that have already taken place. This is useful for understanding what happened, but if your recurring events repeat in perpetuity, those records can add up, and start taking up database and memory space. Use :keep_latest_completed_computations to limit how many are kept:
tick_recurring(:reminder_tick, deps, &next_time/1,
keep_latest_completed_computations: 100
)
After each successful completion, older computation records beyond the retention window are deleted. The default is unlimited; set this option on any node whose computation count will grow larger than you want.
See the tick_recurring/3 documentation for more details.
Beyond Hydration
tick_recurring is useful when you need to schedule recurring actions, things like:
- sending your users a weekly activity summary,
- sending your customers a monthly statement,
- having an agent scan logs every few minutes, checking for problems and handling issues,
- … any other place you want to reliably schedule a recurring action.
Summary
In this Livebook, we saw how tick_recurring nodes fire repeatedly on a schedule.
We built a hydration reminder with four nodes — two inputs, a tick_recurring, and a compute — and watched the compute node fire twice as the tick kept scheduling the next reminder.
Key takeaways:
-
A
tick_recurringnode computes a future timestamp, and when that time arrives, its downstream nodes fire. Then it schedules the next tick, and so on. -
Downstream nodes re-fire on every tick — the same
:remind_to_hydratecompute ran each time, incrementing its counter. -
Use
unblocked_whento conditionally start and stop the recurring schedule. In our example, setting:enable_reminderstofalsestopped the tick. -
Use
keep_latest_completed_computationsto manage retention in long-running recurring schedules.