Spaces:
Runtime error
Runtime error
Download backend/app/worker.py from firebird-technologies/NoteStack-Test: direct link, hf CLI and curl.
- Browser
- Download file 20.6 kB
-
https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/52ec279e6108c8a40d96dbfbb4d5573a02472df3/backend/app/worker.py
- Command line
-
hf download hf://spaces/firebird-technologies/NoteStack-Test@52ec279e6108c8a40d96dbfbb4d5573a02472df3/backend/app/worker.py
-
curl -L -o worker.py https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/52ec279e6108c8a40d96dbfbb4d5573a02472df3/backend/app/worker.py
20.6 kB
| """Worker: runs queued jobs from the Postgres jobs table plus scheduled email batches. | |
| Run: python -m app.worker | |
| - Claims jobs with FOR UPDATE SKIP LOCKED, runs up to WORKER_CONCURRENCY at once in threads. | |
| - Failed jobs retry with backoff up to max_attempts; jobs whose worker died (no heartbeat for | |
| JOB_STALE_SECONDS) are requeued. | |
| - Periodic tasks take a Postgres advisory lock, so with several workers only one runs each tick. | |
| """ | |
| import logging | |
| import os | |
| import signal | |
| import socket | |
| import threading | |
| import time | |
| import uuid | |
| from collections.abc import Callable | |
| from concurrent.futures import Future, ThreadPoolExecutor | |
| from contextlib import contextmanager | |
| from dataclasses import dataclass, field | |
| from datetime import UTC, datetime | |
| import httpx | |
| from sqlalchemy import select, text | |
| from sqlalchemy.orm import Session | |
| from app.chat_memory.organize import forget_orphans, organize_chat, rebuild_chat | |
| from app.config import settings | |
| from app.db import SessionLocal, engine | |
| from app.models import ( | |
| Artifact, | |
| Chat, | |
| Job, | |
| Message, | |
| Source, | |
| Subscription, | |
| UpdateEmail, | |
| UpdateEmailSend, | |
| Upload, | |
| User, | |
| VoiceConsent, | |
| VoiceProfile, | |
| Workspace, | |
| WorkspaceMember, | |
| ) | |
| from app.pipeline import generate, launchkit, media | |
| from app.pipeline.generate import NothingToDo | |
| from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries | |
| from app.pipeline.memory import learn_from_message | |
| from app.services import launchpad, tts | |
| from app.services.email import email_service | |
| from app.services.email_verification import purge_old_codes | |
| from app.services.jobs import claim_next, create_job, record_usage, retry_or_fail, stale_jobs, update_job | |
| from app.services.plans import effective_plan | |
| from app.services.renderer import PermanentJobError, request_render | |
| from app.services.storage import keys, storage | |
| log = logging.getLogger("notestack.worker") | |
| class Done: | |
| result: dict = field(default_factory=dict) | |
| message: str = "Done" | |
| HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back via callbacks | |
| # Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry. | |
| def _pending(db: Session, workspace_id: uuid.UUID, kind: str) -> bool: | |
| return db.scalar(select(Job.id).where(Job.workspace_id == workspace_id, Job.kind == kind, | |
| Job.status.in_(("queued", "running"))).limit(1)) is not None | |
| def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None: | |
| """New or changed posts are mapped into the topic constellation and scored for resurfacing, and the | |
| first import builds the writing voice, so none of these need a button press.""" | |
| if not changed or not settings.llm_api_key: | |
| return | |
| create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2) | |
| create_job(db, workspace_id, "ideas", {"document_ids": changed}, max_attempts=2) | |
| if not _pending(db, workspace_id, "resurface_scan"): | |
| create_job(db, workspace_id, "resurface_scan", {}, max_attempts=2) | |
| vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id)) | |
| if not (vp and vp.profile_json) and not _pending(db, workspace_id, "voice_profile"): | |
| create_job(db, workspace_id, "voice_profile", {"document_ids": []}, max_attempts=2) | |
| def handle_ingest(db: Session, job: Job): | |
| source = db.get(Source, uuid.UUID(job.params["source_id"])) | |
| if not source: | |
| return Done({"error": "source deleted"}, "Source no longer exists") | |
| plan = effective_plan(db, db.get(Workspace, source.workspace_id)) | |
| try: | |
| result = ingest_source(db, source, job, max_posts=plan.indexed_posts) | |
| except Exception as exc: | |
| db.rollback() | |
| source.sync_status = "error" | |
| source.sync_error = str(exc)[:500] | |
| db.commit() | |
| if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (401, 403, 404, 410): | |
| raise PermanentJobError(f"The feed returned {exc.response.status_code}.") from exc | |
| if isinstance(exc, FeedNotFound): | |
| raise PermanentJobError(str(exc)) from exc | |
| raise | |
| _after_import(db, job, source.workspace_id, result.get("changed", [])) | |
| if result.get("locked"): | |
| return Done(result, f"Found {result['found']} posts. Your latest {result['available']} are indexed; " | |
| f"upgrade to index the other {result['locked']}") | |
| return Done(result, f"All posts in orbit: {result['indexed']} new or updated") | |
| def handle_memory(db: Session, job: Job): | |
| """Decide whether the writer's chat message holds a lasting note, and update their memory.""" | |
| msg = db.get(Message, uuid.UUID(job.params["message_id"])) | |
| chat = db.get(Chat, msg.chat_id) if msg else None | |
| if not msg or msg.role != "user" or not chat or chat.workspace_id != job.workspace_id: | |
| return Done({"saved": []}, "Message no longer exists") | |
| result = learn_from_message(db, job, job.workspace_id, msg.content) | |
| return Done(result, "Notes updated" if result["saved"] else "Nothing to save") | |
| def handle_chat_memory(db: Session, job: Job): | |
| """File a chat's new rounds under topics (summary, keywords, exact quotes) in the notebook's chat memory.""" | |
| chat = db.get(Chat, uuid.UUID(job.params["chat_id"])) | |
| if not chat or chat.workspace_id != job.workspace_id: | |
| return Done({"rounds": 0}, "Chat no longer exists") | |
| result = organize_chat(db, job, chat) | |
| return Done(result, f"Filed {result['rounds']} rounds" if result["rounds"] else "Nothing new to file") | |
| def handle_chat_memory_rebuild(db: Session, job: Job): | |
| """Rebuild chat memory from the messages. Params: notebook_id, and optionally chat_id for just one chat.""" | |
| notebook_id = uuid.UUID(job.params["notebook_id"]) | |
| chats = db.scalars(select(Chat).where(Chat.notebook_id == notebook_id, Chat.workspace_id == job.workspace_id)) | |
| only = job.params.get("chat_id") | |
| done = [rebuild_chat(db, job, c) for c in chats if not only or str(c.id) == only] | |
| return Done({"chats": len(done), "rounds": sum(d["rounds"] for d in done)}, f"Rebuilt {len(done)} chats") | |
| def handle_chat_memory_cleanup(db: Session, job: Job): | |
| """Remove chat memory whose chat is gone. Params: notebook_id.""" | |
| removed = forget_orphans(db, job.workspace_id, uuid.UUID(job.params["notebook_id"])) | |
| return Done({"removed": removed}, f"Removed {removed} orphaned chats") | |
| def handle_import_url(db: Session, job: Job): | |
| source = db.get(Source, uuid.UUID(job.params["source_id"])) | |
| update_job(db, job, progress=0.1, message="Fetching the article") | |
| try: | |
| entry = extract_article(job.params["url"]) | |
| except FeedNotFound as exc: | |
| raise PermanentJobError(str(exc)) from exc | |
| indexed, _, changed = store_entries(db, source, [entry]) | |
| _after_import(db, job, source.workspace_id, [str(i) for i in changed]) | |
| return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit") | |
| def handle_import_upload(db: Session, job: Job): | |
| source = db.get(Source, uuid.UUID(job.params["source_id"])) | |
| upload = db.get(Upload, uuid.UUID(job.params["upload_id"])) | |
| update_job(db, job, progress=0.1, message=f"Reading {upload.filename}") | |
| entry = entry_from_upload(upload.filename, upload.content_type, storage.get_bytes(upload.key)) | |
| if not entry.sections and not entry.html: | |
| raise PermanentJobError("We could not read any text in that file.") | |
| indexed, _, changed = store_entries(db, source, [entry]) | |
| _after_import(db, job, source.workspace_id, [str(i) for i in changed]) | |
| return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit") | |
| def handle_topics(db: Session, job: Job): | |
| return Done(generate.extract_topics(db, job, job.workspace_id, job.params.get("document_ids")), "Topic map ready") | |
| def handle_ideas(db: Session, job: Job): | |
| result = generate.extract_ideas(db, job, job.workspace_id, job.params.get("document_ids")) | |
| return Done(result, f"Read {result['extracted']} posts for their ideas") | |
| def handle_voice_profile(db: Session, job: Job): | |
| try: | |
| result = generate.build_voice_profile(db, job, job.workspace_id, job.params.get("document_ids") or []) | |
| except NothingToDo as exc: | |
| raise PermanentJobError(str(exc)) from exc | |
| return Done(result, "Voice profile ready") | |
| def handle_voice_clone(db: Session, job: Job): | |
| consent = db.get(VoiceConsent, uuid.UUID(job.params["consent_id"])) | |
| if not consent or consent.revoked_at: | |
| return Done({}, "Consent was revoked") | |
| sample_keys = job.params.get("sample_keys") or [consent.sample_key] | |
| update_job(db, job, progress=0.15, message=f"Sending {len(sample_keys)} recording(s) to ElevenLabs") | |
| samples = [] | |
| for key in sample_keys: | |
| head = storage.head(key) or {} | |
| samples.append((key.rsplit("/", 1)[-1], storage.get_bytes(key), head.get("ContentType") or "audio/webm")) | |
| owner = db.get(User, consent.user_id) | |
| try: | |
| voice_id = tts.clone_voice(f"{(owner.name if owner and owner.name else 'My')} (Notestack)", samples, | |
| remove_background_noise=bool(job.params.get("remove_background_noise", True))) | |
| except tts.TTSError as exc: | |
| raise PermanentJobError(str(exc)) from exc | |
| consent.elevenlabs_voice_id = voice_id | |
| db.commit() | |
| # Make the new voice host A and record a short preview so the writer can judge it straight away. | |
| update_job(db, job, progress=0.7, message="Recording a preview in your voice") | |
| vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == consent.workspace_id)) | |
| if not vp: | |
| vp = VoiceProfile(workspace_id=consent.workspace_id) | |
| db.add(vp) | |
| vp.host_voices = {**(vp.host_voices or {}), "host_a": voice_id} | |
| db.commit() | |
| preview_text = job.params.get("preview_text") or ( | |
| "Hi, this is my Notestack voice. From now on, my audio overviews and videos can sound like me.") | |
| preview_key = None | |
| try: | |
| clip = tts.synthesize(consent.workspace_id, preview_text[:600], voice_id) | |
| preview_key = storage.put_bytes(keys.artifact(consent.workspace_id, consent.id, "voice-preview", "mp3"), | |
| clip, "audio/mpeg") | |
| record_usage(db, workspace_id=consent.workspace_id, kind="tts", provider="elevenlabs", | |
| quantity=round(tts.mp3_seconds(clip), 1), unit="seconds", job=job) | |
| except tts.TTSError: | |
| log.warning("voice preview failed for %s", voice_id, exc_info=True) | |
| return Done({"voice_id": voice_id, "preview_key": preview_key}, "Your voice is ready and set as host A") | |
| def handle_resurface(db: Session, job: Job): | |
| return Done(generate.score_evergreen(db, job, job.workspace_id), "Evergreen scores updated") | |
| def _artifact_handler(fn: Callable[[Session, Job, Artifact], object], done_message: str): | |
| def handler(db: Session, job: Job): | |
| artifact = db.get(Artifact, uuid.UUID(job.params["artifact_id"])) | |
| if not artifact: | |
| return Done({}, "Artifact was deleted") | |
| artifact.status = "generating" | |
| db.commit() | |
| try: | |
| result = fn(db, job, artifact) | |
| except (NothingToDo, tts.TTSError) as exc: | |
| raise PermanentJobError(str(exc)) from exc | |
| if result is None: | |
| return HANDED_OFF # the renderer reports back through /api/internal/jobs/{id}/progress | |
| return Done(result if isinstance(result, dict) else {}, done_message) | |
| return handler | |
| def handle_render(db: Session, job: Job): | |
| """Direct render of caller supplied props (POST /api/artifacts/{id}/render).""" | |
| artifact = db.get(Artifact, uuid.UUID(job.params["artifact_id"])) | |
| update_job(db, job, progress=0.01, message="T-minus: preparing render") | |
| request_render(job, artifact, job.params["composition"], job.params.get("props") or {}) | |
| return HANDED_OFF | |
| HANDLERS: dict[str, Callable[[Session, Job], object]] = { | |
| "ingest": handle_ingest, | |
| "import_url": handle_import_url, | |
| "import_upload": handle_import_upload, | |
| "topics": handle_topics, | |
| "ideas": handle_ideas, | |
| "memory_update": handle_memory, | |
| "chat_memory": handle_chat_memory, | |
| "chat_memory_rebuild": handle_chat_memory_rebuild, | |
| "chat_memory_cleanup": handle_chat_memory_cleanup, | |
| "voice_profile": handle_voice_profile, | |
| "voice_clone": handle_voice_clone, | |
| "resurface_scan": handle_resurface, | |
| "summary": _artifact_handler(generate.summarize_notebook, "Summary ready"), | |
| "mind_map": _artifact_handler(generate.build_mind_map, "Mind Constellation ready"), | |
| "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"), | |
| "video": _artifact_handler(media.make_video, "Video ready"), | |
| "quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"), | |
| "carousel": _artifact_handler(media.render_carousel, "Carousel ready"), | |
| "launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"), | |
| "render": handle_render, | |
| } | |
| def mark_artifact_failed(db: Session, job: Job, error: str) -> None: | |
| if job.artifact_id: | |
| artifact = db.get(Artifact, job.artifact_id) | |
| if artifact: | |
| artifact.status = "failed" | |
| artifact.content_json = {**(artifact.content_json or {}), "error": error[:500]} | |
| db.commit() | |
| def run_job( | |
| job_id: uuid.UUID, | |
| session_factory: Callable[[], Session] = SessionLocal, | |
| handlers: dict | None = None, | |
| ) -> None: | |
| handlers = handlers or HANDLERS | |
| with session_factory() as db: | |
| job = db.get(Job, job_id) | |
| if not job: | |
| return | |
| handler = handlers.get(job.kind) | |
| if not handler: | |
| update_job(db, job, status="failed", error=f"No handler for job kind {job.kind!r}") | |
| return | |
| try: | |
| outcome = handler(db, job) | |
| except PermanentJobError as exc: | |
| db.rollback() | |
| job = db.get(Job, job_id) | |
| update_job(db, job, status="failed", error=str(exc), message=str(exc)[:500]) | |
| mark_artifact_failed(db, job, str(exc)) | |
| return | |
| except Exception as exc: | |
| log.exception("job %s (%s) attempt %s failed", job.id, job.kind, job.attempts) | |
| db.rollback() | |
| job = db.get(Job, job_id) | |
| if not retry_or_fail(db, job, f"{type(exc).__name__}: {exc}"): | |
| mark_artifact_failed(db, job, f"{type(exc).__name__}: {exc}") | |
| return | |
| if outcome is HANDED_OFF: | |
| return | |
| done = outcome if isinstance(outcome, Done) else Done() | |
| update_job(db, job, status="done", progress=1.0, message=done.message, result=done.result) | |
| def recover_stale(session_factory: Callable[[], Session] = SessionLocal) -> int: | |
| with session_factory() as db: | |
| jobs = stale_jobs(db) | |
| for job in jobs: | |
| log.warning("job %s stale (worker %s), requeueing", job.id, job.locked_by) | |
| retry_or_fail(db, job, "Worker stopped responding") | |
| db.commit() | |
| return len(jobs) | |
| # Periodic tasks | |
| def single_flight(name: str): | |
| """Postgres advisory lock on a dedicated connection; yields False if another worker holds it.""" | |
| if engine.dialect.name != "postgresql": | |
| yield True | |
| return | |
| with engine.connect() as conn: | |
| got = conn.execute(text("SELECT pg_try_advisory_lock(hashtext(:n))"), {"n": name}).scalar() | |
| try: | |
| yield bool(got) | |
| finally: | |
| if got: | |
| conn.execute(text("SELECT pg_advisory_unlock(hashtext(:n))"), {"n": name}) | |
| conn.commit() | |
| def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) -> None: | |
| """Sends one daily batch per scheduled campaign at its send hour (UTC).""" | |
| now = datetime.now(UTC) | |
| with session_factory() as db: | |
| campaigns = db.scalars(select(UpdateEmail).where(UpdateEmail.status.in_(["scheduled", "running"]))).all() | |
| for campaign in campaigns: | |
| hour = campaign.send_hour if campaign.send_hour >= 0 else settings.update_email_send_hour | |
| if now.hour != hour: | |
| continue | |
| if campaign.last_batch_at and campaign.last_batch_at.date() == now.date(): | |
| continue | |
| already = select(UpdateEmailSend.user_id).where(UpdateEmailSend.update_email_id == campaign.id) | |
| q = select(User).where( | |
| User.is_active.is_(True), User.email_unsubscribed.is_(False), User.id.not_in(already) | |
| ) | |
| if campaign.user_filter != "all": | |
| plans = {"paid": ["writer", "studio"]}.get(campaign.user_filter, [campaign.user_filter]) | |
| q = ( | |
| q.join(WorkspaceMember, WorkspaceMember.user_id == User.id) | |
| .join(Subscription, Subscription.workspace_id == WorkspaceMember.workspace_id) | |
| .where(Subscription.plan.in_(plans)) | |
| ) | |
| users = db.scalars(q.limit(campaign.batch_size)).unique().all() | |
| campaign.status = "running" | |
| for user in users: | |
| ok = email_service.send_blast_email( | |
| str(user.id), user.email, user.name, campaign.subject, campaign.body | |
| ) | |
| db.add(UpdateEmailSend(update_email_id=campaign.id, user_id=user.id, status="sent" if ok else "failed")) | |
| campaign.sent_count += int(ok) | |
| campaign.failed_count += int(not ok) | |
| campaign.total_users = campaign.sent_count + campaign.failed_count | |
| campaign.last_batch_at = now | |
| if len(users) < campaign.batch_size: | |
| campaign.status = "completed" | |
| db.commit() | |
| def daily_cleanup(session_factory: Callable[[], Session] = SessionLocal) -> None: | |
| with session_factory() as db: | |
| purge_old_codes(db) | |
| class Periodic: | |
| name: str | |
| every_seconds: float | |
| fn: Callable[[], object] | |
| next_at: float = 0.0 | |
| PERIODIC = [ | |
| Periodic("recover_stale", 60, recover_stale), | |
| Periodic("publish_due", 30, launchpad.publish_due), | |
| Periodic("sync_engagement", 3600, launchpad.sync_engagement), | |
| Periodic("update_email_batch", 300, update_email_batch), | |
| Periodic("daily_cleanup", 24 * 3600, daily_cleanup), | |
| ] | |
| def run_periodic(now: float) -> None: | |
| for task in PERIODIC: | |
| if now < task.next_at: | |
| continue | |
| task.next_at = now + task.every_seconds | |
| try: | |
| with single_flight(task.name) as got: | |
| if got: | |
| task.fn() | |
| except Exception: | |
| log.exception("periodic task %s failed", task.name) | |
| # Main loop | |
| def run_loop(stop: threading.Event, worker_id: str | None = None) -> None: | |
| """Claim and run jobs until `stop` is set. Used by the CLI and, in development, by the API.""" | |
| worker_id = worker_id or f"{socket.gethostname()}:{os.getpid()}" | |
| pool = ThreadPoolExecutor(max_workers=settings.worker_concurrency, thread_name_prefix="job") | |
| running: set[Future] = set() | |
| log.info("worker %s up, concurrency=%s", worker_id, settings.worker_concurrency) | |
| while not stop.is_set(): | |
| running = {f for f in running if not f.done()} | |
| run_periodic(time.monotonic()) | |
| claimed = False | |
| while len(running) < settings.worker_concurrency and not stop.is_set(): | |
| try: | |
| with SessionLocal() as db: | |
| job_id = claim_next(db, worker_id) | |
| except Exception: | |
| log.exception("claim failed") | |
| break | |
| if not job_id: | |
| break | |
| running.add(pool.submit(run_job, job_id)) | |
| claimed = True | |
| if not claimed: | |
| stop.wait(settings.worker_poll_seconds) | |
| log.info("worker %s stopping, waiting for %d running jobs", worker_id, len(running)) | |
| pool.shutdown(wait=True) | |
| def start_background(stop: threading.Event) -> threading.Thread: | |
| thread = threading.Thread(target=run_loop, args=(stop, f"api:{os.getpid()}"), name="worker", daemon=True) | |
| thread.start() | |
| return thread | |
| def main() -> None: | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") | |
| stop = threading.Event() | |
| for sig in (signal.SIGINT, signal.SIGTERM): | |
| signal.signal(sig, lambda *_: stop.set()) | |
| run_loop(stop) | |
| if __name__ == "__main__": | |
| main() | |