Skip to content

Commit 5cbfe65

Browse files
fix: JSONL append-only job store — eliminates race condition where SSE closes immediately before job file exists; also hard refresh browser with Ctrl+Shift+R
1 parent 14ee2aa commit 5cbfe65

1 file changed

Lines changed: 102 additions & 55 deletions

File tree

panel/routes/modules.py

Lines changed: 102 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -40,58 +40,78 @@ def translate_install_cmd(cmd):
4040

4141
def req(): return 'user' in session
4242

43-
# ── Job store: file-based so all gunicorn workers share state ──────────────
44-
# In-memory dict fails with --workers > 1 because each worker has its own
45-
# memory space. A small JSON file per job in /tmp/vortex_jobs/ is shared
46-
# across all workers via the filesystem.
43+
# ── Job store: JSONL append-only files shared across all gunicorn workers ───
44+
# Each job = one .jsonl file where every line is a complete JSON object.
45+
# Appending one JSON line is atomic for small writes — no read-modify-write,
46+
# no corruption, no locks needed between workers.
47+
# Format per line:
48+
# {"line": "apt-get output..."} — progress output line
49+
# {"done": true, "success": true/false, — final status (last line)
50+
# "installed": true, "installedVer": "x.y.z"}
4751
_JOBS_DIR = '/tmp/vortex_jobs'
4852
os.makedirs(_JOBS_DIR, exist_ok=True)
49-
_jobs_lock = threading.Lock()
5053

5154
def _job_path(job_id):
52-
return os.path.join(_JOBS_DIR, f'{job_id}.json')
55+
return os.path.join(_JOBS_DIR, f'{job_id}.jsonl')
5356

54-
def _job_get(job_id):
55-
try:
56-
with open(_job_path(job_id)) as f:
57-
return json.load(f)
58-
except: return None
59-
60-
def _job_set(job_id, data):
61-
with _jobs_lock:
62-
try:
63-
with open(_job_path(job_id), 'w') as f:
64-
json.dump(data, f)
65-
except: pass
57+
def _job_create(job_id, **_):
58+
"""Create empty job file so SSE stream knows it exists."""
59+
open(_job_path(job_id), 'w').close()
6660

6761
def _job_append_line(job_id, line):
68-
with _jobs_lock:
69-
try:
70-
path = _job_path(job_id)
71-
with open(path) as f: data = json.load(f)
72-
data['lines'].append(line)
73-
with open(path, 'w') as f: json.dump(data, f)
74-
except: pass
62+
"""Append one output line. Atomic for small writes."""
63+
try:
64+
with open(_job_path(job_id), 'a') as f:
65+
f.write(json.dumps({'line': line}) + '\n')
66+
except Exception as e:
67+
pass # non-fatal; best-effort streaming
7568

7669
def _job_finish(job_id, success, installed, inst_ver=''):
77-
with _jobs_lock:
78-
try:
79-
path = _job_path(job_id)
80-
with open(path) as f: data = json.load(f)
81-
data.update({'done':True,'success':success,'installed':installed,
82-
'installedVer':inst_ver,'status':'done'})
83-
with open(path, 'w') as f: json.dump(data, f)
84-
except: pass
85-
86-
def _job_create(job_id, initial_installed=False):
87-
_job_set(job_id, {'status':'running','lines':[],'done':False,
88-
'success':False,'installed':initial_installed,'installedVer':''})
70+
"""Append final status line to job file."""
71+
try:
72+
with open(_job_path(job_id), 'a') as f:
73+
f.write(json.dumps({
74+
'done': True, 'success': success,
75+
'installed': installed, 'installedVer': inst_ver,
76+
}) + '\n')
77+
except Exception:
78+
pass
8979

