basyx commited on
Commit
1c2cf22
·
verified ·
1 Parent(s): 84481d5

Add tenant-scoped generation attempt history query

Browse files
app/generation/repositories/generation.py CHANGED
@@ -193,6 +193,30 @@ class GenerationRepository:
193
  raise GenerationJobNotFoundError("Generation job was not found in this workspace.")
194
  return job
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  async def cancel(self, workspace_id: str, job_id: str, *, user_id: str) -> GenerationJob:
197
  """Cancel queued work immediately; request cancellation otherwise.
198
 
@@ -354,518 +378,4 @@ class GenerationRepository:
354
  )
355
  if job.provider != provider:
356
  raise GenerationProviderJobConflictError(
357
- "Generation worker job belongs to a different provider."
358
- )
359
- if GenerationJobStatus(job.status) not in {
360
- GenerationJobStatus.SUBMITTING,
361
- GenerationJobStatus.RUNNING,
362
- GenerationJobStatus.CANCEL_REQUESTED,
363
- }:
364
- raise GenerationProviderJobConflictError(
365
- "Generation job is not in a state that can bind a worker job."
366
- )
367
- if job.external_job_id is not None:
368
- if job.external_job_id != external_job_id:
369
- raise GenerationProviderJobConflictError(
370
- "Generation job is already bound to a different worker job."
371
- )
372
- return job
373
-
374
- conflicting = await session.scalar(
375
- select(GenerationJob.id).where(
376
- GenerationJob.provider == provider,
377
- GenerationJob.external_job_id == external_job_id,
378
- GenerationJob.id != job.id,
379
- )
380
- )
381
- if conflicting is not None:
382
- raise GenerationProviderJobConflictError(
383
- "Generation worker job is already bound to another workspace."
384
- )
385
- job.external_job_id = external_job_id
386
- job.provider_metadata_json = self._merge_safe_metadata(
387
- job.provider_metadata_json, provider_metadata
388
- )
389
- job.updated_at = datetime.now(timezone.utc)
390
- await session.commit()
391
- await session.refresh(job)
392
- return job
393
- except IntegrityError as exc:
394
- # The unique database constraint arbitrates a binding race across
395
- # independently running worker processes without leaking either
396
- # workspace or provider details.
397
- raise GenerationProviderJobConflictError(
398
- "Generation worker job is already bound to another logical job."
399
- ) from exc
400
-
401
- async def complete_with_output_asset(
402
- self,
403
- workspace_id: str,
404
- job_id: str,
405
- *,
406
- user_id: str,
407
- external_job_id: str,
408
- output_asset_id: str,
409
- provider_metadata: dict[str, object] | None = None,
410
- ) -> GenerationJob:
411
- """Atomically attach an owned canonical output and complete the job."""
412
-
413
- async with self.database.tenant_session(
414
- workspace_id=workspace_id, user_id=user_id
415
- ) as session:
416
- job = await session.scalar(
417
- select(GenerationJob)
418
- .where(
419
- GenerationJob.id == job_id,
420
- GenerationJob.workspace_id == workspace_id,
421
- )
422
- .with_for_update()
423
- )
424
- if job is None:
425
- raise GenerationJobNotFoundError("Generation job was not found in this workspace.")
426
- if job.external_job_id != external_job_id:
427
- raise GenerationProviderJobConflictError(
428
- "Generation worker job is not bound to this logical job."
429
- )
430
- asset = await session.scalar(
431
- select(CanonicalMediaAsset).where(
432
- CanonicalMediaAsset.id == output_asset_id,
433
- CanonicalMediaAsset.workspace_id == workspace_id,
434
- )
435
- )
436
- if asset is None:
437
- raise GenerationOutputConflictError(
438
- "Generation output asset is not owned by this workspace."
439
- )
440
- if job.output_asset_id is not None:
441
- if job.output_asset_id == output_asset_id:
442
- return job
443
- raise GenerationOutputConflictError(
444
- "Generation job already has a different canonical output asset."
445
- )
446
-
447
- destination = validate_transition(job.status, GenerationJobStatus.SUCCEEDED)
448
- now = datetime.now(timezone.utc)
449
- job.status = destination.value
450
- job.output_asset_id = output_asset_id
451
- job.provider_metadata_json = self._merge_safe_metadata(
452
- job.provider_metadata_json, provider_metadata
453
- )
454
- job.error_code = None
455
- job.error_message = None
456
- job.next_attempt_at = None
457
- job.completed_at = now
458
- job.updated_at = now
459
- await self._set_request_status(
460
- session,
461
- workspace_id=workspace_id,
462
- request_id=job.generation_request_id,
463
- status=destination,
464
- completed_at=now,
465
- )
466
- await self._finish_open_attempt(
467
- session,
468
- generation_job_id=job.id,
469
- status=GenerationJobStatus.SUCCEEDED.value,
470
- error_code=None,
471
- error_message=None,
472
- completed_at=now,
473
- )
474
- await session.commit()
475
- await session.refresh(job)
476
- return job
477
-
478
- async def start_attempt(
479
- self, workspace_id: str, job_id: str, *, user_id: str
480
- ) -> GenerationJobAttempt:
481
- async with self.database.tenant_session(
482
- workspace_id=workspace_id, user_id=user_id
483
- ) as session:
484
- job = await session.scalar(
485
- select(GenerationJob)
486
- .where(
487
- GenerationJob.id == job_id,
488
- GenerationJob.workspace_id == workspace_id,
489
- )
490
- .with_for_update()
491
- )
492
- if job is None:
493
- raise GenerationJobNotFoundError("Generation job was not found in this workspace.")
494
- job.attempt_count += 1
495
- attempt = GenerationJobAttempt(
496
- generation_job_id=job.id,
497
- attempt_number=job.attempt_count,
498
- status="started",
499
- )
500
- session.add(attempt)
501
- await session.commit()
502
- await session.refresh(attempt)
503
- return attempt
504
-
505
- async def claim_dispatchable(self, *, limit: int) -> list[GenerationDispatchRecord]:
506
- """Atomically claim unbound queued/retry jobs for one submission.
507
-
508
- The worker/service role is the only caller. ``FOR UPDATE SKIP
509
- LOCKED`` prevents two worker processes from submitting the same
510
- logical job; SQLite safely serialises writers and ignores the lock
511
- clause. A claim persists ``submitting`` and an attempt before network
512
- I/O, so crash recovery can distinguish a never-started job from an
513
- ambiguous lost worker response.
514
- """
515
-
516
- now = datetime.now(timezone.utc)
517
- claimed: list[GenerationDispatchRecord] = []
518
- async with self.database.session() as session:
519
- rows = await session.execute(
520
- select(GenerationJob, GenerationRequest)
521
- .join(
522
- GenerationRequest,
523
- GenerationRequest.id == GenerationJob.generation_request_id,
524
- )
525
- .where(
526
- GenerationJob.external_job_id.is_(None),
527
- GenerationJob.status.in_(
528
- [GenerationJobStatus.QUEUED.value, GenerationJobStatus.RETRYING.value]
529
- ),
530
- or_(
531
- GenerationJob.next_attempt_at.is_(None),
532
- GenerationJob.next_attempt_at <= now,
533
- ),
534
- )
535
- .order_by(GenerationJob.created_at)
536
- .limit(limit)
537
- .with_for_update(skip_locked=True)
538
- )
539
- for job, request in rows.tuples().all():
540
- if job.attempt_count >= job.max_attempts:
541
- result = await session.execute(
542
- update(GenerationJob)
543
- .where(
544
- GenerationJob.id == job.id,
545
- GenerationJob.status == job.status,
546
- GenerationJob.external_job_id.is_(None),
547
- GenerationJob.attempt_count == job.attempt_count,
548
- )
549
- .values(
550
- status=GenerationJobStatus.FAILED.value,
551
- error_code="GENERATION_RETRY_LIMIT_EXCEEDED",
552
- error_message="Generation retry limit was reached before submission.",
553
- completed_at=now,
554
- updated_at=now,
555
- )
556
- .execution_options(synchronize_session=False)
557
- )
558
- if result.rowcount != 1:
559
- continue
560
- destination = validate_transition(job.status, GenerationJobStatus.FAILED)
561
- job.status = destination.value
562
- job.error_code = "GENERATION_RETRY_LIMIT_EXCEEDED"
563
- job.error_message = "Generation retry limit was reached before submission."
564
- job.completed_at = now
565
- job.updated_at = now
566
- await self._set_request_status(
567
- session,
568
- workspace_id=job.workspace_id,
569
- request_id=job.generation_request_id,
570
- status=destination,
571
- completed_at=now,
572
- )
573
- continue
574
- original_status = job.status
575
- original_attempt_count = job.attempt_count
576
- destination = validate_transition(original_status, GenerationJobStatus.SUBMITTING)
577
- result = await session.execute(
578
- update(GenerationJob)
579
- .where(
580
- GenerationJob.id == job.id,
581
- GenerationJob.status == original_status,
582
- GenerationJob.external_job_id.is_(None),
583
- GenerationJob.attempt_count == original_attempt_count,
584
- )
585
- .values(
586
- status=destination.value,
587
- error_code=None,
588
- error_message=None,
589
- next_attempt_at=None,
590
- updated_at=now,
591
- started_at=job.started_at or now,
592
- attempt_count=original_attempt_count + 1,
593
- )
594
- .execution_options(synchronize_session=False)
595
- )
596
- if result.rowcount != 1:
597
- continue
598
- job.status = destination.value
599
- job.error_code = None
600
- job.error_message = None
601
- job.next_attempt_at = None
602
- job.updated_at = now
603
- if job.started_at is None:
604
- job.started_at = now
605
- job.attempt_count = original_attempt_count + 1
606
- attempt = GenerationJobAttempt(
607
- generation_job_id=job.id,
608
- attempt_number=job.attempt_count,
609
- status="submitting",
610
- )
611
- session.add(attempt)
612
- await self._set_request_status(
613
- session,
614
- workspace_id=job.workspace_id,
615
- request_id=job.generation_request_id,
616
- status=destination,
617
- completed_at=None,
618
- )
619
- claimed.append(
620
- GenerationDispatchRecord(
621
- workspace_id=job.workspace_id,
622
- user_id=request.created_by_user_id,
623
- generation_request_id=request.id,
624
- generation_job_id=job.id,
625
- provider=job.provider,
626
- model_id=request.model_id,
627
- modality=request.modality,
628
- input_asset_id=request.input_asset_id,
629
- project_id=request.project_id,
630
- product_surface=request.product_surface,
631
- spec=dict(request.spec_json or {}),
632
- idempotency_key=request.idempotency_key,
633
- attempt_number=job.attempt_count,
634
- external_job_id=None,
635
- status=destination,
636
- )
637
- )
638
- await session.commit()
639
- return claimed
640
-
641
- async def list_reconcilable(
642
- self, *, limit: int, lease_seconds: int
643
- ) -> list[GenerationDispatchRecord]:
644
- """Return bound active jobs for idempotent status reconciliation."""
645
-
646
- now = datetime.now(timezone.utc)
647
- lease_until = now + timedelta(seconds=max(1, lease_seconds))
648
- async with self.database.session() as session:
649
- rows = await session.execute(
650
- select(GenerationJob, GenerationRequest)
651
- .join(
652
- GenerationRequest,
653
- GenerationRequest.id == GenerationJob.generation_request_id,
654
- )
655
- .where(
656
- GenerationJob.external_job_id.is_not(None),
657
- GenerationJob.status.in_(
658
- [
659
- GenerationJobStatus.QUEUED.value,
660
- GenerationJobStatus.SUBMITTING.value,
661
- GenerationJobStatus.RUNNING.value,
662
- GenerationJobStatus.CANCEL_REQUESTED.value,
663
- ]
664
- ),
665
- or_(
666
- GenerationJob.next_attempt_at.is_(None),
667
- GenerationJob.next_attempt_at <= now,
668
- ),
669
- )
670
- .order_by(GenerationJob.updated_at)
671
- .limit(limit)
672
- )
673
- records: list[GenerationDispatchRecord] = []
674
- for job, request in rows.tuples().all():
675
- result = await session.execute(
676
- update(GenerationJob)
677
- .where(
678
- GenerationJob.id == job.id,
679
- GenerationJob.status == job.status,
680
- GenerationJob.external_job_id == job.external_job_id,
681
- or_(
682
- GenerationJob.next_attempt_at.is_(None),
683
- GenerationJob.next_attempt_at <= now,
684
- ),
685
- )
686
- .values(next_attempt_at=lease_until, updated_at=now)
687
- .execution_options(synchronize_session=False)
688
- )
689
- if result.rowcount != 1:
690
- continue
691
- job.next_attempt_at = lease_until
692
- job.updated_at = now
693
- records.append(
694
- GenerationDispatchRecord(
695
- workspace_id=job.workspace_id,
696
- user_id=request.created_by_user_id,
697
- generation_request_id=request.id,
698
- generation_job_id=job.id,
699
- provider=job.provider,
700
- model_id=request.model_id,
701
- modality=request.modality,
702
- input_asset_id=request.input_asset_id,
703
- project_id=request.project_id,
704
- spec=dict(request.spec_json or {}),
705
- idempotency_key=request.idempotency_key,
706
- attempt_number=job.attempt_count,
707
- external_job_id=job.external_job_id,
708
- status=GenerationJobStatus(job.status),
709
- )
710
- )
711
- await session.commit()
712
- return records
713
-
714
- async def set_reconciliation_due(
715
- self,
716
- workspace_id: str,
717
- job_id: str,
718
- *,
719
- user_id: str,
720
- due_at: datetime | None,
721
- ) -> None:
722
- """Release or defer one bound active job's durable reconciliation lease."""
723
-
724
- async with self.database.tenant_session(
725
- workspace_id=workspace_id, user_id=user_id
726
- ) as session:
727
- job = await session.scalar(
728
- select(GenerationJob)
729
- .where(
730
- GenerationJob.id == job_id,
731
- GenerationJob.workspace_id == workspace_id,
732
- GenerationJob.external_job_id.is_not(None),
733
- GenerationJob.status.in_(
734
- [
735
- GenerationJobStatus.QUEUED.value,
736
- GenerationJobStatus.SUBMITTING.value,
737
- GenerationJobStatus.RUNNING.value,
738
- GenerationJobStatus.CANCEL_REQUESTED.value,
739
- ]
740
- ),
741
- )
742
- .with_for_update()
743
- )
744
- if job is None:
745
- return
746
- job.next_attempt_at = due_at
747
- job.updated_at = datetime.now(timezone.utc)
748
- await session.commit()
749
-
750
- async def fail_stale_unbound_submissions(self, *, stale_before: datetime) -> int:
751
- """Fail unrecoverably ambiguous submissions that lost their response.
752
-
753
- A remote worker without an idempotency/reconciliation lookup cannot be
754
- safely resubmitted after MediaRouter crashes or loses the job ID. A
755
- terminal, auditable failure is preferable to silently making a second
756
- video. This is intentionally provider-neutral; a future idempotent
757
- provider may choose a different recovery route before calling it.
758
- """
759
-
760
- count = 0
761
- async with self.database.session() as session:
762
- rows = await session.scalars(
763
- select(GenerationJob)
764
- .where(
765
- GenerationJob.status == GenerationJobStatus.SUBMITTING.value,
766
- GenerationJob.external_job_id.is_(None),
767
- GenerationJob.updated_at < stale_before,
768
- )
769
- .with_for_update(skip_locked=True)
770
- )
771
- now = datetime.now(timezone.utc)
772
- for job in rows.all():
773
- destination = validate_transition(job.status, GenerationJobStatus.FAILED)
774
- job.status = destination.value
775
- job.error_code = "GENERATION_SUBMISSION_AMBIGUOUS"
776
- job.error_message = (
777
- "Worker submission could not be reconciled safely; it was not retried."
778
- )
779
- job.completed_at = now
780
- job.updated_at = now
781
- await self._set_request_status(
782
- session,
783
- workspace_id=job.workspace_id,
784
- request_id=job.generation_request_id,
785
- status=destination,
786
- completed_at=now,
787
- )
788
- await self._finish_open_attempt(
789
- session,
790
- generation_job_id=job.id,
791
- status="failed_ambiguous",
792
- error_code=job.error_code,
793
- error_message=job.error_message,
794
- completed_at=now,
795
- )
796
- count += 1
797
- await session.commit()
798
- return count
799
-
800
- @staticmethod
801
- def _merge_safe_metadata(
802
- existing: dict[str, object] | None, incoming: dict[str, object] | None
803
- ) -> dict[str, object]:
804
- """Prevent a worker response from becoming a token/URL secret store."""
805
-
806
- safe_existing = safe_worker_metadata(existing or {})
807
- safe_incoming = safe_worker_metadata(incoming or {})
808
- assert isinstance(safe_existing, dict)
809
- assert isinstance(safe_incoming, dict)
810
- return {**safe_existing, **safe_incoming}
811
-
812
- @staticmethod
813
- async def _finish_open_attempt(
814
- session: AsyncSession,
815
- *,
816
- generation_job_id: str,
817
- status: str,
818
- error_code: str | None,
819
- error_message: str | None,
820
- completed_at: datetime,
821
- ) -> None:
822
- """Close the newest open attempt without creating a second job."""
823
-
824
- attempt = await session.scalar(
825
- select(GenerationJobAttempt)
826
- .where(
827
- GenerationJobAttempt.generation_job_id == generation_job_id,
828
- GenerationJobAttempt.completed_at.is_(None),
829
- )
830
- .order_by(GenerationJobAttempt.attempt_number.desc())
831
- .limit(1)
832
- .with_for_update()
833
- )
834
- if attempt is None:
835
- return
836
- attempt.status = status[:32]
837
- attempt.error_code = error_code
838
- attempt.error_message = error_message
839
- attempt.completed_at = completed_at
840
-
841
- @staticmethod
842
- def _safe_error(code: str | None, message: str | None) -> tuple[str | None, str | None]:
843
- """Persist only safe, bounded worker error information."""
844
-
845
- safe_code = code if code and _EXTERNAL_JOB_ID.fullmatch(code) else None
846
- safe_message = safe_worker_metadata(message) if message is not None else None
847
- if not isinstance(safe_message, str):
848
- safe_message = None
849
- return safe_code, safe_message[:500] if safe_message is not None else None
850
-
851
- @staticmethod
852
- async def _set_request_status(
853
- session: AsyncSession,
854
- *,
855
- workspace_id: str,
856
- request_id: str,
857
- status: GenerationJobStatus,
858
- completed_at: datetime | None,
859
- ) -> None:
860
- request = await session.scalar(
861
- select(GenerationRequest).where(
862
- GenerationRequest.id == request_id,
863
- GenerationRequest.workspace_id == workspace_id,
864
- )
865
- )
866
- if request is None:
867
- raise GenerationRequestNotFoundError(
868
- "Generation request was not found in this workspace."
869
- )
870
- request.status = GenerationRequestStatus(status.value).value
871
- request.completed_at = completed_at
 
