Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
346 lines
11 KiB
Go
346 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// File is an uploaded or written file of the Files API, without its bytes.
|
|
type File struct {
|
|
ID string
|
|
Owner string
|
|
Purpose string
|
|
Filename string
|
|
Bytes int64
|
|
CreatedAt time.Time
|
|
// ExpiresAt is when it is deleted by itself; zero is never.
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// PutFile keeps a file and its bytes.
|
|
func (s *DB) PutFile(ctx context.Context, f File, content []byte) error {
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO files (id, owner, purpose, filename, bytes, content, created_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, f.ID, f.Owner, f.Purpose, f.Filename, int64(len(content)), content,
|
|
millis(f.CreatedAt), optMillis(f.ExpiresAt))
|
|
return err
|
|
}
|
|
|
|
const fileCols = `id, owner, purpose, filename, bytes, created_at, expires_at`
|
|
|
|
func scanFile(sc interface{ Scan(...any) error }) (File, error) {
|
|
var f File
|
|
var created, expires int64
|
|
if err := sc.Scan(&f.ID, &f.Owner, &f.Purpose, &f.Filename, &f.Bytes, &created, &expires); err != nil {
|
|
return File{}, err
|
|
}
|
|
f.CreatedAt = time.UnixMilli(created)
|
|
if expires > 0 {
|
|
f.ExpiresAt = time.UnixMilli(expires)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// FileByID finds a file, ErrNotFound when there is none.
|
|
func (s *DB) FileByID(ctx context.Context, id string) (File, error) {
|
|
f, err := scanFile(s.db.QueryRowContext(ctx, `SELECT `+fileCols+` FROM files WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return File{}, ErrNotFound
|
|
}
|
|
return f, err
|
|
}
|
|
|
|
// FileContent is a file's bytes.
|
|
func (s *DB) FileContent(ctx context.Context, id string) ([]byte, error) {
|
|
var b []byte
|
|
err := s.db.QueryRowContext(ctx, `SELECT content FROM files WHERE id = ?`, id).Scan(&b)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return b, err
|
|
}
|
|
|
|
// Files lists an owner's files, the newest first; purpose narrows it.
|
|
func (s *DB) Files(ctx context.Context, owner, purpose string, limit int) ([]File, error) {
|
|
q := `SELECT ` + fileCols + ` FROM files WHERE owner = ?`
|
|
args := []any{owner}
|
|
if purpose != "" {
|
|
q += ` AND purpose = ?`
|
|
args = append(args, purpose)
|
|
}
|
|
if limit <= 0 || limit > 10000 {
|
|
limit = 10000
|
|
}
|
|
q += ` ORDER BY created_at DESC LIMIT ?`
|
|
args = append(args, limit)
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []File
|
|
for rows.Next() {
|
|
f, err := scanFile(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DeleteFile removes a file; false when there was none.
|
|
func (s *DB) DeleteFile(ctx context.Context, id string) (bool, error) {
|
|
res, err := s.db.ExecContext(ctx, `DELETE FROM files WHERE id = ?`, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n > 0, nil
|
|
}
|
|
|
|
// DeleteExpiredFiles removes the files past their expiry.
|
|
func (s *DB) DeleteExpiredFiles(ctx context.Context, now time.Time) (int64, error) {
|
|
res, err := s.db.ExecContext(ctx, `DELETE FROM files WHERE expires_at > 0 AND expires_at < ?`, millis(now))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
// Batch is one batch of the Batch API.
|
|
type Batch struct {
|
|
ID string
|
|
Owner string
|
|
KeyID string
|
|
Endpoint string
|
|
InputFileID string
|
|
CompletionWindow string
|
|
Status string
|
|
OutputFileID string
|
|
ErrorFileID string
|
|
Total int
|
|
Completed int
|
|
Failed int
|
|
CreatedAt time.Time
|
|
InProgressAt time.Time
|
|
FinalizingAt time.Time
|
|
CompletedAt time.Time
|
|
FailedAt time.Time
|
|
ExpiredAt time.Time
|
|
CancellingAt time.Time
|
|
CancelledAt time.Time
|
|
ExpiresAt time.Time
|
|
// Metadata is the client's, as JSON; Errors why validation failed, as
|
|
// JSON.
|
|
Metadata string
|
|
Errors string
|
|
}
|
|
|
|
// Batch states, as OpenAI names them.
|
|
const (
|
|
BatchValidating = "validating"
|
|
BatchFailed = "failed"
|
|
BatchInProgress = "in_progress"
|
|
BatchFinalizing = "finalizing"
|
|
BatchCompleted = "completed"
|
|
BatchExpired = "expired"
|
|
BatchCancelling = "cancelling"
|
|
BatchCancelled = "cancelled"
|
|
)
|
|
|
|
// BatchDone is whether a state is an end.
|
|
func BatchDone(status string) bool {
|
|
switch status {
|
|
case BatchFailed, BatchCompleted, BatchExpired, BatchCancelled:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
const batchCols = `id, owner, key_id, endpoint, input_file_id, completion_window, status, output_file_id,
|
|
error_file_id, total, completed, failed, created_at, in_progress_at, finalizing_at, completed_at,
|
|
failed_at, expired_at, cancelling_at, cancelled_at, expires_at, metadata, errors`
|
|
|
|
func scanBatch(sc interface{ Scan(...any) error }) (Batch, error) {
|
|
var b Batch
|
|
var t [10]int64
|
|
err := sc.Scan(&b.ID, &b.Owner, &b.KeyID, &b.Endpoint, &b.InputFileID, &b.CompletionWindow, &b.Status,
|
|
&b.OutputFileID, &b.ErrorFileID, &b.Total, &b.Completed, &b.Failed,
|
|
&t[0], &t[1], &t[2], &t[3], &t[4], &t[5], &t[6], &t[7], &t[8], &b.Metadata, &b.Errors)
|
|
if err != nil {
|
|
return Batch{}, err
|
|
}
|
|
at := func(v int64) time.Time {
|
|
if v == 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.UnixMilli(v)
|
|
}
|
|
b.CreatedAt, b.InProgressAt, b.FinalizingAt, b.CompletedAt = at(t[0]), at(t[1]), at(t[2]), at(t[3])
|
|
b.FailedAt, b.ExpiredAt, b.CancellingAt, b.CancelledAt, b.ExpiresAt = at(t[4]), at(t[5]), at(t[6]), at(t[7]), at(t[8])
|
|
return b, nil
|
|
}
|
|
|
|
// PutBatch records a new batch.
|
|
func (s *DB) PutBatch(ctx context.Context, b Batch) error {
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO batches (id, owner, key_id, endpoint, input_file_id,
|
|
completion_window, status, created_at, expires_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
b.ID, b.Owner, b.KeyID, b.Endpoint, b.InputFileID, b.CompletionWindow, b.Status,
|
|
millis(b.CreatedAt), millis(b.ExpiresAt), b.Metadata)
|
|
return err
|
|
}
|
|
|
|
// UpdateBatch writes a batch's progress: its state, counts, files, times
|
|
// and errors.
|
|
func (s *DB) UpdateBatch(ctx context.Context, b Batch) error {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE batches SET status = ?, output_file_id = ?, error_file_id = ?,
|
|
total = ?, completed = ?, failed = ?, in_progress_at = ?, finalizing_at = ?, completed_at = ?,
|
|
failed_at = ?, expired_at = ?, cancelling_at = ?, cancelled_at = ?, errors = ? WHERE id = ?`,
|
|
b.Status, b.OutputFileID, b.ErrorFileID, b.Total, b.Completed, b.Failed,
|
|
optMillis(b.InProgressAt), optMillis(b.FinalizingAt), optMillis(b.CompletedAt), optMillis(b.FailedAt),
|
|
optMillis(b.ExpiredAt), optMillis(b.CancellingAt), optMillis(b.CancelledAt), b.Errors, b.ID)
|
|
return err
|
|
}
|
|
|
|
// CancelBatch asks for a batch to stop: cancelling, unless it has ended
|
|
// already. It says the batch as it stands.
|
|
func (s *DB) CancelBatch(ctx context.Context, id string, now time.Time) (Batch, error) {
|
|
if _, err := s.db.ExecContext(ctx, `UPDATE batches SET status = ?, cancelling_at = ?
|
|
WHERE id = ? AND status IN (?, ?, ?)`, BatchCancelling, millis(now), id,
|
|
BatchValidating, BatchInProgress, BatchFinalizing); err != nil {
|
|
return Batch{}, err
|
|
}
|
|
return s.BatchByID(ctx, id)
|
|
}
|
|
|
|
// BatchByID finds a batch, ErrNotFound when there is none.
|
|
func (s *DB) BatchByID(ctx context.Context, id string) (Batch, error) {
|
|
b, err := scanBatch(s.db.QueryRowContext(ctx, `SELECT `+batchCols+` FROM batches WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Batch{}, ErrNotFound
|
|
}
|
|
return b, err
|
|
}
|
|
|
|
// Batches lists an owner's batches, the newest first, those made before
|
|
// the batch after when it is given.
|
|
func (s *DB) Batches(ctx context.Context, owner, after string, limit int) ([]Batch, error) {
|
|
q := `SELECT ` + batchCols + ` FROM batches WHERE owner = ?`
|
|
args := []any{owner}
|
|
if after != "" {
|
|
q += ` AND created_at < (SELECT created_at FROM batches WHERE id = ?)`
|
|
args = append(args, after)
|
|
}
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
q += ` ORDER BY created_at DESC LIMIT ?`
|
|
args = append(args, limit)
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Batch
|
|
for rows.Next() {
|
|
b, err := scanBatch(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ClaimBatch takes the lease on one batch that has work left and that no
|
|
// gateway holds, for owner until the time given. ok is false when there is
|
|
// none.
|
|
func (s *DB) ClaimBatch(ctx context.Context, owner string, now, until time.Time) (Batch, bool, error) {
|
|
var id string
|
|
err := s.db.QueryRowContext(ctx, `UPDATE batches SET lease_owner = ?, lease_until = ?
|
|
WHERE id = (SELECT id FROM batches WHERE status IN (?, ?, ?, ?) AND lease_until < ?
|
|
ORDER BY created_at LIMIT 1)
|
|
AND lease_until < ?
|
|
RETURNING id`, owner, millis(until), BatchValidating, BatchInProgress, BatchFinalizing, BatchCancelling,
|
|
millis(now), millis(now)).Scan(&id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Batch{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Batch{}, false, err
|
|
}
|
|
b, err := s.BatchByID(ctx, id)
|
|
return b, err == nil, err
|
|
}
|
|
|
|
// RenewBatchLease keeps a lease held; false when it is held no longer.
|
|
func (s *DB) RenewBatchLease(ctx context.Context, id, owner string, until time.Time) (bool, error) {
|
|
res, err := s.db.ExecContext(ctx, `UPDATE batches SET lease_until = ? WHERE id = ? AND lease_owner = ?`,
|
|
millis(until), id, owner)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n > 0, nil
|
|
}
|
|
|
|
// ReleaseBatch gives a lease up.
|
|
func (s *DB) ReleaseBatch(ctx context.Context, id, owner string) error {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE batches SET lease_until = 0, lease_owner = '' WHERE id = ? AND lease_owner = ?`, id, owner)
|
|
return err
|
|
}
|
|
|
|
// BatchResult is one line of a batch, served.
|
|
type BatchResult struct {
|
|
Line int
|
|
OK bool
|
|
Data string
|
|
}
|
|
|
|
// PutBatchResult keeps one served line; a line kept already is left as it
|
|
// was.
|
|
func (s *DB) PutBatchResult(ctx context.Context, batchID string, r BatchResult) error {
|
|
ok := 0
|
|
if r.OK {
|
|
ok = 1
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO batch_results (batch_id, line, ok, data) VALUES (?, ?, ?, ?)
|
|
ON CONFLICT (batch_id, line) DO NOTHING`, batchID, r.Line, ok, r.Data)
|
|
return err
|
|
}
|
|
|
|
// BatchResults are a batch's served lines, in order.
|
|
func (s *DB) BatchResults(ctx context.Context, batchID string) ([]BatchResult, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT line, ok, data FROM batch_results WHERE batch_id = ? ORDER BY line`, batchID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []BatchResult
|
|
for rows.Next() {
|
|
var r BatchResult
|
|
var ok int
|
|
if err := rows.Scan(&r.Line, &ok, &r.Data); err != nil {
|
|
return nil, err
|
|
}
|
|
r.OK = ok == 1
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DeleteBatchResults forgets a batch's lines, once its files are written.
|
|
func (s *DB) DeleteBatchResults(ctx context.Context, batchID string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM batch_results WHERE batch_id = ?`, batchID)
|
|
return err
|
|
}
|
|
|
|
func optMillis(t time.Time) int64 {
|
|
if t.IsZero() {
|
|
return 0
|
|
}
|
|
return t.UnixMilli()
|
|
}
|