Skip to content

Commit 348bc47

Browse files
committed
Harden log_lines search_vector deferral
Follow-up hardening on the search_vector deferral, from a review pass: - Fix the worker's snowball chain. Oban's default unique states include :executing and :completed, so a running snowball job matched itself when enqueueing its successor and the insert was silently deduped, breaking the chain after one hop. Restrict uniqueness to [:available, :scheduled]. - Make the pending-search index migration re-runnable: a failed CREATE INDEX CONCURRENTLY leaves an INVALID index that IF NOT EXISTS would skip and ATTACH would leave the parent invalid forever. Drop any invalid leftover first. - Make safe_to_tsvector NULL-defensive and re-runnable (CREATE OR REPLACE, drop STRICT, COALESCE the doc) so it never returns NULL and leaves a row stuck in the pending set. Also the template for the dataclip fix. - Add snowball uniqueness regression tests.
1 parent 878f5e3 commit 348bc47

5 files changed

Lines changed: 88 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ and this project adheres to
2121

2222
### Fixed
2323

24+
- Reduce `run:log` channel timeouts under heavy log volume by moving `log_lines`
25+
search indexing off the insert path. The full-text search vector is now
26+
backfilled by a background worker rather than computed synchronously on every
27+
insert, so log search is eventually-consistent (typically within a minute).
28+
[#4425](https://github.com/OpenFn/lightning/issues/4425)
2429
- Channel join crashes when multiple users open the same workflow concurrently
2530
[#4802](https://github.com/OpenFn/lightning/issues/4802)
2631
- Fix `purge_deleted` Oban job crashing when a soft-deleted project has

lib/lightning/log_lines/search_vector_worker.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ defmodule Lightning.LogLines.SearchVectorWorker do
3939
queue: :search_indexing,
4040
priority: 1,
4141
max_attempts: 10,
42-
unique: [period: 55, keys: [:trigger]]
42+
# `states` is restricted to the queued states on purpose. Oban's default
43+
# unique states include `:executing` and `:completed`, which would make a
44+
# running snowball job match *itself* when it tries to enqueue its
45+
# successor, silently dedup the insert, and break the chain after a single
46+
# hop. Limiting uniqueness to `:available`/`:scheduled` still guarantees at
47+
# most one queued snowball (and one queued cron heartbeat, via the distinct
48+
# `:trigger` key) while letting the executing job enqueue the next link.
49+
unique: [period: 55, keys: [:trigger], states: [:available, :scheduled]]
4350

4451
alias Lightning.Repo
4552

priv/repo/migrations/20260530091125_add_safe_to_tsvector_function.exs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ defmodule Lightning.Repo.Migrations.AddSafeToTsvectorFunction do
22
use Ecto.Migration
33

44
def up do
5+
# Not STRICT: a STRICT function returns NULL (without running) when `doc` is
6+
# NULL, which would leave the row's search_vector NULL forever and stuck in
7+
# the pending index. COALESCE the doc instead so the function always yields
8+
# a non-NULL tsvector. CREATE OR REPLACE keeps the migration re-runnable.
59
execute("""
6-
CREATE FUNCTION safe_to_tsvector(config regconfig, doc text) RETURNS tsvector
7-
LANGUAGE plpgsql IMMUTABLE STRICT AS $$
10+
CREATE OR REPLACE FUNCTION safe_to_tsvector(config regconfig, doc text) RETURNS tsvector
11+
LANGUAGE plpgsql IMMUTABLE AS $$
812
BEGIN
9-
RETURN to_tsvector(config, doc);
13+
RETURN to_tsvector(config, COALESCE(doc, ''));
1014
EXCEPTION WHEN program_limit_exceeded THEN
1115
RETURN ''::tsvector;
1216
END;

priv/repo/migrations/20260530091126_add_log_lines_pending_search_index.exs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ defmodule Lightning.Repo.Migrations.AddLogLinesPendingSearchIndex do
3131
end
3232

3333
defp create_partition_index(_num_partitions, part_num) do
34+
# A failed CREATE INDEX CONCURRENTLY leaves an INVALID index behind. The
35+
# IF NOT EXISTS below would then skip rebuilding it, and the subsequent
36+
# ATTACH would leave the parent permanently INVALID. Drop any invalid
37+
# leftover first so a re-run rebuilds it cleanly. (A valid index is kept;
38+
# only invalid ones are dropped.)
39+
execute("""
40+
DO $$
41+
BEGIN
42+
IF EXISTS (
43+
SELECT 1 FROM pg_class c
44+
JOIN pg_index i ON i.indexrelid = c.oid
45+
WHERE c.relname = 'log_lines_#{part_num}_pending_search_idx'
46+
AND NOT i.indisvalid
47+
) THEN
48+
EXECUTE 'DROP INDEX log_lines_#{part_num}_pending_search_idx';
49+
END IF;
50+
END $$;
51+
""")
52+
3453
execute("""
3554
CREATE INDEX CONCURRENTLY IF NOT EXISTS log_lines_#{part_num}_pending_search_idx
3655
ON log_lines_#{part_num} (timestamp)

test/lightning/log_lines/search_vector_worker_test.exs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,53 @@ defmodule Lightning.LogLines.SearchVectorWorkerTest do
143143
end)
144144
end
145145
end
146+
147+
describe "snowball uniqueness" do
148+
# Regression: Oban's default unique states include :executing and :completed,
149+
# so a running snowball job (state :executing) matched *itself* when it tried
150+
# to enqueue its successor — the insert was silently deduped and the chain
151+
# died after one hop. The worker restricts uniqueness to the queued states so
152+
# an executing job can always enqueue the next link.
153+
test "an executing snowball does not block enqueuing its successor" do
154+
Oban.Testing.with_testing_mode(:manual, fn ->
155+
{:ok, running} =
156+
Oban.insert(
157+
Lightning.Oban,
158+
SearchVectorWorker.new(%{"trigger" => "snowball"})
159+
)
160+
161+
# Mimic Oban marking the job as executing while perform/1 runs.
162+
from(j in Oban.Job, where: j.id == ^running.id)
163+
|> Repo.update_all(set: [state: "executing"])
164+
165+
{:ok, successor} =
166+
Oban.insert(
167+
Lightning.Oban,
168+
SearchVectorWorker.new(%{"trigger" => "snowball"})
169+
)
170+
171+
refute successor.conflict?
172+
refute successor.id == running.id
173+
end)
174+
end
175+
176+
test "two queued snowballs are deduped to one" do
177+
Oban.Testing.with_testing_mode(:manual, fn ->
178+
{:ok, first} =
179+
Oban.insert(
180+
Lightning.Oban,
181+
SearchVectorWorker.new(%{"trigger" => "snowball"})
182+
)
183+
184+
{:ok, second} =
185+
Oban.insert(
186+
Lightning.Oban,
187+
SearchVectorWorker.new(%{"trigger" => "snowball"})
188+
)
189+
190+
assert second.conflict?
191+
assert second.id == first.id
192+
end)
193+
end
194+
end
146195
end

0 commit comments

Comments
 (0)