193
  raise GenerationJobNotFoundError("Generation job was not found in this workspace.")
194
  return job
195
 
196
+ async def list_attempts(
197
+ self, workspace_id: str, job_id: str, *, user_id: str
198
+ ) -> list[GenerationJobAttempt]:
199
+ """Return durable retry history only when the logical job belongs to this workspace."""
200
+ async with self.database.tenant_session(
201
+ workspace_id=workspace_id, user_id=user_id
202
+ ) as session:
203
+ job_exists = await session.scalar(
204
+ select(GenerationJob.id).where(
205
+ GenerationJob.id == job_id,
206
+ GenerationJob.workspace_id == workspace_id,
207
+ )
208
+ )
209
+ if job_exists is None:
210
+ raise GenerationJobNotFoundError(
211
+ "Generation job was not found in this workspace."
212
+ )
213
+ rows = await session.scalars(
214
+ select(GenerationJobAttempt)
215
+ .where(GenerationJobAttempt.generation_job_id == job_id)
216
+ .order_by(GenerationJobAttempt.attempt_number.asc())
217
+ )
218
+ return list(rows.all())
219
+
220
  async def cancel(self, workspace_id: str, job_id: str, *, user_id: str) -> GenerationJob:
221
  """Cancel queued work immediately; request cancellation otherwise.
222
 
 
378
  )
379
  if job.provider != provider:
380
  raise GenerationProviderJobConflictError(
381
+ "Generation worker job belongs to a different pr