Arslan1997 commited on
Commit
7299acc
·
1 Parent(s): e5d90fd

added email

Browse files
.env.example CHANGED
@@ -42,5 +42,10 @@ LLM_REASONING_EFFORT=low
42
  CORPUS_CACHE_DIR=.corpus-cache
43
  RESEARCH_MAX_STEPS=10
44
 
 
 
 
 
 
45
  # ---- ElevenLabs (Phase 1 audio)
46
  ELEVENLABS_API_KEY=
 
42
  CORPUS_CACHE_DIR=.corpus-cache
43
  RESEARCH_MAX_STEPS=10
44
 
45
+ # ---- Worker (jobs queue lives in Postgres; no Redis)
46
+ WORKER_CONCURRENCY=4
47
+ WORKER_POLL_SECONDS=1
48
+ JOB_STALE_SECONDS=900
49
+
50
  # ---- ElevenLabs (Phase 1 audio)
51
  ELEVENLABS_API_KEY=
.github/workflows/ci.yml CHANGED
@@ -5,6 +5,14 @@ jobs:
5
  backend:
6
  runs-on: ubuntu-latest
7
  defaults: { run: { working-directory: backend } }
 
 
 
 
 
 
 
 
8
  steps:
9
  - uses: actions/checkout@v4
10
  - uses: actions/setup-python@v5
 
5
  backend:
6
  runs-on: ubuntu-latest
7
  defaults: { run: { working-directory: backend } }
8
+ services:
9
+ postgres:
10
+ image: postgres:16-alpine
11
+ env: { POSTGRES_USER: notestack, POSTGRES_PASSWORD: notestack, POSTGRES_DB: notestack_test }
12
+ ports: ["5432:5432"]
13
+ options: --health-cmd "pg_isready -U notestack" --health-interval 5s --health-retries 10
14
+ env:
15
+ TEST_POSTGRES_URL: postgresql+psycopg://notestack:notestack@localhost:5432/notestack_test
16
  steps:
17
  - uses: actions/checkout@v4
18
  - uses: actions/setup-python@v5
README.md CHANGED
@@ -9,7 +9,7 @@ overviews, videos and launch kits, all traced back to what the writer actually w
9
  ## Layout
10
 
11
  ```
12
- backend/ FastAPI + SQLAlchemy + Alembic, arq worker, DSPy (LLM agnostic, Z.ai first)
13
  frontend/ React + Vite + TS: night sky landing, pricing, blog, auth, Mission Control
14
  renderer/ Remotion compositions + render service that uploads to R2 via presigned PUT
15
  docs/ Design doc and task lists
@@ -31,8 +31,8 @@ Without Docker:
31
 
32
  ```bash
33
  cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
34
- alembic upgrade head && uvicorn app.main:app --reload # needs Postgres + Redis
35
- arq app.worker.WorkerSettings # second terminal
36
  cd frontend && npm install && npm run dev
37
  cd renderer && npm install && npm start # or: npm run studio
38
  ```
 
9
  ## Layout
10
 
11
  ```
12
+ backend/ FastAPI + SQLAlchemy + Alembic, Postgres job queue worker, DSPy (LLM agnostic, Z.ai first)
13
  frontend/ React + Vite + TS: night sky landing, pricing, blog, auth, Mission Control
14
  renderer/ Remotion compositions + render service that uploads to R2 via presigned PUT
15
  docs/ Design doc and task lists
 
31
 
32
  ```bash
33
  cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
34
+ alembic upgrade head && uvicorn app.main:app --reload # needs Postgres only
35
+ python -m app.worker # second terminal
36
  cd frontend && npm install && npm run dev
37
  cd renderer && npm install && npm start # or: npm run studio
38
  ```
backend/app/config.py CHANGED
@@ -3,7 +3,6 @@ from pathlib import Path
3
 
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
5
 
6
-
7
  # One .env at the repo root serves every way of running the API; a backend/.env (or the
8
  # working directory's .env) overrides it. Real environment variables beat both.
9
  _ROOT_ENV = Path(__file__).resolve().parents[2] / ".env"
@@ -19,7 +18,11 @@ class Settings(BaseSettings):
19
  cors_origins: str = "http://localhost:5173"
20
 
21
  database_url: str = "postgresql+psycopg://notestack:notestack@localhost:5432/notestack"
22
- redis_url: str = "redis://localhost:6379/0"
 
 
 
 
23
 
24
  # Auth
25
  jwt_secret: str = "change-me"
 
3
 
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
5
 
 
6
  # One .env at the repo root serves every way of running the API; a backend/.env (or the
7
  # working directory's .env) overrides it. Real environment variables beat both.
8
  _ROOT_ENV = Path(__file__).resolve().parents[2] / ".env"
 
18
  cors_origins: str = "http://localhost:5173"
19
 
20
  database_url: str = "postgresql+psycopg://notestack:notestack@localhost:5432/notestack"
21
+
22
+ # Worker (Postgres backed queue, see app/worker.py)
23
+ worker_concurrency: int = 4
24
+ worker_poll_seconds: float = 1.0
25
+ job_stale_seconds: int = 900 # a running job with no heartbeat for this long is retried
26
 
27
  # Auth
28
  jwt_secret: str = "change-me"
backend/app/main.py CHANGED
@@ -3,8 +3,6 @@ import time
3
  import uuid
4
  from contextlib import asynccontextmanager
5
 
6
- from arq import create_pool
7
- from arq.connections import RedisSettings
8
  from fastapi import FastAPI, Request
9
  from fastapi.middleware.cors import CORSMiddleware
10
 
@@ -19,7 +17,6 @@ log = logging.getLogger("notestack")
19
  async def lifespan(app: FastAPI):
20
  logging.basicConfig(level=logging.INFO)
21
  configure_default()
22
- app.state.arq = await create_pool(RedisSettings.from_dsn(settings.redis_url))
23
  if settings.env == "development" and settings.r2_endpoint_url:
24
  try:
25
  from app.services.storage import storage as store
@@ -28,7 +25,6 @@ async def lifespan(app: FastAPI):
28
  except Exception:
29
  log.warning("could not ensure local bucket", exc_info=True)
30
  yield
31
- await app.state.arq.aclose()
32
 
33
 
34
  app = FastAPI(title="Notestack API", version="0.1.0", lifespan=lifespan)
 
3
  import uuid
4
  from contextlib import asynccontextmanager
5
 
 
 
6
  from fastapi import FastAPI, Request
7
  from fastapi.middleware.cors import CORSMiddleware
8
 
 
17
  async def lifespan(app: FastAPI):
18
  logging.basicConfig(level=logging.INFO)
19
  configure_default()
 
20
  if settings.env == "development" and settings.r2_endpoint_url:
21
  try:
22
  from app.services.storage import storage as store
 
25
  except Exception:
26
  log.warning("could not ensure local bucket", exc_info=True)
27
  yield
 
28
 
29
 
30
  app = FastAPI(title="Notestack API", version="0.1.0", lifespan=lifespan)
