Skip to content

Commit 4b5096d

Browse files
Copilotmanast
authored andcommitted
refactor: align Rust and Elixir with lazy script loading
Co-authored-by: manast <95200+manast@users.noreply.github.com>
1 parent 04ba2f8 commit 4b5096d

5 files changed

Lines changed: 105 additions & 200 deletions

File tree

elixir/lib/bullmq/redis_connection.ex

Lines changed: 5 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,10 @@ defmodule BullMQ.RedisConnection do
5858
5959
## Lua Script Loading
6060
61-
BullMQ uses Lua scripts for atomic Redis operations. All scripts are
62-
automatically loaded into Redis's script cache when the connection starts.
63-
This ensures the connection is fully ready for BullMQ operations (Worker,
64-
Queue, QueueEvents, etc.) before it's used.
65-
66-
Unlike Node.js BullMQ which uses ioredis's `defineCommand` to register scripts
67-
on the client, the Elixir version loads scripts via `SCRIPT LOAD` during
68-
initialization and uses `EVALSHA` for execution with automatic `EVAL` fallback
69-
on `NOSCRIPT` errors (in case Redis was restarted and lost its script cache).
61+
BullMQ uses Lua scripts for atomic Redis operations. Scripts execute with
62+
`EVALSHA` and lazily fall back to `EVAL` on `NOSCRIPT`, matching the Node.js
63+
ports. For pipelined or transactional paths where that fallback is not
64+
available, BullMQ loads only the specific scripts needed just before use.
7065
7166
## Options
7267
@@ -112,10 +107,8 @@ defmodule BullMQ.RedisConnection do
112107

113108
case Supervisor.start_link(__MODULE__, opts, name: Pool.supervisor_name(name)) do
114109
{:ok, pid} ->
115-
# Check Redis version before loading scripts
110+
# Check Redis version before the connection is used
116111
check_redis_version!(name)
117-
# Load scripts synchronously - connection isn't ready until scripts are loaded
118-
load_scripts(name)
119112
{:ok, pid}
120113

121114
error ->
@@ -228,85 +221,6 @@ defmodule BullMQ.RedisConnection do
228221
end
229222
end
230223