90-
# Legacy compatibility — keep _jobs as a shim for any direct access
80+
def _job_get(job_id):
81+
"""Read all lines from JSONL job file. Returns dict with lines[], done, etc."""
82+
path = _job_path(job_id)
83+
if not os.path.exists(path):
84+
return None
85+
lines = []
86+
done = False
87+
success = False
88+
installed = True
89+
inst_ver = ''
90+
try:
91+
with open(path) as f:
92+
for raw in f:
93+
raw = raw.strip()
94+
if not raw:
95+
continue
96+
try:
97+
obj = json.loads(raw)
98+
except json.JSONDecodeError:
99+
continue
100+
if 'line' in obj:
101+
lines.append(obj['line'])
102+
elif obj.get('done'):
103+
done = True
104+
success = obj.get('success', False)
105+
installed = obj.get('installed', True)
106+
inst_ver = obj.get('installedVer', '')
107+
except Exception:
108+
pass
109+
return {'lines': lines, 'done': done, 'success': success,
110+
'installed': installed, 'installedVer': inst_ver}
111+
112+
# Shim so existing _jobs[job_id] reads still work (used nowhere new, but safe)
91113
class _JobsShim:
92114
def get(self, job_id, default=None): return _job_get(job_id) or default
93-
def __setitem__(self, job_id, val): _job_set(job_id, val)
94-
def __getitem__(self, job_id): return _job_get(job_id)
95115
_jobs = _JobsShim()
96116

97117
def sh(c, t=10):
@@ -1027,24 +1047,51 @@ def run_job():
10271047
@modules_bp.route('/api/modules/job/<job_id>')
10281048
def job_stream(job_id):
10291049
def generate():
1030-
sent = 0
1031-
for _ in range(600): # max 3 minutes (600 × 0.3s)
1032-
job = _job_get(job_id)
1033-
if not job:
1034-
yield f'data: {json.dumps({"error":"Job not found"})}\n\n'; break
1035-
lines = job.get('lines', [])
1036-
while sent < len(lines):
1037-
yield f'data: {json.dumps({"line": lines[sent]})}\n\n'
1038-
sent += 1
1039-
if job.get('done'):
1040-
yield f'data: {json.dumps({"done":True,"success":job["success"],"installed":job["installed"],"installedVer":job.get("installedVer","")})}\n\n'
1041-
# Clean up job file after a short delay
1042-
try: os.remove(_job_path(job_id))
1043-
except: pass
1050+
path = _job_path(job_id)
1051+
# Wait up to 5s for the job file to appear (handles race between
1052+
# POST creating the job and the EventSource connecting)
1053+
for _ in range(50):
1054+
if os.path.exists(path):
10441055
break
1056+
time.sleep(0.1)
1057+
else:
1058+
yield f'data: {json.dumps({"error": "Job not found"})}\n\n'
1059+
return
1060+
1061+
sent = 0 # number of JSONL lines already sent to client
1062+
for _ in range(1200): # max 6 minutes (1200 × 0.3s)
1063+
try:
1064+
with open(path) as f:
1065+
all_lines = f.readlines()
1066+
except Exception:
1067+
time.sleep(0.3)
1068+
continue
1069+
1070+
# Stream any new lines since last poll
1071+
for raw in all_lines[sent:]:
1072+
raw = raw.strip()
1073+
if not raw:
1074+
continue
1075+
try:
1076+
obj = json.loads(raw)
1077+
except json.JSONDecodeError:
1078+
sent += 1
1079+
continue
1080+
if 'line' in obj:
1081+
yield f'data: {json.dumps({"line": obj["line"]})}\n\n'
1082+
elif obj.get('done'):
1083+
yield f'data: {json.dumps({"done": True, "success": obj.get("success", False), "installed": obj.get("installed", True), "installedVer": obj.get("installedVer", "")})}\n\n'
1084+
try:
1085+
os.remove(path)
1086+
except Exception:
1087+
pass
1088+
return
1089+
sent += 1
1090+
10451091
time.sleep(0.3)
1092+
10461093
return Response(generate(), mimetype='text/event-stream',
1047-
headers={'Cache-Control':'no-cache','X-Accel-Buffering':'no'})
1094+
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
10481095

10491096
@modules_bp.route('/api/modules/<mod_id>/control', methods=['POST'])
10501097
def control_module(mod_id):

0 commit comments

Comments
 (0)