fix: store digital-human upload text in files, slim jsonb for list
App / Format, vet, test, and build (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s

Avoid pulling multi-MB result payloads on uploads?view=summary over remote DB, which caused 30s client timeouts and HTTP 500.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-09-23 19:20:13 +08:00
co-authored by Cursor
parent f7fc992f57
commit 9fb2c1321a
6 changed files with 255 additions and 17 deletions
+77 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
@@ -61,7 +62,68 @@ func validHash(v string) bool {
b, e := hex.DecodeString(v)
return e == nil && len(b) == 32 && strings.ToLower(v) == v
}
func (s *Service) path(id string) string { return filepath.Join(s.root, id+".upload") }
func (s *Service) path(id string) string { return filepath.Join(s.root, id+".upload") }
func (s *Service) materialPath(id string) string { return filepath.Join(s.root, id+".material") }
// FilePath is the durable raw upload bytes on disk.
func (s *Service) FilePath(id string) string { return s.path(id) }
// SaveMaterial stores parsed digital-human source text beside the upload file
// so result jsonb only needs small metadata.
func (s *Service) SaveMaterial(id string, source types.DigitalHumanSource) error {
if id == "" || strings.TrimSpace(source.Name) == "" || source.Text == "" {
return ErrInvalid
}
data, err := json.Marshal(source)
if err != nil {
return err
}
tmp := s.materialPath(id) + ".tmp"
if err = os.WriteFile(tmp, data, 0600); err != nil {
return err
}
return os.Rename(tmp, s.materialPath(id))
}
// LoadMaterial reads the file-backed digital-human source for an upload.
func (s *Service) LoadMaterial(id string) (types.DigitalHumanSource, error) {
data, err := os.ReadFile(s.materialPath(id))
if err != nil {
return types.DigitalHumanSource{}, err
}
var source types.DigitalHumanSource
if err = json.Unmarshal(data, &source); err != nil {
return types.DigitalHumanSource{}, err
}
if strings.TrimSpace(source.Name) == "" || source.Text == "" {
return types.DigitalHumanSource{}, ErrInvalid
}
return source, nil
}
func (s *Service) removeStorage(id string) error {
for _, p := range []string{s.path(id), s.materialPath(id), s.materialPath(id) + ".tmp"} {
if e := os.Remove(p); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
}
return nil
}
// listSelect projects columns for List. Summary mode never loads full result
// jsonb payloads — only a tiny selection object when present.
func (s *Service) listSelect(summary bool) string {
cols := `id, tenant_id, owner_id, kind, target_id, name, size, "offset", chunk_size, sha256, fingerprint, state, error, options, ingest_offset, part, expires_at, lease_until, lease_token, created_at, updated_at`
if !summary {
return cols + ", result"
}
switch s.db.Dialector.Name() {
case "postgres":
return cols + `, CASE WHEN result->>'selection' IN ('full','sampled') THEN jsonb_build_object('selection', result->'selection') ELSE NULL END AS result`
default:
return cols + `, CASE WHEN json_extract(result, '$.selection') IN ('full','sampled') THEN json_object('selection', json_extract(result, '$.selection')) ELSE NULL END AS result`
}
}
func (s *Service) Create(ctx context.Context, in CreateInput) (*Upload, error) {
t, u, e := identity(ctx)
@@ -143,13 +205,13 @@ func (s *Service) Chunks(ctx context.Context, id string, offset int64) ([]Chunk,
return chunks, err
}
func (s *Service) List(ctx context.Context, kind, target string) ([]Upload, error) {
func (s *Service) List(ctx context.Context, kind, target string, summary bool) ([]Upload, error) {
q, e := s.scoped(ctx)
if e != nil {
return nil, e
}
rows := []Upload{}
q = q.Where("state NOT IN ('cancelled','expired')")
q = q.Select(s.listSelect(summary)).Where("state NOT IN ('cancelled','expired')")
if kind != "" {
q = q.Where("kind=?", kind)
}
@@ -161,8 +223,17 @@ func (s *Service) List(ctx context.Context, kind, target string) ([]Upload, erro
return nil, e
}
visible := make([]Upload, 0, len(rows))
type authKey struct{ kind, target string }
authCache := map[authKey]error{}
for _, row := range rows {
if s.authorize != nil && s.authorize(ctx, row.Kind, row.TargetID) == nil {
if s.authorize == nil {
continue
}
key := authKey{row.Kind, row.TargetID}
if _, ok := authCache[key]; !ok {
authCache[key] = s.authorize(ctx, row.Kind, row.TargetID)
}
if authCache[key] == nil {
visible = append(visible, row)
}
}
@@ -296,7 +367,7 @@ func (s *Service) Cancel(ctx context.Context, id string) error {
if row.State == "verifying" || row.State == "processing" {
return ErrConflict
}
if e := os.Remove(s.path(id)); e != nil && !errors.Is(e, os.ErrNotExist) {
if e := s.removeStorage(id); e != nil {
return e
}
if e := tx.Where("upload_id=?", id).Delete(&Chunk{}).Error; e != nil {
@@ -320,7 +391,7 @@ func (s *Service) Cleanup(ctx context.Context) error {
if current.ExpiresAt.After(time.Now()) || (current.LeaseUntil != nil && current.LeaseUntil.After(time.Now())) {
return nil
}
if e := os.Remove(s.path(row.ID)); e != nil && !errors.Is(e, os.ErrNotExist) {
if e := s.removeStorage(row.ID); e != nil {
return e
}
if e := tx.Where("upload_id=?", row.ID).Delete(&Chunk{}).Error; e != nil {