feat(api): 集群列表读改走 informer 缓存,审核队列、我的提交与备份列表改为服务端分页筛选并一次批量查构建,面板三页跟进
This commit is contained in:
41 files changed
+1642
-295
No files matched your search
@@ -73,10 +73,39 @@ func (s *PGStore) ListSubmissions(ctx context.Context) ([]Submission, error) {
|
||||
return s.querySubmissions(ctx, q)
|
||||
}
|
||||
|
||||
func (s *PGStore) ListSubmissionsBy(ctx context.Context, submittedBy string) ([]Submission, error) {
|
||||
const q = `SELECT ` + submissionColumns + `
|
||||
FROM image_submissions WHERE submitted_by = $1 ORDER BY created_at DESC`
|
||||
return s.querySubmissions(ctx, q, submittedBy)
|
||||
// PageSubmissions reads the scope's status counts, the filtered total and one
|
||||
// page. id breaks created_at ties so a page boundary never repeats or skips a row.
|
||||
func (s *PGStore) PageSubmissions(ctx context.Context, opts ListOpts) (Page, error) {
|
||||
page := Page{Counts: map[Status]int{}}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT status, count(*) FROM image_submissions
|
||||
WHERE $1::text = '' OR submitted_by = $1::text GROUP BY status`, opts.SubmittedBy)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var st string
|
||||
var n int
|
||||
if err := rows.Scan(&st, &n); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
page.Counts[Status(st)] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
const match = ` WHERE ($1::text = '' OR submitted_by = $1::text)
|
||||
AND ($2::text = '' OR status::text = $2::text)
|
||||
AND ($3::text = '' OR strpos(lower(id), lower($3::text)) > 0
|
||||
OR strpos(lower(submitted_by), lower($3::text)) > 0
|
||||
OR strpos(lower(display_name), lower($3::text)) > 0)`
|
||||
args := []any{opts.SubmittedBy, string(opts.Status), opts.Query}
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM image_submissions`+match, args...).Scan(&page.Total); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
page.Submissions, err = s.querySubmissions(ctx, `SELECT `+submissionColumns+` FROM image_submissions`+match+`
|
||||
ORDER BY created_at DESC, id DESC LIMIT $4 OFFSET $5`, append(args, opts.Limit, opts.Offset)...)
|
||||
return page, err
|
||||
}
|
||||
|
||||
// cas executes a single-statement compare-and-set — an UPDATE or DELETE whose
|
||||
|
||||
+60
-10
@@ -210,6 +210,34 @@ type Submission struct {
|
||||
ContextSHA256 string `json:"context_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// ListOpts selects a page of submissions, newest first. SubmittedBy scopes it to
|
||||
// one user (the "my uploads" view); empty is the admin queue across all users.
|
||||
// Status matches exactly; Query matches any part of the id, the submitter or the
|
||||
// display name, ignoring case. Empty filters match everything.
|
||||
type ListOpts struct {
|
||||
SubmittedBy string
|
||||
Status Status
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Page is one page of submissions plus what a list header shows: Total is how
|
||||
// many match the filters in all (for the pager), Counts how many of the scope's
|
||||
// submissions sit in each status regardless of Status and Query (for the summary
|
||||
// cards, which must not shrink as the reviewer narrows the list).
|
||||
type Page struct {
|
||||
Submissions []Submission
|
||||
Total int
|
||||
Counts map[Status]int
|
||||
}
|
||||
|
||||
// DefaultListLimit and MaxListLimit bound one page of submissions.
|
||||
const (
|
||||
DefaultListLimit = 20
|
||||
MaxListLimit = 100
|
||||
)
|
||||
|
||||
// Store is the business-layer persistence the Manager depends on. It is an
|
||||
// interface so the Manager is tested against an in-memory fake; the Postgres
|
||||
// implementation (PGStore) is integration-tested only.
|
||||
@@ -222,10 +250,13 @@ type Store interface {
|
||||
CreateSubmission(ctx context.Context, s *Submission, maxPending int) (int, error)
|
||||
// GetSubmission loads one submission, or ErrNotFound.
|
||||
GetSubmission(ctx context.Context, id string) (*Submission, error)
|
||||
// ListSubmissions returns every submission, newest first (admin queue).
|
||||
// ListSubmissions returns every submission, newest first — the full scan the
|
||||
// storage budget and the rejected-blob reaper need. Lists shown to a person
|
||||
// go through PageSubmissions.
|
||||
ListSubmissions(ctx context.Context) ([]Submission, error)
|
||||
// ListSubmissionsBy returns one user's submissions, newest first.
|
||||
ListSubmissionsBy(ctx context.Context, submittedBy string) ([]Submission, error)
|
||||
// PageSubmissions returns one page of submissions, newest first, with the
|
||||
// match total and the scope's per-status counts (see Page).
|
||||
PageSubmissions(ctx context.Context, opts ListOpts) (Page, error)
|
||||
// ApproveSubmission atomically flips pending_review -> approved, recording the
|
||||
// derived image_ref, the reviewer and reviewed_at. It reports whether THIS
|
||||
// call won the transition: false means a concurrent review already moved the
|
||||
@@ -951,17 +982,36 @@ func (m *Manager) deleteBlob(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every submission, newest first (the admin review queue).
|
||||
func (m *Manager) List(ctx context.Context) ([]Submission, error) {
|
||||
return m.Store.ListSubmissions(ctx)
|
||||
// List returns one page of every user's submissions, newest first (the admin
|
||||
// review queue). opts.SubmittedBy is ignored: the queue is the whole lane.
|
||||
func (m *Manager) List(ctx context.Context, opts ListOpts) (Page, error) {
|
||||
opts.SubmittedBy = ""
|
||||
return m.page(ctx, opts)
|
||||
}
|
||||
|
||||
// ListBy returns one user's submissions, newest first (the "my uploads" view).
|
||||
func (m *Manager) ListBy(ctx context.Context, submittedBy string) ([]Submission, error) {
|
||||
// ListBy returns one page of one user's submissions, newest first (the "my
|
||||
// uploads" view). The scope is the submittedBy argument, never opts.
|
||||
func (m *Manager) ListBy(ctx context.Context, submittedBy string, opts ListOpts) (Page, error) {
|
||||
if strings.TrimSpace(submittedBy) == "" {
|
||||
return nil, invalidf("submitter identity is required")
|
||||
return Page{}, invalidf("submitter identity is required")
|
||||
}
|
||||
return m.Store.ListSubmissionsBy(ctx, submittedBy)
|
||||
opts.SubmittedBy = submittedBy
|
||||
return m.page(ctx, opts)
|
||||
}
|
||||
|
||||
func (m *Manager) page(ctx context.Context, opts ListOpts) (Page, error) {
|
||||
switch opts.Status {
|
||||
case "", StatusPendingReview, StatusApproved, StatusRejected:
|
||||
default:
|
||||
return Page{}, invalidf("unknown status %q", opts.Status)
|
||||
}
|
||||
opts.Query = strings.TrimSpace(opts.Query)
|
||||
if opts.Limit <= 0 {
|
||||
opts.Limit = DefaultListLimit
|
||||
}
|
||||
opts.Limit = min(opts.Limit, MaxListLimit)
|
||||
opts.Offset = max(opts.Offset, 0)
|
||||
return m.Store.PageSubmissions(ctx, opts)
|
||||
}
|
||||
|
||||
// Compile-time proof that the production build subsystem satisfies Builds.
|
||||
|
||||
@@ -104,7 +104,8 @@ type fakeStore struct {
|
||||
linkErr error
|
||||
deleteErr error
|
||||
|
||||
linked []string // "id=buildID" recorder
|
||||
linked []string // "id=buildID" recorder
|
||||
pageOpts ListOpts // the last PageSubmissions call
|
||||
|
||||
// beforeApprove runs between the Manager's read and its CAS, the window a
|
||||
// concurrent re-upload lands in.
|
||||
@@ -151,14 +152,19 @@ func (f *fakeStore) ListSubmissions(_ context.Context) ([]Submission, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListSubmissionsBy(_ context.Context, by string) ([]Submission, error) {
|
||||
var out []Submission
|
||||
// PageSubmissions records the options the Manager settled on and scopes by
|
||||
// submitter; the SQL filters and paging are pgint's to prove.
|
||||
func (f *fakeStore) PageSubmissions(_ context.Context, opts ListOpts) (Page, error) {
|
||||
f.pageOpts = opts
|
||||
page := Page{Counts: map[Status]int{}}
|
||||
for _, s := range f.subs {
|
||||
if s.SubmittedBy == by {
|
||||
out = append(out, *s)
|
||||
if opts.SubmittedBy == "" || s.SubmittedBy == opts.SubmittedBy {
|
||||
page.Submissions = append(page.Submissions, *s)
|
||||
page.Counts[s.Status]++
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
page.Total = len(page.Submissions)
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ApproveSubmission(_ context.Context, id, reviewedBy, imageRef, digest string, at time.Time) (bool, error) {
|
||||
@@ -618,18 +624,52 @@ func TestListBy(t *testing.T) {
|
||||
if _, err := m.Create(context.Background(), CreateRequest{DisplayName: "B", SubmittedBy: "user-2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine, err := m.ListBy(context.Background(), "user-1")
|
||||
// The scope is the argument: a SubmittedBy smuggled into opts cannot widen it.
|
||||
mine, err := m.ListBy(context.Background(), "user-1", ListOpts{SubmittedBy: "user-2"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListBy: %v", err)
|
||||
}
|
||||
if len(mine) != 1 || mine[0].SubmittedBy != "user-1" {
|
||||
if len(mine.Submissions) != 1 || mine.Submissions[0].SubmittedBy != "user-1" || mine.Total != 1 {
|
||||
t.Fatalf("ListBy scoped wrong: %+v", mine)
|
||||
}
|
||||
if _, err := m.ListBy(context.Background(), ""); !errors.Is(err, ErrInvalid) {
|
||||
if _, err := m.ListBy(context.Background(), "", ListOpts{}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("empty submitter err = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// List is the whole lane whatever opts names, and both lists settle the page the
|
||||
// same way: a missing limit takes the default, an oversized one the cap, a
|
||||
// negative offset zero, and the query loses its padding.
|
||||
func TestListPagingOptions(t *testing.T) {
|
||||
m, st, _ := newManager()
|
||||
for _, by := range []string{"user-1", "user-2"} {
|
||||
if _, err := m.Create(context.Background(), CreateRequest{DisplayName: "P", SubmittedBy: by}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
all, err := m.List(context.Background(), ListOpts{SubmittedBy: "user-1", Query: " pack ", Offset: -5})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if all.Total != 2 {
|
||||
t.Fatalf("List total = %d, want 2 (the admin queue ignores a submitter in opts)", all.Total)
|
||||
}
|
||||
want := ListOpts{Query: "pack", Limit: 20, Offset: 0}
|
||||
if st.pageOpts != want {
|
||||
t.Fatalf("List settled %+v, want %+v", st.pageOpts, want)
|
||||
}
|
||||
if _, err := m.ListBy(context.Background(), "user-2", ListOpts{Limit: 5000, Offset: 40, Status: StatusApproved}); err != nil {
|
||||
t.Fatalf("ListBy: %v", err)
|
||||
}
|
||||
want = ListOpts{SubmittedBy: "user-2", Status: StatusApproved, Limit: 100, Offset: 40}
|
||||
if st.pageOpts != want {
|
||||
t.Fatalf("ListBy settled %+v, want %+v", st.pageOpts, want)
|
||||
}
|
||||
if _, err := m.List(context.Background(), ListOpts{Status: "building"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("unknown status err = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadContextStoresUnderDerivedID(t *testing.T) {
|
||||
m, _, _ := newManager()
|
||||
fb := newFakeBlobs()
|
||||
|
||||
Reference in new issue
Block a user