231-
# Loads all BullMQ Lua scripts into Redis script cache.
232-
# Called once during initialization - scripts are cached server-side in Redis,
233-
# so all pool connections can use EVALSHA to execute them efficiently.
234-
#
235-
# To keep startup fast (a cold `SCRIPT LOAD` of many large scripts can be slow
236-
# on high-latency links), we first issue a single `SCRIPT EXISTS` call with the
237-
# precomputed SHA1 of every script and then only `SCRIPT LOAD` the scripts that
238-
# are not already present in the server-side cache.
239-
defp load_scripts(conn) do
240-
scripts =
241-
Scripts.list_scripts()
242-
|> Enum.map(fn script_name ->
243-
case Scripts.get(script_name) do
244-
{content, _key_count} -> {content, Scripts.get_sha(script_name)}
245-
nil -> nil
246-
end
247-
end)
248-
|> Enum.reject(&is_nil/1)
249-
250-
with {:ok, missing} <- missing_scripts(conn, scripts) do
251-
load_missing_scripts(conn, missing)
252-
else
253-
{:error, reason} ->
254-
Logger.warning(
255-
"BullMQ: Failed to pre-load scripts for #{inspect(conn)}: #{inspect(reason)}. " <>
256-
"Scripts will be loaded on first use via EVAL fallback."
257-
)
258-
259-
:ok
260-
end
261-
end
262-
263-
# Returns the scripts that are not yet cached server-side, using a single
264-
# SCRIPT EXISTS round trip. If any SHA is missing (or the check fails), the
265-
# corresponding script is treated as not loaded.
266-
defp missing_scripts(conn, scripts) do
267-
shas = Enum.map(scripts, fn {_content, sha} -> sha end)
268-
269-
case command(conn, ["SCRIPT", "EXISTS" | shas]) do
270-
{:ok, existing} ->
271-
missing =
272-
scripts
273-
|> Enum.zip(Stream.concat(existing, Stream.cycle([0])))
274-
|> Enum.reject(fn {_script, loaded} -> loaded == 1 end)
275-
|> Enum.map(fn {{content, _sha}, _loaded} -> content end)
276-
277-
{:ok, missing}
278-
279-
{:error, reason} ->
280-
{:error, reason}
281-
end
282-
end
283-
284-
defp load_missing_scripts(conn, []) do
285-
Logger.debug("BullMQ: All Lua scripts already cached in Redis for #{inspect(conn)}")
286-
:ok
287-
end
288-
289-
defp load_missing_scripts(conn, missing) do
290-
commands = Enum.map(missing, fn content -> ["SCRIPT", "LOAD", content] end)
291-
292-
case pipeline(conn, commands) do
293-
{:ok, _shas} ->
294-
Logger.debug(
295-
"BullMQ: Loaded #{length(commands)} Lua scripts into Redis cache for #{inspect(conn)}"
296-
)
297-
298-
:ok
299-
300-
{:error, reason} ->
301-
Logger.warning(
302-
"BullMQ: Failed to pre-load scripts for #{inspect(conn)}: #{inspect(reason)}. " <>
303-
"Scripts will be loaded on first use via EVAL fallback."
304-
)
305-
306-
:ok
307-
end
308-
end
309-
310224
@doc """
311225
Executes a Redis command.
312226

elixir/test/bullmq/script_loading_integration_test.exs

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ defmodule BullMQ.ScriptLoadingIntegrationTest do
3030
conn_name
3131
end
3232

33-
test "loads every script into the server-side cache when it is empty" do
33+
test "does not preload scripts when the connection starts" do
3434
flush_script_cache()
3535

3636
conn = start_pool()
@@ -39,22 +39,24 @@ defmodule BullMQ.ScriptLoadingIntegrationTest do
3939
{:ok, existing} = RedisConnection.command(conn, ["SCRIPT", "EXISTS" | shas])
4040

4141
assert length(existing) == length(shas)
42-
assert Enum.all?(existing, &(&1 == 1))
42+
assert Enum.all?(existing, &(&1 == 0))
4343
end
4444

45-
test "leaves all scripts cached when starting with a warm script cache" do
45+
test "loads only the requested scripts for pipelined operations" do
4646
flush_script_cache()
4747

48-
# Warm the cache with a first connection.
49-
_first = start_pool()
50-
51-
# A second connection must find the scripts already cached (only a single
52-
# SCRIPT EXISTS round trip is needed) and must not remove or corrupt them.
53-
second = start_pool()
54-
55-
shas = all_script_shas()
56-
{:ok, existing} = RedisConnection.command(second, ["SCRIPT", "EXISTS" | shas])
57-
58-
assert Enum.all?(existing, &(&1 == 1))
48+
conn = start_pool()
49+
:ok = Scripts.ensure_scripts_loaded(conn, [:add_standard_job])
50+
51+
{:ok, [add_standard_job, add_parent_job]} =
52+
RedisConnection.command(conn, [
53+
"SCRIPT",
54+
"EXISTS",
55+
Scripts.get_sha(:add_standard_job),
56+
Scripts.get_sha(:add_parent_job)
57+
])
58+
59+
assert add_standard_job == 1
60+
assert add_parent_job == 0
5961
end
6062
end

rust/src/flow_producer.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,10 @@ impl FlowProducer {
301301
validate_flow_queue_names(&flow)?;
302302

303303
let mut conn = self.conn.conn();
304+
self.conn
305+
.scripts()
306+
.ensure_loaded(&mut conn, &Self::pipeline_script_names(&flow))
307+
.await?;
304308

305309
let mut pipe = redis::pipe();
306310
pipe.atomic();
@@ -354,6 +358,11 @@ impl FlowProducer {
354358
}
355359

356360
let mut conn = self.conn.conn();
361+
let script_names: Vec<&str> = flows.iter().flat_map(Self::pipeline_script_names).collect();
362+
self.conn
363+
.scripts()
364+
.ensure_loaded(&mut conn, &script_names)
365+
.await?;
357366

358367
let mut pipe = redis::pipe();
359368
pipe.atomic();
@@ -701,6 +710,39 @@ impl FlowProducer {
701710
})
702711
}
703712

713+
fn pipeline_script_names(node: &FlowJob) -> Vec<&'static str> {
714+
let mut names = Vec::new();
715+
Self::collect_pipeline_script_names(node, &mut names);
716+
names
717+
}
718+
719+
fn collect_pipeline_script_names(node: &FlowJob, names: &mut Vec<&'static str>) {
720+
if let Some(children) = &node.children {
721+
if !children.is_empty() {
722+
names.push("addParentJob");
723+
for child in children {
724+
Self::collect_pipeline_script_names(child, names);
725+
}
726+
return;
727+
}
728+
}
729+
730+
let script_name = if node.opts.as_ref().and_then(|opts| opts.delay).unwrap_or(0) > 0 {
731+
"addDelayedJob"
732+
} else if node
733+
.opts
734+
.as_ref()
735+
.and_then(|opts| opts.priority)
736+
.unwrap_or(0)
737+
> 0
738+
{
739+
"addPrioritizedJob"
740+
} else {
741+
"addStandardJob"
742+
};
743+
names.push(script_name);
744+
}
745+
704746
/// Add a parent job to the pipeline using the addParentJob Lua script.
705747
fn add_parent_job_to_pipe(
706748
&self,

rust/src/redis_connection.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ fn build_client(opts: &RedisConnectionOptions, url: &str) -> Result<Client, Erro
4040
Ok(Client::build_with_tls(url, tls_certs)?)
4141
}
4242

43-
/// A managed Redis connection that handles reconnection and script loading.
43+
/// A managed Redis connection that handles reconnection and script execution.
4444
///
4545
/// This is designed to be cheaply cloneable (Arc-wrapped internals).
4646
/// `MultiplexedConnection` is internally multiplexed via channels,
@@ -62,8 +62,7 @@ impl RedisConnection {
6262
let url = opts.effective_url();
6363
let client = build_client(opts, &url)?;
6464
let scripts = ScriptRegistry::new();
65-
let mut conn = client.get_multiplexed_async_connection().await?;
66-
scripts.load_all(&mut conn).await?;
65+
let conn = client.get_multiplexed_async_connection().await?;
6766

6867
let inner = Arc::new(Inner {
6968
client,

0 commit comments

Comments
 (0)