Files
silo-server/internal/userstore/pgstore/profile_libraries.go
T
f637404c46 fix(userstore): batch allowed-library and profile lookups when listing (#380)
* fix(userstore): batch allowed-library and profile lookups when listing

Listing profiles cost 1 + P queries: one allowed-libraries lookup fired
per profile row while the cursor was still open, which on Postgres also
checks out a second pooled connection mid-scan. The admin sessions
dashboard makes it worse, calling ListProfiles once per streaming user
just to resolve names. The SQLite store had the same pattern for both
profiles and collections, even though the Postgres collections path was
already written with array_agg to avoid exactly this.

Collect the rows first, then fetch the child lists in one batched query
and stitch them together in Go. No behaviour change, just fewer round
trips.

* fix(userstore): avoid sqlite batch variable limits

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-20 11:25:24 -04:00

105 lines
3.2 KiB
Go

package pgstore
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type profileLibraryQuerier interface {
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}
func listProfileAllowedLibraries(ctx context.Context, q profileLibraryQuerier, userID int, profileID string) ([]int, error) {
rows, err := q.Query(ctx,
`SELECT library_id
FROM user_profile_allowed_libraries
WHERE user_id = $1 AND profile_id = $2
ORDER BY library_id ASC`,
userID,
profileID,
)
if err != nil {
return nil, fmt.Errorf("listing allowed libraries for profile %s: %w", profileID, err)
}
defer rows.Close()
var libraryIDs []int
for rows.Next() {
var libraryID int
if err := rows.Scan(&libraryID); err != nil {
return nil, fmt.Errorf("scanning allowed library for profile %s: %w", profileID, err)
}
libraryIDs = append(libraryIDs, libraryID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating allowed libraries for profile %s: %w", profileID, err)
}
return libraryIDs, nil
}
// attachAllowedLibraries fills AllowedLibraryIDs for every profile with a
// single batched query, avoiding the N+1 round trip a per-profile lookup
// would create.
func (s *PostgresUserStore) attachAllowedLibraries(ctx context.Context, profiles []userstore.Profile) error {
if len(profiles) == 0 {
return nil
}
ids := make([]string, len(profiles))
for i := range profiles {
ids[i] = profiles[i].ID
}
rows, err := s.pool.Query(ctx,
`SELECT profile_id, library_id
FROM user_profile_allowed_libraries
WHERE user_id = $1 AND profile_id = ANY($2)
ORDER BY library_id ASC`,
s.userID,
ids,
)
if err != nil {
return fmt.Errorf("listing allowed libraries for user %d: %w", s.userID, err)
}
defer rows.Close()
var allowedLibraries []userstore.ProfileAllowedLibrary
for rows.Next() {
var allowedLibrary userstore.ProfileAllowedLibrary
if err := rows.Scan(&allowedLibrary.ProfileID, &allowedLibrary.LibraryID); err != nil {
return fmt.Errorf("scanning allowed library for user %d: %w", s.userID, err)
}
allowedLibraries = append(allowedLibraries, allowedLibrary)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating allowed libraries for user %d: %w", s.userID, err)
}
userstore.AttachAllowedLibraries(profiles, allowedLibraries)
return nil
}
func replaceProfileAllowedLibraries(ctx context.Context, q profileLibraryQuerier, userID int, profileID string, libraryIDs []int) error {
if _, err := q.Exec(ctx,
"DELETE FROM user_profile_allowed_libraries WHERE user_id = $1 AND profile_id = $2",
userID,
profileID,
); err != nil {
return fmt.Errorf("clearing allowed libraries for profile %s: %w", profileID, err)
}
for _, libraryID := range libraryIDs {
if _, err := q.Exec(ctx,
"INSERT INTO user_profile_allowed_libraries (user_id, profile_id, library_id) VALUES ($1, $2, $3)",
userID,
profileID,
libraryID,
); err != nil {
return fmt.Errorf("inserting allowed library %d for profile %s: %w", libraryID, profileID, err)
}
}
return nil
}