@@ -40,58 +40,78 @@ def translate_install_cmd(cmd):
4040
4141def 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'
4852os .makedirs (_JOBS_DIR , exist_ok = True )
49- _jobs_lock = threading .Lock ()
5053
5154def _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
6761def _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
7669def _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)
91113class _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
97117def sh (c , t = 10 ):
@@ -1027,24 +1047,51 @@ def run_job():
10271047@modules_bp .route ('/api/modules/job/<job_id>' )
10281048def 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' ])
10501097def control_module (mod_id ):
0 commit comments