fix: keep references to background asyncio tasks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
03529a5403
commit
4f30ee5c0d
@ -13,6 +13,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
- **Voice list rows no longer show the "Source unknown" badge** (it crowded the play buttons). The badge stays in the voice inspector, next to the Source & consent panel.
|
- **Voice list rows no longer show the "Source unknown" badge** (it crowded the play buttons). The badge stays in the voice inspector, next to the Source & consent panel.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Background jobs could be garbage-collected mid-run.** Fire-and-forget `asyncio.create_task` calls (magazine, conversation, tts, stt, library, sources) now go through `core.tasks.spawn`, which keeps a strong reference until the task ends and logs failures by exception class only.
|
||||||
- **Voice archetype gallery broke `npm run check:frontend`**: its `say` helper collided with `magazine.js`; renamed to `archSay`.
|
- **Voice archetype gallery broke `npm run check:frontend`**: its `say` helper collided with `magazine.js`; renamed to `archSay`.
|
||||||
- **Export failed with HTTP 500 when provenance tagging failed** (EPUB/ZIP/article MP3 with audio ffmpeg cannot tag); it now ships the untagged file and logs a warning.
|
- **Export failed with HTTP 500 when provenance tagging failed** (EPUB/ZIP/article MP3 with audio ffmpeg cannot tag); it now ships the untagged file and logs a warning.
|
||||||
- **Archetype `language` filter accepted only full names.** `GET /api/archetypes?language=de` returned 0 items; it now accepts ISO codes and names, case-insensitive (`de`, `DE`, `German`); unknown values return an empty list.
|
- **Archetype `language` filter accepted only full names.** `GET /api/archetypes?language=de` returned 0 items; it now accepts ISO codes and names, case-insensitive (`de`, `DE`, `German`); unknown values return an empty list.
|
||||||
|
|||||||
23
core/tasks.py
Normal file
23
core/tasks.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""Fire-and-forget asyncio tasks that are kept alive until they finish."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
_BACKGROUND: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _done(task: asyncio.Task) -> None:
|
||||||
|
_BACKGROUND.discard(task)
|
||||||
|
if not task.cancelled() and task.exception() is not None:
|
||||||
|
# class name only: exception text may carry paths/URLs/keys
|
||||||
|
log.error("background task failed: %s", type(task.exception()).__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def spawn(coro) -> asyncio.Task:
|
||||||
|
"""create_task + strong reference (the loop only keeps weak ones)."""
|
||||||
|
task = asyncio.create_task(coro)
|
||||||
|
_BACKGROUND.add(task)
|
||||||
|
task.add_done_callback(_done)
|
||||||
|
return task
|
||||||
@ -23,6 +23,7 @@ from PIL import Image, ImageDraw, ImageOps
|
|||||||
from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
|
from core.tasks import spawn
|
||||||
from core.config import _load_settings, _save_settings, _clean_preview_backend
|
from core.config import _load_settings, _save_settings, _clean_preview_backend
|
||||||
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES
|
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES
|
||||||
from core.registry import _registry_get, TEMP_DIR
|
from core.registry import _registry_get, TEMP_DIR
|
||||||
@ -930,7 +931,7 @@ async def match_characters_voices(request: Request):
|
|||||||
oldest = min(_CAST_JOB_IDEMPOTENCY, key=lambda key: _CAST_JOB_IDEMPOTENCY[key][1])
|
oldest = min(_CAST_JOB_IDEMPOTENCY, key=lambda key: _CAST_JOB_IDEMPOTENCY[key][1])
|
||||||
_CAST_JOB_IDEMPOTENCY.pop(oldest, None)
|
_CAST_JOB_IDEMPOTENCY.pop(oldest, None)
|
||||||
_cast_job_trim()
|
_cast_job_trim()
|
||||||
asyncio.create_task(_run_cast_job(job_id, job_data, {"idempotency-key": idempotency_key}))
|
spawn(_run_cast_job(job_id, job_data, {"idempotency-key": idempotency_key}))
|
||||||
return {"job_id": job_id, "stage": "queued"}
|
return {"job_id": job_id, "stage": "queued"}
|
||||||
request_fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
|
request_fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
|
|
||||||
|
from core.tasks import spawn
|
||||||
from core.config import _load_settings
|
from core.config import _load_settings
|
||||||
from core.constants import _VOICES_DIR_DEFAULT, _OUTPUT_DIR_DEFAULT, _VOICE_TARGET_DBFS, _VOICE_PEAK_DBFS, _BENCHMARK_SENTENCES, _MAX_TTS_OUTPUT_SECONDS, _MAX_UPLOAD_BYTES, _MAX_PICTURE_BYTES, _MAX_SOUND_BYTES, _ALLOW_PRIVATE_DOWNLOADS
|
from core.constants import _VOICES_DIR_DEFAULT, _OUTPUT_DIR_DEFAULT, _VOICE_TARGET_DBFS, _VOICE_PEAK_DBFS, _BENCHMARK_SENTENCES, _MAX_TTS_OUTPUT_SECONDS, _MAX_UPLOAD_BYTES, _MAX_PICTURE_BYTES, _MAX_SOUND_BYTES, _ALLOW_PRIVATE_DOWNLOADS
|
||||||
from core.registry import _registry_put, _registry_get, TEMP_DIR
|
from core.registry import _registry_put, _registry_get, TEMP_DIR
|
||||||
@ -739,7 +740,7 @@ async def prepare_missing_references(request: Request):
|
|||||||
"errors": [],
|
"errors": [],
|
||||||
"started_at": datetime.now().isoformat(),
|
"started_at": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
asyncio.create_task(_run_prepare_missing_job(job_id, candidates, settings))
|
spawn(_run_prepare_missing_job(job_id, candidates, settings))
|
||||||
return {"job_id": job_id, "queued": len(candidates)}
|
return {"job_id": job_id, "queued": len(candidates)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ from fastapi.responses import FileResponse, Response
|
|||||||
from core import magazine as mag
|
from core import magazine as mag
|
||||||
from core import magazine_untitled as untitled
|
from core import magazine_untitled as untitled
|
||||||
from core import word_alignment
|
from core import word_alignment
|
||||||
|
from core.tasks import spawn
|
||||||
from core.config import _load_settings
|
from core.config import _load_settings
|
||||||
from core.constants import CONFIG_DIR
|
from core.constants import CONFIG_DIR
|
||||||
from core.database import (
|
from core.database import (
|
||||||
@ -511,7 +512,7 @@ async def magazine_ingest(doc_id: str, request: Request):
|
|||||||
meta["ingest"] = {"status": "running", "job_id": job_id}
|
meta["ingest"] = {"status": "running", "job_id": job_id}
|
||||||
_write_meta(d, meta)
|
_write_meta(d, meta)
|
||||||
|
|
||||||
asyncio.create_task(
|
spawn(
|
||||||
asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi,
|
asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi,
|
||||||
vision_ocr_url, vision_ocr_model,
|
vision_ocr_url, vision_ocr_model,
|
||||||
vision_ocr_fallback_url, vision_ocr_fallback_model,
|
vision_ocr_fallback_url, vision_ocr_fallback_model,
|
||||||
@ -987,7 +988,7 @@ async def magazine_suggest_titles(doc_id: str):
|
|||||||
_require_magazine(doc_id)
|
_require_magazine(doc_id)
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-suggest-titles", fingerprint=doc_id)
|
background_job_create(job_id, "magazine-suggest-titles", fingerprint=doc_id)
|
||||||
asyncio.create_task(asyncio.to_thread(_suggest_titles_sync, doc_id, job_id))
|
spawn(asyncio.to_thread(_suggest_titles_sync, doc_id, job_id))
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@ -1382,7 +1383,7 @@ async def magazine_reocr_page(doc_id: str, page: int, request: Request):
|
|||||||
raise HTTPException(409, f"Page {page} is still being read; wait for it to finish")
|
raise HTTPException(409, f"Page {page} is still being read; wait for it to finish")
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-reocr-page", fingerprint=f"{doc_id}:{page}")
|
background_job_create(job_id, "magazine-reocr-page", fingerprint=f"{doc_id}:{page}")
|
||||||
asyncio.create_task(
|
spawn(
|
||||||
asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page,
|
asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page,
|
||||||
vision_ocr_url, vision_ocr_model,
|
vision_ocr_url, vision_ocr_model,
|
||||||
vision_ocr_fallback_url, vision_ocr_fallback_model)
|
vision_ocr_fallback_url, vision_ocr_fallback_model)
|
||||||
@ -1420,7 +1421,7 @@ async def magazine_rotate_page(doc_id: str, page: int, request: Request):
|
|||||||
raise HTTPException(409, f"Page {page} is still being turned or read; wait for it to finish before turning it again")
|
raise HTTPException(409, f"Page {page} is still being turned or read; wait for it to finish before turning it again")
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-rotate-page", fingerprint=f"{doc_id}:{page}")
|
background_job_create(job_id, "magazine-rotate-page", fingerprint=f"{doc_id}:{page}")
|
||||||
asyncio.create_task(asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page, url, model, fb_url, fb_model, degrees))
|
spawn(asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page, url, model, fb_url, fb_model, degrees))
|
||||||
return {"job_id": job_id, "degrees": degrees}
|
return {"job_id": job_id, "degrees": degrees}
|
||||||
|
|
||||||
|
|
||||||
@ -1471,7 +1472,7 @@ def _start_quality_page_job(doc_id: str, page: int, mode: str,
|
|||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, f"magazine-quality-{mode}",
|
background_job_create(job_id, f"magazine-quality-{mode}",
|
||||||
fingerprint=f"{doc_id}:{page}")
|
fingerprint=f"{doc_id}:{page}")
|
||||||
asyncio.create_task(asyncio.to_thread(
|
spawn(asyncio.to_thread(
|
||||||
_quality_page_sync, doc_id, job_id, page, mode,
|
_quality_page_sync, doc_id, job_id, page, mode,
|
||||||
str(settings.get("vision_ocr_url") or "").strip() or None,
|
str(settings.get("vision_ocr_url") or "").strip() or None,
|
||||||
str(settings.get("vision_ocr_model") or "").strip() or None,
|
str(settings.get("vision_ocr_model") or "").strip() or None,
|
||||||
@ -1702,7 +1703,7 @@ async def magazine_quality_run(doc_id: str):
|
|||||||
_require_magazine(doc_id)
|
_require_magazine(doc_id)
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-quality-run", fingerprint=doc_id)
|
background_job_create(job_id, "magazine-quality-run", fingerprint=doc_id)
|
||||||
asyncio.create_task(asyncio.to_thread(_quality_run_sync, doc_id, job_id, _load_settings()))
|
spawn(asyncio.to_thread(_quality_run_sync, doc_id, job_id, _load_settings()))
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@ -1770,7 +1771,7 @@ async def magazine_autofix_page(doc_id: str, page: int, request: Request):
|
|||||||
mode = "boxes" if isinstance(body, dict) and body.get("mode") == "boxes" else "all"
|
mode = "boxes" if isinstance(body, dict) and body.get("mode") == "boxes" else "all"
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-autofix", fingerprint=f"{doc_id}:{page}")
|
background_job_create(job_id, "magazine-autofix", fingerprint=f"{doc_id}:{page}")
|
||||||
asyncio.create_task(asyncio.to_thread(_autofix_page_sync, doc_id, job_id, page, mode, _load_settings()))
|
spawn(asyncio.to_thread(_autofix_page_sync, doc_id, job_id, page, mode, _load_settings()))
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@ -2253,7 +2254,7 @@ async def magazine_refine_figures(doc_id: str):
|
|||||||
_require_magazine(doc_id)
|
_require_magazine(doc_id)
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-refine-figures", fingerprint=doc_id)
|
background_job_create(job_id, "magazine-refine-figures", fingerprint=doc_id)
|
||||||
asyncio.create_task(asyncio.to_thread(_refine_figures_sync, doc_id, job_id))
|
spawn(asyncio.to_thread(_refine_figures_sync, doc_id, job_id))
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@ -2267,7 +2268,7 @@ async def magazine_refine_page_figures(doc_id: str, page: int):
|
|||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-refine-page-figures",
|
background_job_create(job_id, "magazine-refine-page-figures",
|
||||||
fingerprint=f"{doc_id}:{page}")
|
fingerprint=f"{doc_id}:{page}")
|
||||||
asyncio.create_task(asyncio.to_thread(_refine_figures_sync, doc_id, job_id, page))
|
spawn(asyncio.to_thread(_refine_figures_sync, doc_id, job_id, page))
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@ -2328,7 +2329,7 @@ async def magazine_add_region(doc_id: str, page: int, request: Request):
|
|||||||
|
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-add-region", fingerprint=f"{doc_id}:{page}")
|
background_job_create(job_id, "magazine-add-region", fingerprint=f"{doc_id}:{page}")
|
||||||
asyncio.create_task(
|
spawn(
|
||||||
asyncio.to_thread(_add_region_sync, doc_id, job_id, page, [float(v) for v in bbox], kind, text,
|
asyncio.to_thread(_add_region_sync, doc_id, job_id, page, [float(v) for v in bbox], kind, text,
|
||||||
vision_ocr_url, vision_ocr_model,
|
vision_ocr_url, vision_ocr_model,
|
||||||
vision_ocr_fallback_url, vision_ocr_fallback_model)
|
vision_ocr_fallback_url, vision_ocr_fallback_model)
|
||||||
@ -2548,7 +2549,7 @@ async def magazine_describe_image(doc_id: str, image_id: str, request: Request):
|
|||||||
|
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-describe-image", fingerprint=f"{doc_id}:{image_id}")
|
background_job_create(job_id, "magazine-describe-image", fingerprint=f"{doc_id}:{image_id}")
|
||||||
asyncio.create_task(
|
spawn(
|
||||||
asyncio.to_thread(_describe_image_sync, doc_id, job_id, image_id,
|
asyncio.to_thread(_describe_image_sync, doc_id, job_id, image_id,
|
||||||
vision_ocr_url, vision_ocr_model,
|
vision_ocr_url, vision_ocr_model,
|
||||||
vision_ocr_fallback_url, vision_ocr_fallback_model)
|
vision_ocr_fallback_url, vision_ocr_fallback_model)
|
||||||
@ -3015,7 +3016,7 @@ async def magazine_spelling_agent(doc_id: str, request: Request):
|
|||||||
raise HTTPException(400, "No LLM model configured")
|
raise HTTPException(400, "No LLM model configured")
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-spelling", fingerprint=f"{doc_id}:{article or page or 'all'}")
|
background_job_create(job_id, "magazine-spelling", fingerprint=f"{doc_id}:{article or page or 'all'}")
|
||||||
asyncio.create_task(asyncio.to_thread(_spelling_agent_job, doc_id, job_id, article, page, settings))
|
spawn(asyncio.to_thread(_spelling_agent_job, doc_id, job_id, article, page, settings))
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@ -3221,7 +3222,7 @@ async def magazine_generate_article_audio(doc_id: str, article_id: str, request:
|
|||||||
raise HTTPException(400, "No narrator voice given")
|
raise HTTPException(400, "No narrator voice given")
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-article-audio", fingerprint=f"{doc_id}:{article_id}")
|
background_job_create(job_id, "magazine-article-audio", fingerprint=f"{doc_id}:{article_id}")
|
||||||
asyncio.create_task(asyncio.to_thread(
|
spawn(asyncio.to_thread(
|
||||||
_audio_generate_one_sync, doc_id, job_id, article_id, voice, interview_voice))
|
_audio_generate_one_sync, doc_id, job_id, article_id, voice, interview_voice))
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
@ -3397,7 +3398,7 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request):
|
|||||||
raise HTTPException(400, "No approved articles to render")
|
raise HTTPException(400, "No approved articles to render")
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
background_job_create(job_id, "magazine-audio-batch", fingerprint=doc_id)
|
background_job_create(job_id, "magazine-audio-batch", fingerprint=doc_id)
|
||||||
asyncio.create_task(asyncio.to_thread(
|
spawn(asyncio.to_thread(
|
||||||
_audio_generate_batch_sync, doc_id, job_id, article_ids, voice, interview_voice))
|
_audio_generate_batch_sync, doc_id, job_id, article_ids, voice, interview_voice))
|
||||||
return {"job_id": job_id, "count": len(article_ids)}
|
return {"job_id": job_id, "count": len(article_ids)}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
from core import magazine_sources as ms
|
from core import magazine_sources as ms
|
||||||
|
from core.tasks import spawn
|
||||||
from core.config import _load_settings, _save_settings
|
from core.config import _load_settings, _save_settings
|
||||||
from core.constants import CONFIG_DIR
|
from core.constants import CONFIG_DIR
|
||||||
from core.database import background_job_create, background_job_update
|
from core.database import background_job_create, background_job_update
|
||||||
@ -162,5 +163,5 @@ async def magazine_sources_import(request: Request):
|
|||||||
async with _import_lock:
|
async with _import_lock:
|
||||||
await asyncio.to_thread(_download_sync, doc_id, job_id, key, item_id)
|
await asyncio.to_thread(_download_sync, doc_id, job_id, key, item_id)
|
||||||
|
|
||||||
asyncio.create_task(run())
|
spawn(run())
|
||||||
return {"id": doc_id, "job_id": job_id}
|
return {"id": doc_id, "job_id": job_id}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import requests
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from core.tasks import spawn
|
||||||
from core.config import _load_settings
|
from core.config import _load_settings
|
||||||
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES
|
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES
|
||||||
from core.registry import _registry_put, _registry_get, TEMP_DIR
|
from core.registry import _registry_put, _registry_get, TEMP_DIR
|
||||||
@ -705,7 +706,7 @@ async def quick_import_voice(request: Request):
|
|||||||
_save_meta(wav_dest, meta)
|
_save_meta(wav_dest, meta)
|
||||||
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest)
|
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest)
|
||||||
if auto_prepare and not transcript:
|
if auto_prepare and not transcript:
|
||||||
asyncio.create_task(_prepare_imported_voice(wav_dest, settings))
|
spawn(_prepare_imported_voice(wav_dest, settings))
|
||||||
return {
|
return {
|
||||||
"voice_id": final_id,
|
"voice_id": final_id,
|
||||||
"loudness": loudness,
|
"loudness": loudness,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||||
|
|
||||||
|
from core.tasks import spawn
|
||||||
from core.config import _load_settings
|
from core.config import _load_settings
|
||||||
from core.constants import (
|
from core.constants import (
|
||||||
_WHISPER_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT,
|
_WHISPER_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT,
|
||||||
@ -579,7 +580,7 @@ async def transcribe(request: Request):
|
|||||||
}
|
}
|
||||||
background_job_create(job_id, "transcription", created=now)
|
background_job_create(job_id, "transcription", created=now)
|
||||||
_stt_jobs_trim()
|
_stt_jobs_trim()
|
||||||
asyncio.create_task(_run_stt_job(job_id, src, settings, backend))
|
spawn(_run_stt_job(job_id, src, settings, backend))
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
try:
|
try:
|
||||||
text, used_backend = await _bounded_stt_call(_transcribe_audio, src, settings, backend)
|
text, used_backend = await _bounded_stt_call(_transcribe_audio, src, settings, backend)
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import requests
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
|
from core.tasks import spawn
|
||||||
from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
|
from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
|
||||||
from core.constants import (
|
from core.constants import (
|
||||||
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
|
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
|
||||||
@ -958,7 +959,7 @@ async def voice_design(request: Request):
|
|||||||
}
|
}
|
||||||
background_job_create(job_id, "voice_design", created=_VOICE_DESIGN_JOBS[job_id]["created"])
|
background_job_create(job_id, "voice_design", created=_VOICE_DESIGN_JOBS[job_id]["created"])
|
||||||
_voice_design_job_trim()
|
_voice_design_job_trim()
|
||||||
asyncio.create_task(_run_voice_design_job(job_id, job_data, idempotency_key, request_fingerprint))
|
spawn(_run_voice_design_job(job_id, job_data, idempotency_key, request_fingerprint))
|
||||||
return {"job_id": job_id, "stage": "queued"}
|
return {"job_id": job_id, "stage": "queued"}
|
||||||
return await _voice_design_execute(data, idempotency_key, request_fingerprint)
|
return await _voice_design_execute(data, idempotency_key, request_fingerprint)
|
||||||
|
|
||||||
|
|||||||
@ -408,7 +408,7 @@ class EndpointTests(unittest.TestCase):
|
|||||||
async def fake_task(coro):
|
async def fake_task(coro):
|
||||||
created.append(coro)
|
created.append(coro)
|
||||||
with mock.patch.object(self.routes, "_load_settings", return_value=settings), \
|
with mock.patch.object(self.routes, "_load_settings", return_value=settings), \
|
||||||
mock.patch.object(self.routes.asyncio, "create_task", side_effect=lambda c: (c.close(), created.append(1))[1]):
|
mock.patch.object(self.routes.asyncio, "create_task", side_effect=lambda c: (c.close(), created.append(1), mock.MagicMock())[2]):
|
||||||
return self.client.post(f"/api/magazine/{self.doc}/pages/{page}/rotate", json={"degrees": degrees}), created
|
return self.client.post(f"/api/magazine/{self.doc}/pages/{page}/rotate", json={"degrees": degrees}), created
|
||||||
|
|
||||||
def test_a_valid_request_starts_a_job(self):
|
def test_a_valid_request_starts_a_job(self):
|
||||||
|
|||||||
37
tests/test_tasks.py
Normal file
37
tests/test_tasks.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import asyncio
|
||||||
|
import gc
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from core import tasks
|
||||||
|
|
||||||
|
|
||||||
|
class SpawnTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_survives_gc_and_set_emptied(self):
|
||||||
|
done = asyncio.Event()
|
||||||
|
|
||||||
|
async def work():
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
tasks.spawn(work())
|
||||||
|
gc.collect()
|
||||||
|
self.assertEqual(len(tasks._BACKGROUND), 1)
|
||||||
|
await asyncio.wait_for(done.wait(), 1)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
self.assertEqual(len(tasks._BACKGROUND), 0)
|
||||||
|
|
||||||
|
async def test_exception_logged_without_message(self):
|
||||||
|
async def boom():
|
||||||
|
raise ValueError("secret-token")
|
||||||
|
|
||||||
|
with self.assertLogs("core.tasks", "ERROR") as cm:
|
||||||
|
tasks.spawn(boom())
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
self.assertIn("ValueError", cm.output[0])
|
||||||
|
self.assertNotIn("secret-token", cm.output[0])
|
||||||
|
self.assertEqual(len(tasks._BACKGROUND), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user