Skip to content

Commit 096fbc7

Browse files
committed
fix(sse): resolve real-time log rendering on re-extraction using thread-safe loop.call_soon_threadsafe queue and resilient line-by-line SSE parsing
1 parent 78645b5 commit 096fbc7

2 files changed

Lines changed: 40 additions & 18 deletions

File tree

frontend/app.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -693,19 +693,32 @@ document.addEventListener('DOMContentLoaded', () => {
693693
if (done) break;
694694

695695
buffer += decoder.decode(value, { stream: true });
696-
const lines = buffer.split('\n\n');
697-
buffer = lines.pop();
696+
const lines = buffer.split(/\r?\n/);
697+
buffer = lines.pop(); // keep last incomplete line in buffer
698698

699-
for (const block of lines) {
700-
if (block.startsWith('data: ')) {
701-
const jsonStr = block.replace('data: ', '').trim();
699+
for (const line of lines) {
700+
const trimmed = line.trim();
701+
if (trimmed.startsWith('data:')) {
702+
const jsonStr = trimmed.replace(/^data:\s*/, '').trim();
702703
if (jsonStr) {
703-
const event = JSON.parse(jsonStr);
704-
handleExtractionEvent(event);
704+
try {
705+
const event = JSON.parse(jsonStr);
706+
handleExtractionEvent(event);
707+
} catch (err) {
708+
console.warn('Failed to parse SSE JSON:', jsonStr, err);
709+
}
705710
}
706711
}
707712
}
708713
}
714+
715+
// Flush any remaining buffer on stream close
716+
if (buffer && buffer.trim().startsWith('data:')) {
717+
try {
718+
const event = JSON.parse(buffer.trim().replace(/^data:\s*/, ''));
719+
handleExtractionEvent(event);
720+
} catch (e) {}
721+
}
709722
} catch (e) {
710723
if (e.name === 'AbortError') {
711724
appendTerminalLog('⏹️ Extraction canceled by user.');

routes/extraction.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
get_embedder,
1818
get_qdrant,
1919
sanitize_error_message,
20+
get_persisted_workspace_files,
2021
get_persisted_document,
2122
save_persisted_document,
2223
)
@@ -42,24 +43,26 @@ class ExtractRequest(BaseModel):
4243
@router.post("/api/extract-jsonld-stream")
4344
async def extract_jsonld_stream(req: ExtractRequest):
4445
file_name = req.file_name
45-
if file_name not in WORKSPACE_FILES:
46+
workspace_files = get_persisted_workspace_files()
47+
if file_name not in workspace_files:
4648
raise HTTPException(status_code=404, detail="File belum diunggah ke workspace.")
4749
if req.base_url and not is_safe_custom_endpoint(req.base_url):
4850
raise HTTPException(status_code=400, detail="Disallowed or unsafe custom base_url parameter.")
4951

52+
loop = asyncio.get_running_loop()
53+
log_queue = asyncio.Queue()
54+
5055
# SSE Generator for real-time extraction logs
5156
async def event_generator():
52-
log_queue = asyncio.Queue()
53-
54-
def sync_logger(msg: str):
57+
def thread_safe_logger(msg: str):
5558
clean_msg = sanitize_error_message(msg)
56-
log_queue.put_nowait({"type": "log", "message": clean_msg})
59+
loop.call_soon_threadsafe(log_queue.put_nowait, {"type": "log", "message": clean_msg})
5760

5861
async def run_extraction():
5962
try:
6063
# Ensure chunks exist safely with workspace lock
6164
async with _WORKSPACE_LOCK:
62-
fpath = WORKSPACE_FILES[file_name]
65+
fpath = workspace_files[file_name]
6366
file_chunks = [c for c in EXTRACTED_CHUNKS if c.get("metadata", {}).get("source") == file_name]
6467
if not file_chunks:
6568
file_chunks = parse_document(fpath, file_name)
@@ -77,7 +80,7 @@ async def run_extraction():
7780
chunks=file_chunks,
7881
qdrant_client=qdrant if IS_INDEXED else None,
7982
embedder=embedder,
80-
progress_callback=sync_logger,
83+
progress_callback=thread_safe_logger,
8184
llm_provider=req.llm_provider,
8285
llm_model=req.llm_model,
8386
api_key=req.api_key,
@@ -87,16 +90,16 @@ async def run_extraction():
8790
existing_record = get_persisted_document(file_name)
8891
if existing_record:
8992
final_res = merge_and_enrich_json_ld(existing_record, res)
90-
sync_logger("🔄 [Database Optimization] Menggabungkan field & struktur baru dengan data terverifikasi sebelumnya secara non-destruktif.")
93+
thread_safe_logger("🔄 [Database Optimization] Menggabungkan field & struktur baru dengan data terverifikasi sebelumnya secara non-destruktif.")
9194
else:
9295
final_res = res
9396

9497
async with _WORKSPACE_LOCK:
9598
save_persisted_document(file_name, final_res)
96-
await log_queue.put({"type": "complete", "result": final_res})
99+
loop.call_soon_threadsafe(log_queue.put_nowait, {"type": "complete", "result": final_res})
97100
except Exception as e:
98101
clean_err = sanitize_error_message(str(e))
99-
await log_queue.put({"type": "error", "error": clean_err})
102+
loop.call_soon_threadsafe(log_queue.put_nowait, {"type": "error", "error": clean_err})
100103

101104
# Launch extraction task
102105
task = asyncio.create_task(run_extraction())
@@ -117,7 +120,13 @@ async def run_extraction():
117120
pass
118121
raise
119122

120-
return StreamingResponse(event_generator(), media_type="text/event-stream")
123+
headers = {
124+
"Cache-Control": "no-cache",
125+
"Connection": "keep-alive",
126+
"X-Accel-Buffering": "no",
127+
"Content-Type": "text/event-stream; charset=utf-8",
128+
}
129+
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
121130

122131

123132
@router.get("/api/jsonld/{file_name}")

0 commit comments

Comments
 (0)