Skip to content

Commit 7da6d5b

Browse files
fix(jobs): judge refresh cleanup against the unrestricted key_source
`Job.refresh(*restrictions)` narrowed key_source to add jobs and then reused the narrowed key_source in its cleanup step. Every job older than stale_timeout whose key fell outside the restriction therefore looked vanished and was deleted, including jobs reserved by other workers and error records. `populate(*restrictions, reserve_jobs=True)` calls refresh this way. Cleanup now checks keys against the unrestricted key_source; the restrictions still limit which jobs are added and re-pended. Success and error records whose key has left both key_source and the target (because the upstream row was deleted) are now removed on every refresh, whatever their age, instead of lingering until stale_timeout. Records of rows that still exist are kept. The age-based check for the other statuses, and stale_timeout=0 skipping it, are unchanged. Cleanup now issues one DELETE per step instead of an existence query and a DELETE for each key. Refs #1577 (cherry picked from commit fc12194ed9e13988f82b09a397b3c3373b244853)
1 parent 68fdea5 commit 7da6d5b

2 files changed

Lines changed: 233 additions & 10 deletions

File tree

‎src/datajoint/jobs.py‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,8 @@ def refresh(
329329
Parameters
330330
----------
331331
*restrictions : any
332-
Conditions to filter key_source (for adding new jobs).
332+
Conditions to filter key_source (for adding new jobs). Cleanup
333+
always checks the unrestricted key_source.
333334
delay : float, optional
334335
Seconds from now until new jobs become available for processing.
335336
Default 0 (immediately available). Uses database server time.
@@ -357,7 +358,11 @@ def refresh(
357358
358359
1. Add new jobs: ``(key_source & restrictions) - target - jobs`` → insert as pending
359360
2. Re-pend success jobs: if ``keep_completed=True`` and key in key_source but not in target
360-
3. Remove stale jobs: jobs older than stale_timeout whose keys not in key_source
361+
3. Remove vanished jobs, judged against the unrestricted key_source:
362+
363+
a. success and error records whose keys are in neither key_source nor target, at any age
364+
b. stale jobs (except ``ignore``): older than stale_timeout, keys not in key_source
365+
361366
4. Remove orphaned jobs: reserved jobs older than orphan_timeout (if specified)
362367
"""
363368
# Ensure jobs table exists
@@ -413,16 +418,24 @@ def refresh(
413418
self.insert1({**key, "status": "pending", "priority": priority})
414419
result["re_pended"] += 1
415420

416-
# 3. Remove stale jobs (not ignore status) - use server CURRENT_TIMESTAMP for consistent timing
421+
# 3. Remove jobs whose keys have vanished. Judge against the unrestricted key_source:
422+
# jobs outside the restrictions (e.g. reserved by other workers) are not stale.
423+
# Disable semantic_check for Job table operations (job table PK has different lineage than target)
424+
vanished = self.restrict(Not(self._target.key_source.proj()), semantic_check=False)
425+
426+
# 3a. Success and error records describe rows that no longer exist once their key
427+
# has left both key_source and the target, regardless of age.
428+
result["removed"] += (
429+
(vanished & "status in ('success', 'error')")
430+
.restrict(Not(self._target.proj()), semantic_check=False)
431+
.delete_quick(get_count=True)
432+
)
433+
434+
# 3b. Remove stale jobs (not ignore status) - use server CURRENT_TIMESTAMP for consistent timing
417435
if stale_timeout > 0:
418436
stale_interval = self.adapter.interval_expr(stale_timeout, "second")
419-
old_jobs = self & f"created_time < CURRENT_TIMESTAMP - {stale_interval}" & "status != 'ignore'"
420-
421-
for key in old_jobs.keys():
422-
# Check if key still in key_source
423-
if not (key_source & key):
424-
(self & key).delete_quick()
425-
result["removed"] += 1
437+
stale_jobs = vanished & f"created_time < CURRENT_TIMESTAMP - {stale_interval}" & "status != 'ignore'"
438+
result["removed"] += stale_jobs.delete_quick(get_count=True)
426439

427440
# 4. Handle orphaned reserved jobs - use server CURRENT_TIMESTAMP for consistent timing
428441
if orphan_timeout is not None and orphan_timeout > 0:
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""
2+
Cleanup in ``Job.refresh()`` (#1577).
3+
4+
``refresh(*restrictions)`` narrows the keys it adds, but the cleanup must judge
5+
which keys have vanished against the full ``key_source``: jobs outside the
6+
restriction -- reserved by other workers, or recording errors -- are not stale.
7+
``success`` and ``error`` records whose key has left both ``key_source`` and the
8+
target are removed on refresh, in one statement rather than one per key.
9+
"""
10+
11+
import time
12+
from datetime import datetime
13+
14+
import pytest
15+
16+
import datajoint as dj
17+
18+
19+
@pytest.fixture(scope="function")
20+
def schema_by_backend(connection_by_backend, db_creds_by_backend):
21+
"""Create a fresh schema per test, parameterized across backends."""
22+
backend = db_creds_by_backend["backend"]
23+
test_id = str(int(time.time() * 1000))[-8:]
24+
schema_name = f"djtest_jobsrefresh_{backend}_{test_id}"[:64]
25+
26+
if connection_by_backend.is_connected:
27+
try:
28+
connection_by_backend.query(
29+
f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}"
30+
)
31+
except Exception:
32+
pass
33+
34+
schema = dj.Schema(schema_name, connection=connection_by_backend)
35+
yield schema
36+
37+
if connection_by_backend.is_connected:
38+
try:
39+
connection_by_backend.query(
40+
f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}"
41+
)
42+
except Exception:
43+
pass
44+
45+
46+
@pytest.fixture
47+
def pipeline(schema_by_backend):
48+
@schema_by_backend
49+
class Raw(dj.Manual):
50+
definition = """
51+
raw_id : int32
52+
---
53+
v : float32
54+
"""
55+
56+
@schema_by_backend
57+
class Proc(dj.Computed):
58+
definition = """
59+
-> Raw
60+
---
61+
w : float32
62+
"""
63+
64+
def make(self, key):
65+
self.insert1(dict(key, w=2 * (Raw & key).fetch1("v")))
66+
67+
return Raw, Proc
68+
69+
70+
def _statuses(jobs):
71+
"""Map raw_id to job status."""
72+
return {row["raw_id"]: row["status"] for row in jobs.proj("status").to_dicts()}
73+
74+
75+
def _backdate(jobs):
76+
"""Make every job older than the default stale_timeout."""
77+
for key in jobs.keys():
78+
jobs.update1({**key, "created_time": datetime(2000, 1, 1)})
79+
80+
81+
def test_restricted_refresh_keeps_jobs_outside_restriction(pipeline):
82+
"""A restricted refresh leaves other workers' reservations and errors alone."""
83+
Raw, Proc = pipeline
84+
Raw.insert([dict(raw_id=i, v=float(i)) for i in range(4)])
85+
Proc.jobs.refresh()
86+
assert Proc.jobs.reserve({"raw_id": 1}) # held by another worker
87+
assert Proc.jobs.reserve({"raw_id": 2})
88+
Proc.jobs.error({"raw_id": 2}, "failed")
89+
(Raw & {"raw_id": 3}).delete(prompt=False) # pending job for a vanished key
90+
_backdate(Proc.jobs)
91+
92+
result = Proc.jobs.refresh({"raw_id": 0})
93+
94+
assert _statuses(Proc.jobs) == {0: "pending", 1: "reserved", 2: "error"}
95+
assert result["removed"] == 1
96+
97+
98+
def test_restricted_populate_keeps_jobs_outside_restriction(pipeline):
99+
"""``populate(restriction, reserve_jobs=True)`` refreshes with the restriction."""
100+
Raw, Proc = pipeline
101+
Raw.insert([dict(raw_id=i, v=float(i)) for i in range(3)])
102+
Proc.jobs.refresh()
103+
assert Proc.jobs.reserve({"raw_id": 1})
104+
assert Proc.jobs.reserve({"raw_id": 2})
105+
Proc.jobs.error({"raw_id": 2}, "failed")
106+
_backdate(Proc.jobs)
107+
108+
Proc.populate({"raw_id": 0}, reserve_jobs=True)
109+
110+
assert len(Proc()) == 1
111+
assert _statuses(Proc.jobs) == {1: "reserved", 2: "error"}
112+
113+
114+
def test_refresh_removes_records_of_vanished_keys(pipeline):
115+
"""Success and error records whose upstream row was deleted go on the next refresh."""
116+
Raw, Proc = pipeline
117+
with dj.config.override(jobs={"keep_completed": True}):
118+
Raw.insert([dict(raw_id=i, v=float(i)) for i in range(3)])
119+
Proc.populate(reserve_jobs=True)
120+
Raw.insert1(dict(raw_id=3, v=3.0))
121+
Proc.jobs.refresh()
122+
assert Proc.jobs.reserve({"raw_id": 3})
123+
Proc.jobs.error({"raw_id": 3}, "failed")
124+
assert _statuses(Proc.jobs) == {0: "success", 1: "success", 2: "success", 3: "error"}
125+
126+
(Raw & "raw_id in (0, 3)").delete(prompt=False)
127+
result = Proc.jobs.refresh() # records are fresh: no stale_timeout involved
128+
129+
assert _statuses(Proc.jobs) == {1: "success", 2: "success"}
130+
assert result["removed"] == 2
131+
132+
133+
def test_refresh_keeps_success_records_of_existing_rows(schema_by_backend):
134+
"""A success record stays while its row exists, even if its key leaves key_source."""
135+
136+
@schema_by_backend
137+
class Raw(dj.Manual):
138+
definition = """
139+
raw_id : int32
140+
---
141+
v : float32
142+
"""
143+
144+
@schema_by_backend
145+
class Gated(dj.Computed):
146+
definition = """
147+
-> Raw
148+
---
149+
w : float32
150+
"""
151+
152+
@property
153+
def key_source(self):
154+
return Raw & "v >= 0"
155+
156+
def make(self, key):
157+
self.insert1(dict(key, w=2 * (Raw & key).fetch1("v")))
158+
159+
with dj.config.override(jobs={"keep_completed": True}):
160+
Raw.insert([dict(raw_id=i, v=float(i)) for i in range(2)])
161+
Gated.populate(reserve_jobs=True)
162+
Raw.update1(dict(raw_id=0, v=-1.0)) # leaves key_source; Gated row remains
163+
164+
Gated.jobs.refresh()
165+
166+
assert _statuses(Gated.jobs) == {0: "success", 1: "success"}
167+
168+
169+
def test_stale_timeout_zero_still_skips_stale_check(pipeline):
170+
"""``stale_timeout=0`` keeps skipping the age-based check for pending jobs."""
171+
Raw, Proc = pipeline
172+
Raw.insert([dict(raw_id=i, v=float(i)) for i in range(2)])
173+
Proc.jobs.refresh()
174+
(Raw & {"raw_id": 1}).delete(prompt=False)
175+
_backdate(Proc.jobs)
176+
177+
Proc.jobs.refresh(stale_timeout=0)
178+
assert _statuses(Proc.jobs) == {0: "pending", 1: "pending"}
179+
180+
Proc.jobs.refresh()
181+
assert _statuses(Proc.jobs) == {0: "pending"}
182+
183+
184+
def test_refresh_cleanup_is_set_based(pipeline, monkeypatch):
185+
"""Cleanup issues a fixed number of statements, not one per vanished key."""
186+
Raw, Proc = pipeline
187+
n = 20
188+
with dj.config.override(jobs={"keep_completed": True}):
189+
Raw.insert([dict(raw_id=i, v=float(i)) for i in range(n)])
190+
Proc.populate(reserve_jobs=True)
191+
Raw.delete(prompt=False)
192+
_backdate(Proc.jobs) # also past stale_timeout: both cleanup steps apply
193+
194+
jobs = Proc.jobs
195+
queries = []
196+
query = jobs.connection.query
197+
198+
def counting_query(sql, *args, **kwargs):
199+
queries.append(sql)
200+
return query(sql, *args, **kwargs)
201+
202+
monkeypatch.setattr(jobs.connection, "query", counting_query)
203+
result = jobs.refresh()
204+
monkeypatch.undo()
205+
206+
assert result["removed"] == n
207+
assert len(Proc.jobs) == 0
208+
deletes = [sql for sql in queries if sql.lstrip().upper().startswith("DELETE")]
209+
assert 1 <= len(deletes) <= 2
210+
assert len(queries) < n

0 commit comments

Comments
 (0)