اضافه شدن قابلیت share و نگهداری بلندمدت
Verify / test (push) Failing after 22s

This commit is contained in:
2026-11-09 14:55:36 +03:30
والد a4e8f0c6c0
کامیت 19824bd180
27فایلهای تغییر یافته به همراه267 افزوده شده و 51 حذف شده
+1 -1
مشاهده پرونده
@@ -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:
+7 -7
مشاهده پرونده
@@ -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)
+5 -2
مشاهده پرونده
@@ -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)
+15
مشاهده پرونده
@@ -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
+9 -9
مشاهده پرونده
@@ -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)
+1 -1
مشاهده پرونده
@@ -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
+8 -8
مشاهده پرونده
@@ -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