This commit is contained in:
@@ -11,7 +11,7 @@ log=logging.getLogger(__name__)
|
||||
def cleanup(batch=100):
|
||||
now=time.time(); removed=0
|
||||
with sessions().begin() as db:
|
||||
q=select(Job).where(Job.expires_at<=now, or_(Job.status!='processing', Job.lease_until<now)).limit(batch)
|
||||
q=select(Job).where(Job.expires_at != -1, Job.expires_at<=now, or_(Job.status!='processing', Job.lease_until<now)).limit(batch)
|
||||
if engine().dialect.name=='postgresql': q=q.with_for_update(skip_locked=True)
|
||||
for job in db.scalars(q):
|
||||
try:
|
||||
|
||||
@@ -10,7 +10,8 @@ from fastapi.responses import FileResponse,JSONResponse,Response
|
||||
from sqlalchemy import select,func,text
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job,sessions
|
||||
from .expiry import expiration
|
||||
from .db import Job,sessions,active_jobs
|
||||
from .storage import storage
|
||||
from .uploads import read_schematic
|
||||
from .models.api import ComparisonStatus,ComparisonUploadResponse
|
||||
@@ -37,20 +38,19 @@ def register_comparison_routes(app, *, authorize, require_api, error):
|
||||
@app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
|
||||
access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None,
|
||||
expires_in:Annotated[int|None,Form(ge=-1, description="-1 for no expiry, or positive seconds")]=None,
|
||||
x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
ttl=expires_in or settings().link_ttl
|
||||
if ttl>settings().link_ttl:error('invalid_expiry','expires_in exceeds the configured link lifetime',422)
|
||||
now=time.time();expires_at=expiration(expires_in,now)
|
||||
try:
|
||||
old_name,old=await read_schematic(old_file)
|
||||
new_name,new=await read_schematic(new_file)
|
||||
finally:
|
||||
await old_file.close();await new_file.close()
|
||||
identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32);now=time.time()
|
||||
identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32)
|
||||
job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access,
|
||||
token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now,
|
||||
expires_at=now+ttl,attempt=0,input_key=identifier+'/comparison.json')
|
||||
expires_at=expires_at,attempt=0,input_key=identifier+'/comparison.json')
|
||||
files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new,
|
||||
job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()}
|
||||
def persist():
|
||||
@@ -58,7 +58,7 @@ def register_comparison_routes(app, *, authorize, require_api, error):
|
||||
try:
|
||||
with sessions().begin() as db:
|
||||
if db.bind.dialect.name=='postgresql':db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
|
||||
pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now))
|
||||
pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),active_jobs(now)))
|
||||
if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503)
|
||||
for key,body in files.items():
|
||||
try: storage().put(key,body)
|
||||
|
||||
@@ -6,7 +6,7 @@ to Redis. Versioned artifact keys fence out expired worker leases.
|
||||
"""
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from sqlalchemy import create_engine, String, Text, Float, Integer, select, update, func, text
|
||||
from sqlalchemy import create_engine, String, Text, Float, Integer, select, update, func, text, or_
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
||||
from .config import settings
|
||||
|
||||
@@ -39,6 +39,9 @@ def engine():
|
||||
@lru_cache
|
||||
def sessions(): return sessionmaker(engine(), expire_on_commit=False)
|
||||
|
||||
def active_jobs(now):
|
||||
return or_(Job.expires_at == -1, Job.expires_at > now)
|
||||
|
||||
def initialize():
|
||||
# Initial schema only. Subsequent schema changes must use explicit migrations.
|
||||
if engine().dialect.name == 'postgresql':
|
||||
@@ -55,7 +58,7 @@ def claim():
|
||||
now = time.time()
|
||||
with sessions().begin() as db:
|
||||
# Expired jobs are never revived. A crashed worker can be retried once.
|
||||
eligible = ((Job.status == 'queued') | ((Job.status == 'processing') & (Job.lease_until < now))) & (Job.expires_at > now)
|
||||
eligible = ((Job.status == 'queued') | ((Job.status == 'processing') & (Job.lease_until < now))) & active_jobs(now)
|
||||
q = select(Job).where(eligible).order_by(Job.created_at).limit(1)
|
||||
if engine().dialect.name == 'postgresql': q = q.with_for_update(skip_locked=True)
|
||||
job = db.scalar(q)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Link expiry policy. -1 is a persistent no-expiry sentinel, not a timestamp."""
|
||||
from fastapi import HTTPException
|
||||
from .config import settings
|
||||
|
||||
NEVER_EXPIRES = -1
|
||||
|
||||
|
||||
def expiration(expires_in, now):
|
||||
if expires_in == NEVER_EXPIRES:
|
||||
return NEVER_EXPIRES
|
||||
ttl = settings().link_ttl if expires_in is None else expires_in
|
||||
if ttl <= 0 or ttl > settings().link_ttl:
|
||||
raise HTTPException(422, {'code': 'invalid_expiry',
|
||||
'message': 'expires_in must be -1 (no expiry) or positive seconds within the configured lifetime'})
|
||||
return now + ttl
|
||||
@@ -16,7 +16,8 @@ from sqlalchemy import func, select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job, initialize, sessions
|
||||
from .expiry import expiration
|
||||
from .db import Job, initialize, sessions, active_jobs
|
||||
from .storage import storage
|
||||
from .fonts import fonts, stylesheet
|
||||
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
|
||||
@@ -119,7 +120,7 @@ def authorize(identifier, key, token, *, comparison=False, resource_kind=None):
|
||||
# Private existence and expiry are not disclosed to unauthenticated callers.
|
||||
if job.access == 'private' and not (authenticated(key) or (token and hmac.compare_digest(job.token_hash, digest(token)))):
|
||||
error('not_found', 'Schematic not found', 404)
|
||||
if job.expires_at <= time.time(): error('link_expired', 'This schematic link has expired', 410)
|
||||
if job.expires_at != -1 and job.expires_at <= time.time(): error('link_expired', 'This schematic link has expired', 410)
|
||||
return job
|
||||
|
||||
def public_status(job):
|
||||
@@ -136,22 +137,21 @@ def public_status(job):
|
||||
503: {'description': 'Queue or storage unavailable'}})
|
||||
async def upload(file: Annotated[UploadFile, File(description='Native binary OLE or ASCII .SchDoc')],
|
||||
access: Annotated[Literal['private', 'unlisted'], Form()] = 'private',
|
||||
expires_in: Annotated[int | None, Form(gt=0, description='Link lifetime in seconds, up to configured maximum')] = None,
|
||||
expires_in: Annotated[int | None, Form(ge=-1, description='-1 for no expiry, or positive seconds up to configured maximum')] = None,
|
||||
x_api_key: Annotated[str | None, Header()] = None):
|
||||
require_api(x_api_key)
|
||||
ttl = expires_in or settings().link_ttl
|
||||
if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422)
|
||||
now = time.time()
|
||||
expires_at = expiration(expires_in, now)
|
||||
name, body = await read_schematic(file)
|
||||
identifier = secrets.token_hex(24)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
job = Job(id=identifier, filename=name, status='queued', access=access, token_hash=digest(token),
|
||||
created_at=now, updated_at=now, expires_at=now+ttl, attempt=0, input_key=f'{identifier}/input.SchDoc')
|
||||
created_at=now, updated_at=now, expires_at=expires_at, attempt=0, input_key=f'{identifier}/input.SchDoc')
|
||||
def persist():
|
||||
with sessions().begin() as db:
|
||||
# Serialize admission checks across API replicas on PostgreSQL.
|
||||
if db.bind.dialect.name == 'postgresql': db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
|
||||
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']), Job.expires_at > now))
|
||||
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']), active_jobs(now)))
|
||||
if pending >= settings().max_pending: error('queue_full', 'Rendering queue is full; try again later', 503)
|
||||
try: storage().put(job.input_key, bytes(body))
|
||||
except Exception: error('storage_unavailable', 'Upload storage unavailable', 503)
|
||||
@@ -203,7 +203,7 @@ def index(): return FileResponse(FRONTEND / 'index.html')
|
||||
|
||||
@app.get('/assets/{name}', include_in_schema=False)
|
||||
def asset(name: str):
|
||||
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js','pcb.js','pcb.css','pcb-layers.js'}: error('not_found', 'Asset not found', 404)
|
||||
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js','pcb.js','pcb.css','pcb-layers.js','share.js','share.css'}: error('not_found', 'Asset not found', 404)
|
||||
return FileResponse(FRONTEND / name)
|
||||
|
||||
@app.get('/docs', include_in_schema=False)
|
||||
|
||||
@@ -15,7 +15,7 @@ class RenderStatus(BaseModel):
|
||||
id: str = Field(description='Unpredictable 192-bit identifier, encoded as 48 hex characters')
|
||||
status: Literal['queued','processing','completed','failed']
|
||||
viewer_url: str = Field(description='Base viewer URL; only initial upload includes private token fragment')
|
||||
expires_at: float = Field(description='Unix timestamp, UTC seconds')
|
||||
expires_at: float = Field(description='Unix timestamp in UTC seconds; -1 means no expiration')
|
||||
access: Literal['private','unlisted']
|
||||
schema_version: str | None = None
|
||||
compatibility: Literal['partial','supported_subset'] | None = None
|
||||
|
||||
@@ -11,7 +11,8 @@ from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from sqlalchemy import select, func, text
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job, sessions
|
||||
from .expiry import expiration
|
||||
from .db import Job, sessions, active_jobs
|
||||
from .storage import storage
|
||||
from .uploads import read_pcb
|
||||
from .models.pcb import PCBData, PCBStatus, PCBUploadResponse, PCBComparisonStatus, PCBComparisonUploadResponse
|
||||
@@ -34,18 +35,17 @@ def register_pcb_routes(app, *, authorize, require_api, error):
|
||||
return result
|
||||
|
||||
async def create(files, access, expires_in, comparison):
|
||||
ttl = expires_in or settings().link_ttl
|
||||
if ttl > settings().link_ttl: error('invalid_expiry','expires_in exceeds configured link lifetime',422)
|
||||
now=time.time();expires_at=expiration(expires_in,now)
|
||||
sources = {}
|
||||
try:
|
||||
for field, file in files.items(): sources[field] = await read_pcb(file)
|
||||
finally:
|
||||
for file in files.values(): await file.close()
|
||||
identifier = secrets.token_hex(24); token = secrets.token_urlsafe(32); now = time.time()
|
||||
identifier = secrets.token_hex(24); token = secrets.token_urlsafe(32)
|
||||
input_name = 'pcb-comparison.json' if comparison else 'input.PcbDoc'
|
||||
job = Job(id=identifier, filename='PCB comparison' if comparison else sources['file'][0],
|
||||
status='queued', access=access, token_hash=hashlib.sha256(token.encode()).hexdigest(),
|
||||
created_at=now, updated_at=now, expires_at=now+ttl, attempt=0, input_key=f'{identifier}/{input_name}')
|
||||
created_at=now, updated_at=now, expires_at=expires_at, attempt=0, input_key=f'{identifier}/{input_name}')
|
||||
if comparison:
|
||||
bodies = {f'{identifier}/{side}.PcbDoc':sources[side+'_file'][1] for side in ('old','new')}
|
||||
bodies[job.input_key] = json.dumps(dict(schema_version='1.0',document_type='pcb_comparison',
|
||||
@@ -56,7 +56,7 @@ def register_pcb_routes(app, *, authorize, require_api, error):
|
||||
try:
|
||||
with sessions().begin() as db:
|
||||
if db.bind.dialect.name == 'postgresql': db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
|
||||
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now))
|
||||
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),active_jobs(now)))
|
||||
if pending >= settings().max_pending: error('queue_full','Rendering queue is full; try again later',503)
|
||||
for key, body in bodies.items():
|
||||
# Include the attempted key so a write-then-raise backend is also cleaned up.
|
||||
@@ -78,14 +78,14 @@ def register_pcb_routes(app, *, authorize, require_api, error):
|
||||
|
||||
@app.post('/api/v1/pcbs/render', status_code=202, response_model=PCBUploadResponse, response_model_exclude_none=True, tags=['PCB'])
|
||||
async def render(file:Annotated[UploadFile,File()], access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None, x_api_key:Annotated[str|None,Header()]=None):
|
||||
expires_in:Annotated[int|None,Form(ge=-1, description="-1 for no expiry, or positive seconds")]=None, x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
return await create({'file':file},access,expires_in,False)
|
||||
|
||||
@app.post('/api/v1/pcbs/comparisons',status_code=202,response_model=PCBComparisonUploadResponse,response_model_exclude_none=True,tags=['PCB'])
|
||||
async def compare(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
|
||||
access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None,x_api_key:Annotated[str|None,Header()]=None):
|
||||
expires_in:Annotated[int|None,Form(ge=-1, description="-1 for no expiry, or positive seconds")]=None,x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
return await create({'old_file':old_file,'new_file':new_file},access,expires_in,True)
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import time
|
||||
import pytest
|
||||
from app.config import settings
|
||||
from app.db import Job,claim,sessions
|
||||
from app.storage import storage
|
||||
from app.worker import process
|
||||
from app.cleanup import cleanup
|
||||
from test_platform import client,KEY
|
||||
from pcb_fixture import pcb
|
||||
|
||||
ROUTES=['/api/v1/schematics/render','/api/v1/comparisons','/api/v1/pcbs/render','/api/v1/pcbs/comparisons']
|
||||
|
||||
|
||||
def create(client,route,raw,expires=None):
|
||||
suffix='PcbDoc' if '/pcbs/' in route else 'SchDoc'
|
||||
files={f'{side}_file':(side+'.'+suffix,raw) for side in ('old','new')} if route.endswith('comparisons') else {'file':('input.'+suffix,raw)}
|
||||
return client.post(route,headers={'X-API-Key':KEY},files=files,data={} if expires is None else {'expires_in':str(expires)})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route',ROUTES)
|
||||
@pytest.mark.parametrize('backend',['local','s3'])
|
||||
def test_unlimited_retention_survives_claim_read_and_cleanup(client,ascii_schematic,monkeypatch,route,backend):
|
||||
fake=None
|
||||
if backend=='s3':
|
||||
import boto3
|
||||
from test_storage_s3 import MemoryS3
|
||||
fake=MemoryS3();monkeypatch.setattr(boto3,'client',lambda *a,**kw:fake)
|
||||
monkeypatch.setenv('STORAGE_BACKEND','s3');monkeypatch.setenv('S3_BUCKET','test-private');monkeypatch.setenv('S3_PREFIX','unlimited')
|
||||
settings.cache_clear();storage.cache_clear()
|
||||
raw=pcb() if '/pcbs/' in route else ascii_schematic
|
||||
created=create(client,route,raw,-1);assert created.status_code==202,created.text
|
||||
body=created.json();assert body['expires_at']==-1
|
||||
with sessions()() as db:assert db.get(Job,body['id']).expires_at==-1
|
||||
# Advance the application clock far beyond every configured finite TTL.
|
||||
future=time.time()+100*365*86400;monkeypatch.setattr(time,'time',lambda:future)
|
||||
claimed=claim();assert claimed is not None and claimed['id']==body['id']
|
||||
process(claimed)
|
||||
headers={'X-Viewer-Token':body['viewer_token']}
|
||||
response=client.get(body['status_url'],headers=headers);assert response.status_code==200
|
||||
status=response.json();assert status['status']=='completed',status
|
||||
assert status['expires_at']==-1
|
||||
base=body['status_url'].removesuffix('/status')
|
||||
assert client.get(base+('/svg/old' if route.endswith('comparisons') else '/svg'),headers=headers).status_code==200
|
||||
assert client.get(body['status_url']).status_code==404
|
||||
assert cleanup()==0
|
||||
with sessions()() as db:assert db.get(Job,body['id']) is not None
|
||||
if fake:assert fake.objects and not (settings().storage_dir/'objects').exists()
|
||||
else:assert list((settings().storage_dir/'objects'/body['id']).rglob('*'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route',ROUTES)
|
||||
@pytest.mark.parametrize('expires',[0,-2,999999999])
|
||||
def test_invalid_expiry_rejected_for_every_operation(client,ascii_schematic,route,expires):
|
||||
response=create(client,route,pcb() if '/pcbs/' in route else ascii_schematic,expires)
|
||||
assert response.status_code==422,response.text
|
||||
assert claim() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route',ROUTES)
|
||||
def test_unlimited_jobs_still_count_toward_queue_limit(client,ascii_schematic,monkeypatch,route):
|
||||
monkeypatch.setenv('MAX_PENDING_JOBS','1');settings.cache_clear()
|
||||
raw=pcb() if '/pcbs/' in route else ascii_schematic
|
||||
assert create(client,route,raw,-1).status_code==202
|
||||
assert create(client,route,raw,-1).status_code==503
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route',ROUTES)
|
||||
def test_default_and_positive_expiry_remain_finite(client,ascii_schematic,route):
|
||||
raw=pcb() if '/pcbs/' in route else ascii_schematic
|
||||
now=time.time()
|
||||
for expires,ttl in [(None,settings().link_ttl),(60,60)]:
|
||||
response=create(client,route,raw,expires);assert response.status_code==202
|
||||
assert now+ttl<=response.json()['expires_at']<=time.time()+ttl
|
||||
مرجع در شماره جدید
مسدود کردن کاربر