ocr is working
This commit is contained in:
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OcrJobRow maps ocr_jobs.
|
||||
@@ -18,6 +19,9 @@ type OcrJobRow struct {
|
||||
// ErrJobNotFound marks an OCR job absent or owned by another device.
|
||||
var ErrJobNotFound = errors.New("ocr job not found")
|
||||
|
||||
// ErrNoQueuedJobs marks an OCR queue drained (no job to claim).
|
||||
var ErrNoQueuedJobs = errors.New("no queued ocr jobs")
|
||||
|
||||
type OcrJobs struct{ DB *sql.DB }
|
||||
|
||||
func (o *OcrJobs) Create(jobID, deviceID, fileID string) error {
|
||||
@@ -62,6 +66,49 @@ func (o *OcrJobs) TouchProcessing(deviceID, jobID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ClaimNext atomically picks the oldest queued job (FIFO) and moves it to
|
||||
// `processing`. Safe for concurrent workers: `FOR UPDATE SKIP LOCKED` blocks
|
||||
// the row as part of the same statement. Empty queue → ErrNoQueuedJobs.
|
||||
func (o *OcrJobs) ClaimNext() (OcrJobRow, error) {
|
||||
var row OcrJobRow
|
||||
err := o.DB.QueryRow(
|
||||
`WITH next AS (
|
||||
SELECT job_id FROM ocr_jobs
|
||||
WHERE status = 'queued'
|
||||
ORDER BY created_at, job_id
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE ocr_jobs
|
||||
SET status = 'processing', started_at = NOW()
|
||||
FROM next
|
||||
WHERE ocr_jobs.job_id = next.job_id
|
||||
RETURNING ocr_jobs.job_id, ocr_jobs.file_id, ocr_jobs.device_id, ocr_jobs.status`,
|
||||
).Scan(&row.ID, &row.FileID, &row.DeviceID, &row.Status)
|
||||
if err == sql.ErrNoRows {
|
||||
return OcrJobRow{}, ErrNoQueuedJobs
|
||||
}
|
||||
if err != nil {
|
||||
return OcrJobRow{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ResetStaleProcessing requeues jobs stuck in `processing` (worker crash /
|
||||
// processus redémarré) et plus vieux que `olderThan`. Retourne le nb de jobs
|
||||
// requeued.
|
||||
func (o *OcrJobs) ResetStaleProcessing(olderThan time.Duration) (int64, error) {
|
||||
res, err := o.DB.Exec(
|
||||
`UPDATE ocr_jobs SET status = 'queued', started_at = NULL
|
||||
WHERE status = 'processing' AND started_at < $1`,
|
||||
time.Now().Add(-olderThan),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (o *OcrJobs) Complete(deviceID, jobID, text string) error {
|
||||
_, err := o.DB.Exec(
|
||||
`UPDATE ocr_jobs SET status = 'done', text = NULLIF($3, ''), started_at = COALESCE(started_at, NOW()), completed_at = NOW()
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vaultdrop/backend/dbtest"
|
||||
)
|
||||
@@ -90,3 +91,91 @@ func TestOcrJobsGetScopedByDevice(t *testing.T) {
|
||||
t.Errorf("get par un autre device : attendu ErrJobNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOcrJobsClaimNextFifo(t *testing.T) {
|
||||
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||
var jobIDs []string
|
||||
for i := 0; i < 3; i++ {
|
||||
jobID := NewID()
|
||||
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||
t.Fatalf("create %d: %v", i, err)
|
||||
}
|
||||
jobIDs = append(jobIDs, jobID)
|
||||
}
|
||||
|
||||
for i, want := range jobIDs {
|
||||
row, err := repo.OcrJobs.ClaimNext()
|
||||
if err != nil {
|
||||
t.Fatalf("claim %d: %v", i, err)
|
||||
}
|
||||
if row.ID != want {
|
||||
t.Errorf("claim %d = %q, attendu %q (FIFO)", i, row.ID, want)
|
||||
}
|
||||
if row.Status != "processing" {
|
||||
t.Errorf("claim %d status = %q, attendu processing", i, row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := repo.OcrJobs.ClaimNext(); !errors.Is(err, ErrNoQueuedJobs) {
|
||||
t.Errorf("queue vide : attendu ErrNoQueuedJobs, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOcrJobsClaimNextSkipsProcessingAndDone(t *testing.T) {
|
||||
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||
job1 := NewID()
|
||||
job2 := NewID()
|
||||
if err := repo.OcrJobs.Create(job1, deviceID, fileID); err != nil {
|
||||
t.Fatalf("create job1: %v", err)
|
||||
}
|
||||
if err := repo.OcrJobs.Create(job2, deviceID, fileID); err != nil {
|
||||
t.Fatalf("create job2: %v", err)
|
||||
}
|
||||
if err := repo.OcrJobs.TouchProcessing(deviceID, job2); err != nil {
|
||||
t.Fatalf("touch job2 processing: %v", err)
|
||||
}
|
||||
|
||||
row, err := repo.OcrJobs.ClaimNext()
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if row.ID != job1 {
|
||||
t.Errorf("claim = %q, attendu job1 %q", row.ID, job1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOcrJobsResetStaleProcessing(t *testing.T) {
|
||||
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||
jobID := NewID()
|
||||
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := repo.OcrJobs.TouchProcessing(deviceID, jobID); err != nil {
|
||||
t.Fatalf("touch processing: %v", err)
|
||||
}
|
||||
|
||||
// Started_at = NOW : pas encore stale → aucun requeue.
|
||||
if n, err := repo.OcrJobs.ResetStaleProcessing(time.Minute); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
} else if n != 0 {
|
||||
t.Errorf("reset récent : attendu 0, got %d", n)
|
||||
}
|
||||
|
||||
// Vieillit le started_at puis re-reset → 1 requeue.
|
||||
if _, err := repo.OcrJobs.DB.Exec(`UPDATE ocr_jobs SET started_at = NOW() - interval '2 minutes' WHERE job_id = $1`, jobID); err != nil {
|
||||
t.Fatalf("vieillir started_at: %v", err)
|
||||
}
|
||||
if n, err := repo.OcrJobs.ResetStaleProcessing(time.Minute); err != nil {
|
||||
t.Fatalf("reset stale: %v", err)
|
||||
} else if n != 1 {
|
||||
t.Errorf("reset stale : attendu 1, got %d", n)
|
||||
}
|
||||
|
||||
row, err := repo.OcrJobs.Get(deviceID, jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if row.Status != "queued" {
|
||||
t.Errorf("status après reset = %q, attendu queued", row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,21 @@ func (r *Resources) GetFileVisible(userID, resourceID string) (FileRow, error) {
|
||||
return file, err
|
||||
}
|
||||
|
||||
// GetResourceOwner returns the owning user_id of any resource (file or folder).
|
||||
// No-rows → ErrNotFound.
|
||||
func (r *Resources) GetResourceOwner(resourceID string) (string, error) {
|
||||
var ownerID string
|
||||
err := r.DB.QueryRow(
|
||||
`SELECT user_id FROM resources
|
||||
WHERE resource_id = $1 AND deleted_at IS NULL`,
|
||||
resourceID,
|
||||
).Scan(&ownerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return ownerID, err
|
||||
}
|
||||
|
||||
// DeleteFile soft-deletes the file (deleted_at), returning its id.
|
||||
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
||||
result, err := r.DB.Exec(
|
||||
|
||||
Reference in New Issue
Block a user