backend/app/models/content.py CHANGED
@@ -8,6 +8,7 @@ from sqlalchemy import (
8
  DateTime,
9
  Float,
10
  ForeignKey,
 
11
  Integer,
12
  String,
13
  Text,
@@ -142,9 +143,14 @@ class Artifact(IdMixin, TimestampMixin, Base):
142
 
143
 
144
  class Job(IdMixin, TimestampMixin, Base):
145
- """Covers ingest, tts and render jobs. kind decides which fields matter."""
 
 
 
 
146
 
147
  __tablename__ = "jobs"
 
148
 
149
  workspace_id: Mapped[uuid.UUID] = _ws_fk()
150
  kind: Mapped[str] = mapped_column(String(30)) # ingest | tts | render | generate
@@ -158,6 +164,13 @@ class Job(IdMixin, TimestampMixin, Base):
158
  cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
159
  finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
160
 
 
 
 
 
 
 
 
161
 
162
  class Upload(IdMixin, TimestampMixin, Base):
163
  __tablename__ = "uploads"
 
8
  DateTime,
9
  Float,
10
  ForeignKey,
11
+ Index,
12
  Integer,
13
  String,
14
  Text,
 
143
 
144
 
145
  class Job(IdMixin, TimestampMixin, Base):
146
+ """Covers ingest, tts and render jobs. kind decides which fields matter.
147
+
148
+ This table is also the queue: workers claim queued rows with FOR UPDATE SKIP LOCKED
149
+ (see app/worker.py), so no separate broker is needed.
150
+ """
151
 
152
  __tablename__ = "jobs"
153
+ __table_args__ = (Index("ix_jobs_queue", "status", "run_after", "created_at"),)
154
 
155
  workspace_id: Mapped[uuid.UUID] = _ws_fk()
156
  kind: Mapped[str] = mapped_column(String(30)) # ingest | tts | render | generate
 
164
  cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
165
  finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
166
 
167
+ # Queue bookkeeping
168
+ attempts: Mapped[int] = mapped_column(Integer, default=0)
169
+ max_attempts: Mapped[int] = mapped_column(Integer, default=3)
170
+ run_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # retry backoff
171
+ locked_by: Mapped[str | None] = mapped_column(String(100)) # worker id while running
172
+ heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # stale detection
173
+
174
 
175
  class Upload(IdMixin, TimestampMixin, Base):
176
  __tablename__ = "uploads"
backend/app/routers/artifacts.py CHANGED
@@ -1,7 +1,7 @@
1
  import uuid
2
  from typing import Literal
3
 
4
- from fastapi import APIRouter, Depends, HTTPException, Request
5
  from pydantic import BaseModel
6
  from sqlalchemy import select
7
 
@@ -66,12 +66,15 @@ def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
66
 
67
 
68
  @router.post("/{artifact_id}/render")
69
- async def render_artifact(artifact_id: uuid.UUID, body: RenderIn, request: Request, ctx: Ctx = Depends(get_ctx)):
70
  a = _get(ctx, artifact_id)
71
- fmt = COMPOSITIONS[body.composition]
72
- job = create_job(ctx.db, ctx.workspace.id, "render", {"composition": body.composition})
73
- job.artifact_id = a.id
74
  a.status = "rendering"
75
- ctx.db.commit()
76
- await request.app.state.arq.enqueue_job("render_task", str(a.id), str(job.id), body.composition, body.props, fmt)
 
 
 
 
 
 
77
  return serialize_job(job)
 
1
  import uuid
2
  from typing import Literal
3
 
4
+ from fastapi import APIRouter, Depends, HTTPException
5
  from pydantic import BaseModel
6
  from sqlalchemy import select
7
 
 
66
 
67
 
68
  @router.post("/{artifact_id}/render")
69
+ def render_artifact(artifact_id: uuid.UUID, body: RenderIn, ctx: Ctx = Depends(get_ctx)):
70
  a = _get(ctx, artifact_id)
 
 
 
71
  a.status = "rendering"
72
+ job = create_job(
73
+ ctx.db,
74
+ ctx.workspace.id,
75
+ "render",
76
+ {"artifact_id": str(a.id), "composition": body.composition, "props": body.props,
77
+ "format": COMPOSITIONS[body.composition]},
78
+ artifact_id=a.id,
79
+ )
80
  return serialize_job(job)
backend/app/routers/jobs.py CHANGED
@@ -1,4 +1,3 @@
1
- import json
2
  import uuid
3
 
4
  from fastapi import APIRouter, Depends, Header, HTTPException
@@ -9,9 +8,9 @@ from sqlalchemy.orm import Session
9
 
10
  from app.auth import Ctx, get_ctx
11
  from app.config import settings
12
- from app.db import get_db
13
  from app.models import Artifact, Job
14
- from app.services.jobs import record_usage, serialize_job, subscribe, update_job
15
 
16
  router = APIRouter(prefix="/api", tags=["jobs"])
17
 
@@ -30,17 +29,11 @@ def get_job(job_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
30
 
31
  @router.get("/jobs/{job_id}/events")
32
  async def job_events(job_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
33
- job = _get(ctx, job_id)
34
- snapshot = serialize_job(job)
35
 
36
  async def stream():
37
- yield f"data: {json.dumps(snapshot)}\n\n"
38
- if snapshot["status"] in {"done", "failed"}:
39
- return
40
- async for data in subscribe(job_id):
41
  yield f"data: {data}\n\n"
42
- if json.loads(data).get("status") in {"done", "failed"}:
43
- return
44
 
45
  return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
46
 
 
 
1
  import uuid
2
 
3
  from fastapi import APIRouter, Depends, Header, HTTPException
 
8
 
9
  from app.auth import Ctx, get_ctx
10
  from app.config import settings
11
+ from app.db import SessionLocal, get_db
12
  from app.models import Artifact, Job
13
+ from app.services.jobs import record_usage, serialize_job, update_job, watch_job
14
 
15
  router = APIRouter(prefix="/api", tags=["jobs"])
16
 
 
29
 
30
  @router.get("/jobs/{job_id}/events")
31
  async def job_events(job_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
32
+ _get(ctx, job_id) # ownership check
 
33
 
34
  async def stream():
35
+ async for data in watch_job(SessionLocal, job_id):
 
 
 
36
  yield f"data: {data}\n\n"
 
 
37
 
38
  return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
39
 
backend/app/routers/sources.py CHANGED
@@ -1,6 +1,6 @@
1
  import uuid
2
 
3
- from fastapi import APIRouter, Depends, HTTPException, Request
4
  from pydantic import BaseModel
5
  from sqlalchemy import func, select
6
 
@@ -30,20 +30,18 @@ def _serialize(source: Source, doc_count: int = 0) -> dict:
30
  }
31
 
32
 
33
- async def _enqueue_sync(request: Request, ctx: Ctx, source: Source) -> Job:
34
- job = create_job(ctx.db, ctx.workspace.id, "ingest", {"source_id": str(source.id)})
35
- await request.app.state.arq.enqueue_job("ingest_source_task", str(source.id), str(job.id))
36
- return job
37
 
38
 
39
  @router.post("")
40
- async def create_source(body: SourceIn, request: Request, ctx: Ctx = Depends(get_ctx)):
41
  feed_url, platform = normalize_feed_url(body.url)
42
  existing = ctx.db.scalar(
43
  select(Source).where(Source.workspace_id == ctx.workspace.id, Source.feed_url == feed_url)
44
  )
45
  if existing:
46
- job = await _enqueue_sync(request, ctx, existing)
47
  return {"source": _serialize(existing), "job": serialize_job(job)}
48
  plan = effective_plan(ctx.db, ctx.workspace)
49
  count = ctx.db.scalar(select(func.count()).select_from(Source).where(Source.workspace_id == ctx.workspace.id))
@@ -52,7 +50,7 @@ async def create_source(body: SourceIn, request: Request, ctx: Ctx = Depends(get
52
  source = Source(workspace_id=ctx.workspace.id, feed_url=feed_url, platform=platform, site_url=body.url)
53
  ctx.db.add(source)
54
  ctx.db.commit()
55
- job = await _enqueue_sync(request, ctx, source)
56
  return {"source": _serialize(source), "job": serialize_job(job)}
57
 
58
 
@@ -69,13 +67,13 @@ def list_sources(ctx: Ctx = Depends(get_ctx)):
69
 
70
 
71
  @router.get("/{source_id}/sync")
72
- async def sync_source(source_id: uuid.UUID, request: Request, ctx: Ctx = Depends(get_ctx)):
73
  source = ctx.db.scalar(
74
  select(Source).where(Source.id == source_id, Source.workspace_id == ctx.workspace.id)
75
  )
76
  if not source:
77
  raise HTTPException(404, "Source not found")
78
- job = await _enqueue_sync(request, ctx, source)
79
  return {"source": _serialize(source), "job": serialize_job(job)}
80
 
81
 
 
1
  import uuid
2
 
3
+ from fastapi import APIRouter, Depends, HTTPException
4
  from pydantic import BaseModel
5
  from sqlalchemy import func, select
6
 
 
30
  }
31
 
32
 
33
+ def _enqueue_sync(ctx: Ctx, source: Source) -> Job:
34
+ return create_job(ctx.db, ctx.workspace.id, "ingest", {"source_id": str(source.id)})
 
 
35
 
36
 
37
  @router.post("")
38
+ def create_source(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
39
  feed_url, platform = normalize_feed_url(body.url)
40
  existing = ctx.db.scalar(
41
  select(Source).where(Source.workspace_id == ctx.workspace.id, Source.feed_url == feed_url)
42
  )
43
  if existing:
44
+ job = _enqueue_sync(ctx, existing)
45
  return {"source": _serialize(existing), "job": serialize_job(job)}
46
  plan = effective_plan(ctx.db, ctx.workspace)
47
  count = ctx.db.scalar(select(func.count()).select_from(Source).where(Source.workspace_id == ctx.workspace.id))
 
50
  source = Source(workspace_id=ctx.workspace.id, feed_url=feed_url, platform=platform, site_url=body.url)
51
  ctx.db.add(source)
52
  ctx.db.commit()
53
+ job = _enqueue_sync(ctx, source)
54
  return {"source": _serialize(source), "job": serialize_job(job)}
55
 
56
 
 
67
 
68
 
69
  @router.get("/{source_id}/sync")
70
+ def sync_source(source_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
71
  source = ctx.db.scalar(
72
  select(Source).where(Source.id == source_id, Source.workspace_id == ctx.workspace.id)
73
  )
74
  if not source:
75
  raise HTTPException(404, "Source not found")
76
+ job = _enqueue_sync(ctx, source)
77
  return {"source": _serialize(source), "job": serialize_job(job)}
78
 
79
 
backend/app/services/jobs.py CHANGED
@@ -1,28 +1,28 @@
1
- """Job rows + progress fan-out over Redis pub/sub (consumed by the SSE endpoint)."""
2
 
 
 
 
 
 
 
3
  import json
4
  import uuid
5
- from datetime import UTC, datetime
 
6
 
7
- import redis
8
- import redis.asyncio as aredis
9
  from sqlalchemy.orm import Session
10
 
11
  from app.config import settings
12
  from app.models import Job, UsageEvent
13
 
14
- _sync_redis: redis.Redis | None = None
15
-
16
-
17
- def _redis() -> redis.Redis:
18
- global _sync_redis
19
- if _sync_redis is None:
20
- _sync_redis = redis.Redis.from_url(settings.redis_url)
21
- return _sync_redis
22
 
23
 
24
- def channel(job_id: uuid.UUID | str) -> str:
25
- return f"job:{job_id}"
26
 
27
 
28
  def serialize_job(job: Job) -> dict:
@@ -35,14 +35,34 @@ def serialize_job(job: Job) -> dict:
35
  "error": job.error,
36
  "result": job.result,
37
  "artifact_id": str(job.artifact_id) if job.artifact_id else None,
 
38
  "cost_usd": job.cost_usd,
39
  "created_at": job.created_at.isoformat() if job.created_at else None,
40
  "finished_at": job.finished_at.isoformat() if job.finished_at else None,
41
  }
42
 
43
 
44
- def create_job(db: Session, workspace_id: uuid.UUID, kind: str, params: dict | None = None) -> Job:
45
- job = Job(workspace_id=workspace_id, kind=kind, params=params or {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  db.add(job)
47
  db.commit()
48
  return job
@@ -58,10 +78,12 @@ def update_job(
58
  error: str | None = None,
59
  result: dict | None = None,
60
  ) -> None:
 
61
  if status:
62
  job.status = status
63
- if status in {"done", "failed"}:
64
- job.finished_at = datetime.now(UTC)
 
65
  if progress is not None:
66
  job.progress = max(0.0, min(1.0, progress))
67
  if message is not None:
@@ -70,24 +92,92 @@ def update_job(
70
  job.error = error
71
  if result is not None:
72
  job.result = result
 
73
  db.commit()
74
- try:
75
- _redis().publish(channel(job.id), json.dumps(serialize_job(job)))
76
- except redis.RedisError:
77
- pass # progress is also readable from GET /jobs/{id}
78
-
79
-
80
- async def subscribe(job_id: uuid.UUID):
81
- client = aredis.Redis.from_url(settings.redis_url)
82
- pubsub = client.pubsub()
83
- await pubsub.subscribe(channel(job_id))
84
- try:
85
- async for msg in pubsub.listen():
86
- if msg["type"] == "message":
87
- yield msg["data"].decode()
88
- finally:
89
- await pubsub.unsubscribe(channel(job_id))
90
- await client.aclose()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
 
93
  def record_usage(
 
1
+ """Jobs on Postgres: the jobs table is the queue, the progress record and the SSE source.
2
 
3
+ Enqueue = insert a queued row. Workers claim with FOR UPDATE SKIP LOCKED, so any number of worker
4
+ processes can poll the same table without double-running a job. Progress lives on the row, and the
5
+ SSE endpoint polls it.
6
+ """
7
+
8
+ import asyncio
9
  import json
10
  import uuid
11
+ from collections.abc import AsyncIterator, Callable
12
+ from datetime import UTC, datetime, timedelta
13
 
14
+ from fastapi.concurrency import run_in_threadpool
15
+ from sqlalchemy import or_, select
16
  from sqlalchemy.orm import Session
17
 
18
  from app.config import settings
19
  from app.models import Job, UsageEvent
20
 
21
+ TERMINAL = {"done", "failed"}
 
 
 
 
 
 
 
22
 
23
 
24
+ def utcnow() -> datetime:
25
+ return datetime.now(UTC)
26
 
27
 
28
  def serialize_job(job: Job) -> dict:
 
35
  "error": job.error,
36
  "result": job.result,
37
  "artifact_id": str(job.artifact_id) if job.artifact_id else None,
38
+ "attempts": job.attempts,
39
  "cost_usd": job.cost_usd,
40
  "created_at": job.created_at.isoformat() if job.created_at else None,
41
  "finished_at": job.finished_at.isoformat() if job.finished_at else None,
42
  }
43
 
44
 
45
+ # Producer side
46
+
47
+
48
+ def create_job(
49
+ db: Session,
50
+ workspace_id: uuid.UUID,
51
+ kind: str,
52
+ params: dict | None = None,
53
+ *,
54
+ artifact_id: uuid.UUID | None = None,
55
+ max_attempts: int = 3,
56
+ ) -> Job:
57
+ """Enqueue. The worker picks it up within WORKER_POLL_SECONDS."""
58
+ job = Job(
59
+ workspace_id=workspace_id,
60
+ kind=kind,
61
+ params=params or {},
62
+ artifact_id=artifact_id,
63
+ status="queued",
64
+ max_attempts=max_attempts,
65
+ )
66
  db.add(job)
67
  db.commit()
68
  return job
 
78
  error: str | None = None,
79
  result: dict | None = None,
80
  ) -> None:
81
+ """Every update is also a heartbeat, so a job that keeps reporting is never treated as stale."""
82
  if status:
83
  job.status = status
84
+ if status in TERMINAL:
85
+ job.finished_at = utcnow()
86
+ job.locked_by = None
87
  if progress is not None:
88
  job.progress = max(0.0, min(1.0, progress))
89
  if message is not None:
 
92
  job.error = error
93
  if result is not None:
94
  job.result = result
95
+ job.heartbeat_at = utcnow()
96
  db.commit()
97
+
98
+
99
+ # Consumer side (worker)
100
+
101
+
102
+ def claim_next(db: Session, worker_id: str) -> uuid.UUID | None:
103
+ """Atomically take the oldest runnable job. SKIP LOCKED lets parallel workers pass each other."""
104
+ now = utcnow()
105
+ job = db.scalar(
106
+ select(Job)
107
+ .where(Job.status == "queued", or_(Job.run_after.is_(None), Job.run_after <= now))
108
+ .order_by(Job.created_at)
109
+ .limit(1)
110
+ .with_for_update(skip_locked=True)
111
+ )
112
+ if not job:
113
+ db.rollback()
114
+ return None
115
+ job.status = "running"
116
+ job.attempts += 1
117
+ job.locked_by = worker_id
118
+ job.heartbeat_at = now
119
+ job.run_after = None
120
+ db.commit()
121
+ return job.id
122
+
123
+
124
+ def backoff_seconds(attempt: int) -> int:
125
+ return min(30 * 2 ** max(attempt - 1, 0), 900) # 30s, 60s, 120s ... capped at 15 min
126
+
127
+
128
+ def retry_or_fail(db: Session, job: Job, error: str) -> bool:
129
+ """Requeue with backoff if attempts remain. Returns True if it will retry."""
130
+ if job.attempts < job.max_attempts:
131
+ wait = backoff_seconds(job.attempts)
132
+ job.status = "queued"
133
+ job.locked_by = None
134
+ job.run_after = utcnow() + timedelta(seconds=wait)
135
+ update_job(db, job, error=error[:1000], message=f"Retrying in {wait}s")
136
+ return True
137
+ update_job(db, job, status="failed", error=error[:1000], message="Failed")
138
+ return False
139
+
140
+
141
+ def stale_jobs(db: Session) -> list[Job]:
142
+ cutoff = utcnow() - timedelta(seconds=settings.job_stale_seconds)
143
+ return list(
144
+ db.scalars(
145
+ select(Job)
146
+ .where(Job.status == "running", or_(Job.heartbeat_at.is_(None), Job.heartbeat_at < cutoff))
147
+ .with_for_update(skip_locked=True)
148
+ ).all()
149
+ )
150
+
151
+
152
+ # Progress stream (API)
153
+
154
+
155
+ async def watch_job(
156
+ session_factory: Callable[[], Session],
157
+ job_id: uuid.UUID,
158
+ interval: float = 1.0,
159
+ max_seconds: float = 4 * 3600,
160
+ ) -> AsyncIterator[str]:
161
+ """Yield the serialized job whenever it changes, until it finishes. Polls the row."""
162
+
163
+ def snapshot() -> dict | None:
164
+ with session_factory() as db:
165
+ job = db.get(Job, job_id)
166
+ return serialize_job(job) if job else None
167
+
168
+ last: dict | None = None
169
+ loop = asyncio.get_running_loop()
170
+ deadline = loop.time() + max_seconds
171
+ while loop.time() < deadline:
172
+ data = await run_in_threadpool(snapshot)
173
+ if data is None:
174
+ return
175
+ if data != last:
176
+ yield json.dumps(data)
177
+ last = data
178
+ if data["status"] in TERMINAL:
179
+ return
180
+ await asyncio.sleep(interval)
181
 
182
 
183
  def record_usage(
backend/app/worker.py CHANGED
@@ -1,19 +1,32 @@
1
- """arq worker: long running jobs + scheduled email batches.
2
 
3
- Run: arq app.worker.WorkerSettings
 
 
 
 
 
4
  """
5
 
6
  import logging
 
 
 
 
 
7
  import uuid
 
 
 
 
8
  from datetime import UTC, datetime
9
 
10
  import httpx
11
- from arq import cron
12
- from arq.connections import RedisSettings
13
- from sqlalchemy import select
14
 
15
  from app.config import settings
16
- from app.db import SessionLocal
17
  from app.models import (
18
  Artifact,
19
  Job,
@@ -28,65 +41,135 @@ from app.models import (
28
  from app.pipeline.ingest import ingest_source
29
  from app.services.email import email_service
30
  from app.services.email_verification import purge_old_codes
31
- from app.services.jobs import update_job
32
  from app.services.plans import effective_plan
33
  from app.services.storage import keys, storage
34
 
35
- log = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
- async def ingest_source_task(ctx, source_id: str, job_id: str) -> dict:
39
- with SessionLocal() as db:
40
- job = db.get(Job, uuid.UUID(job_id))
41
- source = db.get(Source, uuid.UUID(source_id))
42
- if not job or not source:
43
- return {"error": "missing"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  try:
45
- plan = effective_plan(db, db.get(Workspace, source.workspace_id))
46
- result = ingest_source(db, source, job, max_posts=min(plan.indexed_posts, 200))
47
- update_job(db, job, status="done", progress=1.0, message="All posts in orbit", result=result)
48
- return result
49
  except Exception as exc:
50
- log.exception("ingest failed source=%s", source_id)
51
  db.rollback()
52
- source.sync_status = "error"
53
- source.sync_error = str(exc)[:500]
54
- update_job(db, job, status="failed", error=str(exc)[:1000], message="Ingestion failed")
55
- return {"error": str(exc)}
 
 
 
56
 
57
 
58
- async def render_task(ctx, artifact_id: str, job_id: str, composition: str, props: dict, fmt: str) -> dict:
59
- """Hand off to the Remotion renderer with a presigned PUT so it never holds R2 credentials."""
60
- with SessionLocal() as db:
61
- job = db.get(Job, uuid.UUID(job_id))
62
- artifact = db.get(Artifact, uuid.UUID(artifact_id))
63
- ext = "png" if fmt == "png" else "mp4"
64
- key = keys.artifact(artifact.workspace_id, artifact.id, composition.lower(), ext)
65
- content_type = "image/png" if ext == "png" else "video/mp4"
66
- update_job(db, job, status="running", progress=0.01, message="T-minus: preparing render")
67
- async with httpx.AsyncClient(timeout=30) as client:
68
- resp = await client.post(
69
- f"{settings.renderer_url}/render",
70
- headers={"x-internal-token": settings.internal_token},
71
- json={
72
- "jobId": job_id,
73
- "compositionId": composition,
74
- "props": props,
75
- "format": fmt,
76
- "uploadUrl": storage.presign_put(key, content_type, ttl=6 * 3600),
77
- "uploadContentType": content_type,
78
- "callbackUrl": f"{settings.api_url}/api/internal/jobs/{job_id}/progress",
79
- "storageKey": key,
80
- },
81
- )
82
- resp.raise_for_status()
83
- return {"accepted": True, "key": key}
84
 
 
85
 
86
- async def update_email_batch(ctx) -> None:
87
- """Hourly check; sends one daily batch per scheduled campaign at its send hour (UTC)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  now = datetime.now(UTC)
89
- with SessionLocal() as db:
90
  campaigns = db.scalars(select(UpdateEmail).where(UpdateEmail.status.in_(["scheduled", "running"]))).all()
91
  for campaign in campaigns:
92
  hour = campaign.send_hour if campaign.send_hour >= 0 else settings.update_email_send_hour
@@ -121,19 +204,74 @@ async def update_email_batch(ctx) -> None:
121
  db.commit()
122
 
123
 
124
- async def daily_cleanup(ctx) -> None:
125
- with SessionLocal() as db:
126
  purge_old_codes(db)
127
 
128
 
129
- async def startup(ctx) -> None:
130
- logging.basicConfig(level=logging.INFO)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
 
133
- class WorkerSettings:
134
- functions = [ingest_source_task, render_task]
135
- cron_jobs = [cron(update_email_batch, minute=5), cron(daily_cleanup, hour=3, minute=17)]
136
- redis_settings = RedisSettings.from_dsn(settings.redis_url)
137
- on_startup = startup
138
- max_jobs = 10
139
- job_timeout = 60 * 30
 
1
+ """Worker: runs queued jobs from the Postgres jobs table plus scheduled email batches.
2
 
3
+ Run: python -m app.worker
4
+
5
+ - Claims jobs with FOR UPDATE SKIP LOCKED, runs up to WORKER_CONCURRENCY at once in threads.
6
+ - Failed jobs retry with backoff up to max_attempts; jobs whose worker died (no heartbeat for
7
+ JOB_STALE_SECONDS) are requeued.
8
+ - Periodic tasks take a Postgres advisory lock, so with several workers only one runs each tick.
9
  """
10
 
11
  import logging
12
+ import os
13
+ import signal
14
+ import socket
15
+ import threading
16
+ import time
17
  import uuid
18
+ from collections.abc import Callable
19
+ from concurrent.futures import Future, ThreadPoolExecutor
20
+ from contextlib import contextmanager
21
+ from dataclasses import dataclass, field
22
  from datetime import UTC, datetime
23
 
24
  import httpx
25
+ from sqlalchemy import select, text
26
+ from sqlalchemy.orm import Session
 
27
 
28
  from app.config import settings
29
+ from app.db import SessionLocal, engine
30
  from app.models import (
31
  Artifact,
32
  Job,
 
41
  from app.pipeline.ingest import ingest_source
42
  from app.services.email import email_service
43
  from app.services.email_verification import purge_old_codes
44
+ from app.services.jobs import claim_next, retry_or_fail, stale_jobs, update_job
45
  from app.services.plans import effective_plan
46
  from app.services.storage import keys, storage
47
 
48
+ log = logging.getLogger("notestack.worker")
49
+
50
+
51
+ @dataclass
52
+ class Done:
53
+ result: dict = field(default_factory=dict)
54
+ message: str = "Done"
55
+
56
+
57
+ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back via callbacks
58
+
59
+
60
+ # Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
61
+
62
+
63
+ def handle_ingest(db: Session, job: Job):
64
+ source = db.get(Source, uuid.UUID(job.params["source_id"]))
65
+ if not source:
66
+ return Done({"error": "source deleted"}, "Source no longer exists")
67
+ plan = effective_plan(db, db.get(Workspace, source.workspace_id))
68
+ try:
69
+ result = ingest_source(db, source, job, max_posts=min(plan.indexed_posts, 200))
70
+ except Exception as exc:
71
+ db.rollback()
72
+ source.sync_status = "error"
73
+ source.sync_error = str(exc)[:500]
74
+ db.commit()
75
+ raise
76
+ return Done(result, "All posts in orbit")
77
 
78
 
79
+ def handle_render(db: Session, job: Job):
80
+ """Hand off to the Remotion renderer with a presigned PUT so it never holds R2 credentials."""
81
+ p = job.params
82
+ artifact = db.get(Artifact, uuid.UUID(p["artifact_id"]))
83
+ ext = "png" if p["format"] == "png" else "mp4"
84
+ key = keys.artifact(artifact.workspace_id, artifact.id, p["composition"].lower(), ext)
85
+ content_type = "image/png" if ext == "png" else "video/mp4"
86
+ update_job(db, job, progress=0.01, message="T-minus: preparing render")
87
+ resp = httpx.post(
88
+ f"{settings.renderer_url}/render",
89
+ headers={"x-internal-token": settings.internal_token},
90
+ json={
91
+ "jobId": str(job.id),
92
+ "compositionId": p["composition"],
93
+ "props": p["props"],
94
+ "format": p["format"],
95
+ "uploadUrl": storage.presign_put(key, content_type, ttl=6 * 3600),
96
+ "uploadContentType": content_type,
97
+ "callbackUrl": f"{settings.api_url}/api/internal/jobs/{job.id}/progress",
98
+ "storageKey": key,
99
+ },
100
+ timeout=30,
101
+ )
102
+ resp.raise_for_status()
103
+ return HANDED_OFF
104
+
105
+
106
+ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
107
+ "ingest": handle_ingest,
108
+ "render": handle_render,
109
+ }
110
+
111
+
112
+ def run_job(
113
+ job_id: uuid.UUID,
114
+ session_factory: Callable[[], Session] = SessionLocal,
115
+ handlers: dict | None = None,
116
+ ) -> None:
117
+ handlers = handlers or HANDLERS
118
+ with session_factory() as db:
119
+ job = db.get(Job, job_id)
120
+ if not job:
121
+ return
122
+ handler = handlers.get(job.kind)
123
+ if not handler:
124
+ update_job(db, job, status="failed", error=f"No handler for job kind {job.kind!r}")
125
+ return
126
  try:
127
+ outcome = handler(db, job)
 
 
 
128
  except Exception as exc:
129
+ log.exception("job %s (%s) attempt %s failed", job.id, job.kind, job.attempts)
130
  db.rollback()
131
+ job = db.get(Job, job_id)
132
+ retry_or_fail(db, job, f"{type(exc).__name__}: {exc}")
133
+ return
134
+ if outcome is HANDED_OFF:
135
+ return
136
+ done = outcome if isinstance(outcome, Done) else Done()
137
+ update_job(db, job, status="done", progress=1.0, message=done.message, result=done.result)
138
 
139
 
140
+ def recover_stale(session_factory: Callable[[], Session] = SessionLocal) -> int:
141
+ with session_factory() as db:
142
+ jobs = stale_jobs(db)
143
+ for job in jobs:
144
+ log.warning("job %s stale (worker %s), requeueing", job.id, job.locked_by)
145
+ retry_or_fail(db, job, "Worker stopped responding")
146
+ db.commit()
147
+ return len(jobs)
148
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
+ # Periodic tasks
151
 
152
+
153
+ @contextmanager
154
+ def single_flight(name: str):
155
+ """Postgres advisory lock on a dedicated connection; yields False if another worker holds it."""
156
+ if engine.dialect.name != "postgresql":
157
+ yield True
158
+ return
159
+ with engine.connect() as conn:
160
+ got = conn.execute(text("SELECT pg_try_advisory_lock(hashtext(:n))"), {"n": name}).scalar()
161
+ try:
162
+ yield bool(got)
163
+ finally:
164
+ if got:
165
+ conn.execute(text("SELECT pg_advisory_unlock(hashtext(:n))"), {"n": name})
166
+ conn.commit()
167
+
168
+
169
+ def update_email_batch(session_factory: Callable[[], Session] = SessionLocal) -> None:
170
+ """Sends one daily batch per scheduled campaign at its send hour (UTC)."""
171
  now = datetime.now(UTC)
172
+ with session_factory() as db:
173
  campaigns = db.scalars(select(UpdateEmail).where(UpdateEmail.status.in_(["scheduled", "running"]))).all()
174
  for campaign in campaigns:
175
  hour = campaign.send_hour if campaign.send_hour >= 0 else settings.update_email_send_hour
 
204
  db.commit()
205
 
206
 
207
+ def daily_cleanup(session_factory: Callable[[], Session] = SessionLocal) -> None:
208
+ with session_factory() as db:
209
  purge_old_codes(db)
210
 
211
 
212
+ @dataclass
213
+ class Periodic:
214
+ name: str
215
+ every_seconds: float
216
+ fn: Callable[[], object]
217
+ next_at: float = 0.0
218
+
219
+
220
+ PERIODIC = [
221
+ Periodic("recover_stale", 60, recover_stale),
222
+ Periodic("update_email_batch", 300, update_email_batch),
223
+ Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
224
+ ]
225
+
226
+
227
+ def run_periodic(now: float) -> None:
228
+ for task in PERIODIC:
229
+ if now < task.next_at:
230
+ continue
231
+ task.next_at = now + task.every_seconds
232
+ try:
233
+ with single_flight(task.name) as got:
234
+ if got:
235
+ task.fn()
236
+ except Exception:
237
+ log.exception("periodic task %s failed", task.name)
238
+
239
+
240
+ # Main loop
241
+
242
+
243
+ def main() -> None:
244
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
245
+ worker_id = f"{socket.gethostname()}:{os.getpid()}"
246
+ stop = threading.Event()
247
+ for sig in (signal.SIGINT, signal.SIGTERM):
248
+ signal.signal(sig, lambda *_: stop.set())
249
+
250
+ pool = ThreadPoolExecutor(max_workers=settings.worker_concurrency, thread_name_prefix="job")
251
+ running: set[Future] = set()
252
+ log.info("worker %s up, concurrency=%s", worker_id, settings.worker_concurrency)
253
+
254
+ while not stop.is_set():
255
+ running = {f for f in running if not f.done()}
256
+ run_periodic(time.monotonic())
257
+ claimed = False
258
+ while len(running) < settings.worker_concurrency and not stop.is_set():
259
+ try:
260
+ with SessionLocal() as db:
261
+ job_id = claim_next(db, worker_id)
262
+ except Exception:
263
+ log.exception("claim failed")
264
+ break
265
+ if not job_id:
266
+ break
267
+ running.add(pool.submit(run_job, job_id))
268
+ claimed = True
269
+ if not claimed:
270
+ stop.wait(settings.worker_poll_seconds)
271
+
272
+ log.info("worker %s stopping, waiting for %d running jobs", worker_id, len(running))
273
+ pool.shutdown(wait=True)
274
 
275
 
276
+ if __name__ == "__main__":
277
+ main()
 
 
 
 
 
backend/requirements.txt CHANGED
@@ -13,8 +13,6 @@ requests==2.*
13
  boto3==1.35.*
14
  resend==2.*
15
  httpx==0.28.*
16
- arq==0.26.*
17
- redis==5.*
18
  dspy==2.6.*
19
  feedparser==6.*
20
  beautifulsoup4==4.*
 
13
  boto3==1.35.*
14
  resend==2.*
15
  httpx==0.28.*
 
 
16
  dspy==2.6.*
17
  feedparser==6.*
18
  beautifulsoup4==4.*
backend/tests/conftest.py CHANGED
@@ -31,7 +31,7 @@ def db_session():
31
  def client(db_session):
32
  app.dependency_overrides[get_db] = lambda: db_session
33
  ConsoleEmailProvider.sent.clear()
34
- yield TestClient(app) # no context manager: skips lifespan (no Redis needed)
35
  app.dependency_overrides.clear()
36
 
37
 
 
31
  def client(db_session):
32
  app.dependency_overrides[get_db] = lambda: db_session
33
  ConsoleEmailProvider.sent.clear()
34
+ yield TestClient(app) # no context manager: skips lifespan
35
  app.dependency_overrides.clear()
36
 
37
 
backend/tests/test_queue.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import uuid
4
+ from datetime import UTC, datetime, timedelta
5
+
6
+ import pytest
7
+ from sqlalchemy import create_engine
8
+ from sqlalchemy.orm import sessionmaker
9
+ from sqlalchemy.pool import StaticPool
10
+
11
+ import app.models # noqa: F401
12
+ from app.db import Base
13
+ from app.models import Job, Workspace
14
+ from app.services import jobs as q
15
+ from app.worker import HANDED_OFF, Done, recover_stale, run_job
16
+
17
+
18
+ @pytest.fixture()
19
+ def factory():
20
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
21
+ Base.metadata.create_all(engine)
22
+ return sessionmaker(bind=engine, expire_on_commit=False)
23
+
24
+
25
+ @pytest.fixture()
26
+ def ws(factory):
27
+ with factory() as db:
28
+ w = Workspace(name="w", owner_id=uuid.uuid4())
29
+ db.add(w)
30
+ db.commit()
31
+ return w.id
32
+
33
+
34
+ def enqueue(factory, ws, kind="ingest", **kw):
35
+ with factory() as db:
36
+ return q.create_job(db, ws, kind, {"n": 1}, **kw).id
37
+
38
+
39
+ def get(factory, job_id) -> Job:
40
+ with factory() as db:
41
+ return db.get(Job, job_id)
42
+
43
+
44
+ def test_claims_oldest_first_and_only_once(factory, ws):
45
+ a, b = enqueue(factory, ws), enqueue(factory, ws)
46
+ with factory() as db:
47
+ assert q.claim_next(db, "w1") == a
48
+ assert q.claim_next(db, "w1") == b
49
+ assert q.claim_next(db, "w1") is None
50
+ job = get(factory, a)
51
+ assert job.status == "running" and job.attempts == 1 and job.locked_by == "w1"
52
+
53
+
54
+ def test_success_marks_done(factory, ws):
55
+ jid = enqueue(factory, ws)
56
+ with factory() as db:
57
+ q.claim_next(db, "w1")
58
+ run_job(jid, factory, {"ingest": lambda db, job: Done({"indexed": 3}, "All posts in orbit")})
59
+ job = get(factory, jid)
60
+ assert (job.status, job.progress, job.result, job.message) == ("done", 1.0, {"indexed": 3}, "All posts in orbit")
61
+
62
+
63
+ def test_failure_retries_with_backoff_then_fails(factory, ws):
64
+ jid = enqueue(factory, ws, max_attempts=2)
65
+
66
+ def boom(db, job):
67
+ raise RuntimeError("feed timed out")
68
+
69
+ with factory() as db:
70
+ q.claim_next(db, "w1")
71
+ run_job(jid, factory, {"ingest": boom})
72
+ job = get(factory, jid)
73
+ assert job.status == "queued" and "feed timed out" in job.error and job.message == "Retrying in 30s"
74
+ with factory() as db:
75
+ assert q.claim_next(db, "w1") is None # backoff not elapsed yet
76
+ db.get(Job, jid).run_after = datetime.now(UTC) - timedelta(seconds=1)
77
+ db.commit()
78
+ assert q.claim_next(db, "w1") == jid
79
+ run_job(jid, factory, {"ingest": boom})
80
+ job = get(factory, jid)
81
+ assert job.status == "failed" and job.attempts == 2 and job.finished_at is not None
82
+
83
+
84
+ def test_handed_off_job_stays_running(factory, ws):
85
+ jid = enqueue(factory, ws, kind="render")
86
+ with factory() as db:
87
+ q.claim_next(db, "w1")
88
+ run_job(jid, factory, {"render": lambda db, job: HANDED_OFF})
89
+ assert get(factory, jid).status == "running"
90
+
91
+
92
+ def test_unknown_kind_fails(factory, ws):
93
+ jid = enqueue(factory, ws, kind="mystery")
94
+ with factory() as db:
95
+ q.claim_next(db, "w1")
96
+ run_job(jid, factory, {})
97
+ assert get(factory, jid).status == "failed"
98
+
99
+
100
+ def test_stale_running_job_is_requeued(factory, ws, monkeypatch):
101
+ jid = enqueue(factory, ws)
102
+ with factory() as db:
103
+ q.claim_next(db, "dead-worker")
104
+ db.get(Job, jid).heartbeat_at = datetime.now(UTC) - timedelta(hours=1)
105
+ db.commit()
106
+ assert recover_stale(factory) == 1
107
+ job = get(factory, jid)
108
+ assert job.status == "queued" and job.error == "Worker stopped responding" and job.locked_by is None
109
+
110
+
111
+ def test_progress_updates_count_as_heartbeat(factory, ws):
112
+ jid = enqueue(factory, ws)
113
+ with factory() as db:
114
+ q.claim_next(db, "w1")
115
+ job = db.get(Job, jid)
116
+ job.heartbeat_at = datetime.now(UTC) - timedelta(hours=1)
117
+ db.commit()
118
+ q.update_job(db, job, progress=0.5, message="halfway")
119
+ assert recover_stale(factory) == 0
120
+
121
+
122
+ def test_watch_job_streams_changes_until_done(factory, ws):
123
+ jid = enqueue(factory, ws)
124
+
125
+ async def collect():
126
+ seen = []
127
+ async for data in q.watch_job(factory, jid, interval=0.01):
128
+ seen.append(json.loads(data))
129
+ with factory() as db:
130
+ job = db.get(Job, jid)
131
+ if len(seen) == 1:
132
+ q.update_job(db, job, status="running", progress=0.5, message="halfway")
133
+ elif len(seen) == 2:
134
+ q.update_job(db, job, status="done", progress=1.0)
135
+ return seen
136
+
137
+ seen = asyncio.run(collect())
138
+ assert [s["status"] for s in seen] == ["queued", "running", "done"]
139
+ assert seen[1]["message"] == "halfway"
backend/tests/test_queue_postgres.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real Postgres checks for the parts SQLite cannot exercise: SKIP LOCKED under concurrency and
2
+ advisory locks. Runs when TEST_POSTGRES_URL is set (CI provides one)."""
3
+
4
+ import os
5
+ import threading
6
+ import uuid
7
+
8
+ import pytest
9
+ from sqlalchemy import create_engine, text
10
+ from sqlalchemy.orm import sessionmaker
11
+
12
+ import app.models # noqa: F401
13
+ from app.db import Base
14
+ from app.models import Job, Workspace
15
+ from app.services import jobs as q
16
+
17
+ PG_URL = os.environ.get("TEST_POSTGRES_URL")
18
+ pytestmark = pytest.mark.skipif(not PG_URL, reason="TEST_POSTGRES_URL not set")
19
+
20
+
21
+ @pytest.fixture()
22
+ def factory():
23
+ engine = create_engine(PG_URL, pool_size=20)
24
+ Base.metadata.drop_all(engine)
25
+ Base.metadata.create_all(engine)
26
+ yield sessionmaker(bind=engine, expire_on_commit=False)
27
+ Base.metadata.drop_all(engine)
28
+ engine.dispose()
29
+
30
+
31
+ def test_parallel_workers_never_claim_the_same_job(factory):
32
+ with factory() as db:
33
+ ws = Workspace(name="w", owner_id=uuid.uuid4())
34
+ db.add(ws)
35
+ db.commit()
36
+ ids = {q.create_job(db, ws.id, "ingest").id for _ in range(60)}
37
+
38
+ claimed: list[uuid.UUID] = []
39
+ lock = threading.Lock()
40
+
41
+ def worker(name):
42
+ while True:
43
+ with factory() as db:
44
+ jid = q.claim_next(db, name)
45
+ if not jid:
46
+ return
47
+ with lock:
48
+ claimed.append(jid)
49
+
50
+ threads = [threading.Thread(target=worker, args=(f"w{i}",)) for i in range(8)]
51
+ for t in threads:
52
+ t.start()
53
+ for t in threads:
54
+ t.join()
55
+ assert len(claimed) == len(set(claimed)) == len(ids)
56
+ with factory() as db:
57
+ assert db.query(Job).filter(Job.attempts != 1).count() == 0
58
+
59
+
60
+ def test_single_flight_excludes_second_holder(factory, monkeypatch):
61
+ import app.worker as worker
62
+
63
+ engine = factory.kw["bind"]
64
+ monkeypatch.setattr(worker, "engine", engine)
65
+ with worker.single_flight("email-batch") as first:
66
+ with worker.single_flight("email-batch") as second:
67
+ assert first is True and second is False
68
+ with worker.single_flight("email-batch") as again:
69
+ assert again is True
70
+ with engine.connect() as c:
71
+ assert c.execute(text("select count(*) from pg_locks where locktype = 'advisory'")).scalar() == 0
docker-compose.yml CHANGED
@@ -13,10 +13,6 @@ services:
13
  interval: 5s
14
  retries: 10
15
 
16
- redis:
17
- image: redis:7-alpine
18
- ports: ["6379:6379"]
19
-
20
  minio:
21
  image: minio/minio:latest
22
  command: server /data --console-address ":9001"
@@ -31,7 +27,6 @@ services:
31
  env_file: .env
32
  environment:
33
  DATABASE_URL: postgresql+psycopg://notestack:notestack@postgres:5432/notestack
34
- REDIS_URL: redis://redis:6379/0
35
  R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
36
  RENDERER_URL: http://renderer:3100
37
  API_URL: http://api:8000
@@ -40,15 +35,13 @@ services:
40
  ports: ["8000:8000"]
41
  depends_on:
42
  postgres: { condition: service_healthy }
43
- redis: { condition: service_started }
44
 
45
  worker:
46
  build: ./backend
47
- command: arq app.worker.WorkerSettings
48
  env_file: .env
49
  environment:
50
  DATABASE_URL: postgresql+psycopg://notestack:notestack@postgres:5432/notestack
51
- REDIS_URL: redis://redis:6379/0
52
  R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
53
  RENDERER_URL: http://renderer:3100
54
  API_URL: http://api:8000
 
13
  interval: 5s
14
  retries: 10
15
 
 
 
 
 
16
  minio:
17
  image: minio/minio:latest
18
  command: server /data --console-address ":9001"
 
27
  env_file: .env
28
  environment:
29
  DATABASE_URL: postgresql+psycopg://notestack:notestack@postgres:5432/notestack
 
30
  R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
31
  RENDERER_URL: http://renderer:3100
32
  API_URL: http://api:8000
 
35
  ports: ["8000:8000"]
36
  depends_on:
37
  postgres: { condition: service_healthy }
 
38
 
39
  worker:
40
  build: ./backend
41
+ command: python -m app.worker
42
  env_file: .env
43
  environment:
44
  DATABASE_URL: postgresql+psycopg://notestack:notestack@postgres:5432/notestack
 
45
  R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
46
  RENDERER_URL: http://renderer:3100
47
  API_URL: http://api:8000
docs/DESIGN.md CHANGED
@@ -26,7 +26,7 @@ Every factual statement Notestack generates must trace back to the writer's own
26
  | Area | Decision |
27
  | --- | --- |
28
  | Backend | FastAPI + SQLAlchemy 2 + Alembic (same stack as blog2video) |
29
- | Worker | arq on Redis (async jobs, progress events over Redis pub/sub, SSE to clients) |
30
  | Database | Postgres 16 (no vector extension) |
31
  | Frontend | React + Vite + TypeScript, plain CSS with design tokens |
32
  | Renderer | Node service running Remotion (`renderer/`) |
@@ -45,9 +45,9 @@ Every factual statement Notestack generates must trace back to the writer's own
45
  +-------------------+ | auth, notebooks, |
46
  ^ | billing, storage |
47
  | presigned GET +---------+----------+
48
- | | enqueue (arq)
49
  +--------+----------+ +---------v----------+
50
- | Cloudflare R2 |<-------------------------| worker (arq) |
51
  | notestack-assets | put objects | ingest, corpus, |
52
  +--------+----------+ | DSPy, TTS |
53
  ^ +---------+----------+
@@ -55,7 +55,7 @@ Every factual statement Notestack generates must trace back to the writer's own
55
  +--------+----------+ progress callbacks +---------v----------+
56
  | renderer (Remotion)|------------------------>| api /internal/... |
57
  +-------------------+ +--------------------+
58
- Postgres (api, worker) Redis (queue, pub/sub, cache)
59
  ```
60
 
61
  ### 3.1 Storage pipeline (R2)
@@ -159,11 +159,28 @@ Z.ai considerations baked in:
159
 
160
  Swapping to Anthropic, OpenAI, Gemini or a local model is only an env change.
161
 
162
- ### 3.4 Jobs and progress
163
-
164
- Every long operation is a row in `jobs` (kind, status, progress, error, cost) plus an arq task.
165
- Workers publish progress to Redis channel `job:{id}`; `GET /api/jobs/{id}/events` streams it as SSE.
166
- No API request blocks for more than a few seconds.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
  ## 4. Data model
169
 
 
26
  | Area | Decision |
27
  | --- | --- |
28
  | Backend | FastAPI + SQLAlchemy 2 + Alembic (same stack as blog2video) |
29
+ | Worker | Postgres backed queue: the `jobs` table, claimed with `FOR UPDATE SKIP LOCKED`; progress on the row, streamed as SSE. No Redis. |
30
  | Database | Postgres 16 (no vector extension) |
31
  | Frontend | React + Vite + TypeScript, plain CSS with design tokens |
32
  | Renderer | Node service running Remotion (`renderer/`) |
 
45
  +-------------------+ | auth, notebooks, |
46
  ^ | billing, storage |
47
  | presigned GET +---------+----------+
48
+ | | insert queued job
49
  +--------+----------+ +---------v----------+
50
+ | Cloudflare R2 |<-------------------------| worker |
51
  | notestack-assets | put objects | ingest, corpus, |
52
  +--------+----------+ | DSPy, TTS |
53
  ^ +---------+----------+
 
55
  +--------+----------+ progress callbacks +---------v----------+
56
  | renderer (Remotion)|------------------------>| api /internal/... |
57
  +-------------------+ +--------------------+
58
+ Postgres: data + job queue (api, worker)
59
  ```
60
 
61
  ### 3.1 Storage pipeline (R2)
 
159
 
160
  Swapping to Anthropic, OpenAI, Gemini or a local model is only an env change.
161
 
162
+ ### 3.4 Jobs and progress (Postgres, no Redis)
163
+
164
+ The `jobs` table is the queue, the progress record and the SSE source.
165
+
166
+ - **Enqueue:** the API inserts a `queued` row (`create_job`). Nothing else.
167
+ - **Claim:** `python -m app.worker` polls every `WORKER_POLL_SECONDS` and takes the oldest runnable
168
+ row with `SELECT ... FOR UPDATE SKIP LOCKED`, so any number of workers can share the table without
169
+ double running a job. Up to `WORKER_CONCURRENCY` jobs run at once in threads.
170
+ - **Retry:** a handler that raises is requeued with backoff (30s, 60s, 120s... capped at 15 min)
171
+ until `max_attempts` (default 3), then marked `failed`.
172
+ - **Heartbeat:** every progress update stamps `heartbeat_at`. A `running` job silent for
173
+ `JOB_STALE_SECONDS` (worker crashed, renderer died) is requeued by the stale sweeper.
174
+ - **Hand off:** render jobs return `HANDED_OFF`; the renderer's progress callbacks keep the row
175
+ alive and mark it done.
176
+ - **Progress to the browser:** `GET /api/jobs/{id}/events` re-reads the row about once a second and
177
+ sends only changes. Latency is at most a second, which is fine for progress bars.
178
+ - **Schedules:** the worker runs the stale sweep (1 min), update email batch (5 min, gated to the
179
+ campaign send hour) and daily cleanup. Each takes a Postgres advisory lock, so with several
180
+ workers only one runs a given task.
181
+
182
+ No request blocks for more than a few seconds. Add Redis back only if job volume makes polling a
183
+ measurable load on Postgres; the handler code would not change.
184
 
185
  ## 4. Data model
186
 
docs/PHASE0.md CHANGED
@@ -4,9 +4,9 @@ Legend: [x] done in this scaffold, [ ] open
4
 
5
  ## Repo and infra
6
  - [x] Monorepo layout: `backend/`, `frontend/`, `renderer/`, `docs/`
7
- - [x] docker-compose: api, worker, postgres, redis, minio (local R2 stand-in), renderer, frontend
8
  - [x] `.env.example` covering every service
9
- - [x] CI: backend lint + tests, frontend typecheck + build, renderer typecheck
10
  - [ ] Production deploy target (Fly, Render or DO App Platform) and R2 bucket + custom domain
11
  - [ ] Sentry (api, worker, frontend)
12
 
@@ -14,7 +14,8 @@ Legend: [x] done in this scaffold, [ ] open
14
  - [x] Settings (`app/config.py`), DB session, Alembic initial migration
15
  - [x] Full data model from design doc Section 4
16
  - [x] Health endpoint, CORS, request id logging
17
- - [x] Jobs table + arq worker + Redis progress pub/sub + SSE endpoint
 
18
  - [x] Usage metering table and helper (`record_usage`)
19
 
20
  ## Auth (mirrors blog2video)
 
4
 
5
  ## Repo and infra
6
  - [x] Monorepo layout: `backend/`, `frontend/`, `renderer/`, `docs/`
7
+ - [x] docker-compose: api, worker, postgres, minio (local R2 stand-in), renderer, frontend
8
  - [x] `.env.example` covering every service
9
+ - [x] CI: backend lint + tests (with a Postgres service), frontend typecheck + build, renderer typecheck
10
  - [ ] Production deploy target (Fly, Render or DO App Platform) and R2 bucket + custom domain
11
  - [ ] Sentry (api, worker, frontend)
12
 
 
14
  - [x] Settings (`app/config.py`), DB session, Alembic initial migration
15
  - [x] Full data model from design doc Section 4
16
  - [x] Health endpoint, CORS, request id logging
17
+ - [x] Postgres job queue (SKIP LOCKED claims, retries with backoff, stale recovery) + SSE progress
18
+ - [x] Scheduled tasks in the worker behind advisory locks
19
  - [x] Usage metering table and helper (`record_usage`)
20
 
21
  ## Auth (mirrors blog2video)