blob: 64a8da28cfd3f0a25aa81ecf1fae405463a3f14b [file] [edit]
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"golang.org/x/pkgsite/internal/database"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/log"
)
// ImporterCountOptions are parameters for UpdateSearchDocumentsImportedByCount.
type ImporterCountOptions struct {
// Number of rows to update in search_documents at once
// (in a single transaction, with the rows locked).
// If zero, a reasonable default is used.
BatchSize int
// A hard limit on the number of rows to update (per kind: applies separately to modules
// and packages). If zero, then no limit.
Limit int
// Maximum time to spend updating. If zero, then no max.
MaxTime time.Duration
// How old a count has to be before updating it.
// If zero, update everything.
Staleness time.Duration
}
// UpdateSearchDocumentsImportedByCount updates imported_by_count and
// imported_by_count_updated_at.
// It also updates imported_by_module_count and imported_by_module_count_updated_at.
//
// It does so by completely recalculating the imported-by counts
// from the imports_unique table.
//
// UpdateSearchDocumentsImportedByCount returns the number of rows updated.
func (db *DB) UpdateSearchDocumentsImportedByCount(ctx context.Context, opts *ImporterCountOptions) (nPackageUpdated, nModuleUpdated int64, err error) {
defer derrors.WrapStack(&err, "UpdateSearchDocumentsImportedByCount(ctx, %+v)", opts)
var o ImporterCountOptions
if opts != nil {
o = *opts
}
if o.BatchSize == 0 {
o.BatchSize = 1000
}
// As per the doc, a zero MaxTime means (effectively) no max.
if o.MaxTime == 0 {
o.MaxTime = 24 * 365 * 10 * time.Hour
}
// Give modules up to 3/4 of the time, since they have many more uncomputed counts.
// Packages get whatever time remains.
// TODO(jba): restore to 1/2 when modules have caught up.
start := time.Now()
om := o
om.MaxTime = 3 * o.MaxTime / 4
log.Infof(ctx, "updating imported-by module counts, opts = %+v", om)
nModuleUpdated, err = db.updateImportedByModuleCounts(ctx, om)
if err != nil {
return 0, 0, err
}
log.Infof(ctx, "updated %d imported-by module counts", nModuleUpdated)
op := o
op.MaxTime = o.MaxTime - time.Since(start)
log.Infof(ctx, "updating imported-by package counts, opts = %+v", op)
nPackageUpdated, err = db.updateImportedByPackageCounts(ctx, op)
if err != nil {
return 0, nModuleUpdated, err
}
log.Infof(ctx, "updated %d imported-by package counts", nPackageUpdated)
return nPackageUpdated, nModuleUpdated, nil
}
// updateImportedByCounts updates the given count column (e.g. imported_by_count
// or imported_by_module_count) and its corresponding _updated_at column in
// search_documents for every package in computed_imported_by_counts.
func updateImportedByCounts(ctx context.Context, db *database.DB, column string) (int64, error) {
// This statement does not lock search_documents itself. Callers that run
// concurrently with module inserts must have already locked the rows that
// will be updated, or deadlock can result. See updateImporterCounts.
updateStmt := fmt.Sprintf(`
UPDATE search_documents s
SET
%[1]s = c.count,
%[1]s_updated_at = CURRENT_TIMESTAMP
FROM computed_imported_by_counts c
INNER JOIN paths p ON p.path = c.package_path
WHERE s.package_path_id = p.id;`, column)
nUpdated, err := db.Exec(ctx, updateStmt)
if err != nil {
return 0, fmt.Errorf("error updating %[1]s and %[1]s_updated_at for search documents: %w", column, err)
}
return nUpdated, nil
}
func (db *DB) updateImportedByPackageCounts(ctx context.Context, opts ImporterCountOptions) (nUpdated int64, err error) {
defer derrors.WrapStack(&err, "updateImportedByPackageCounts(ctx, %+v)", opts)
// See the comment on the query in updateImportedByModuleCounts for an explanation of this query.
// The important difference is the exclusion of packages in the same module, approximated by
// checking whether one import path is a prefix of the other. We could now get a precise answer
// by joining with search_documents and comparing module_paths directly, but that would change
// some numbers and we want to keep these counts the same while we introduce module counts.
// TODO(golang/go#81962): improve the logic for seeing if two packages are in the same module.
const query = `
CREATE TEMPORARY TABLE computed_imported_by_counts (package_path, count) ON COMMIT DROP AS
WITH stale AS (
SELECT package_path
FROM search_documents
WHERE imported_by_count_updated_at < $1
OR imported_by_count_updated_at IS NULL
ORDER BY imported_by_count_updated_at NULLS FIRST
LIMIT $2
FOR NO KEY UPDATE SKIP LOCKED
)
SELECT stale.package_path, COUNT(DISTINCT u.from_path)
FROM imports_unique u
RIGHT JOIN stale ON (
u.to_path = stale.package_path
AND NOT (
(u.from_module_path = 'std' AND strpos(split_part(u.to_path, '/', 1), '.') = 0)
OR starts_with(u.to_path || '/', u.from_module_path || '/')
)
AND EXISTS (
SELECT 1
FROM search_documents s
WHERE s.package_path = u.from_path
)
)
GROUP BY 1;
`
return db.updateImporterCounts(ctx, query, "imported_by_count", opts)
}
// Update the number of importing modules for packages in search_documents.
//
// For each package P in search_documents, we count all unique modules that have
// a package that imports P. For example, if a package is imported by N different
// packages, but all N of the packages belong to the same module, the count will
// be 1. This includes P's module as well, so we can distinguish packages that have
// at least one importer from those that have zero. (Though note that since we don't
// track test packages, a package with zero importers might still be used in tests.)
func (db *DB) updateImportedByModuleCounts(ctx context.Context, opts ImporterCountOptions) (nUpdated int64, err error) {
defer derrors.WrapStack(&err, "updateImportedByModuleCounts(ctx, %+v)", opts)
// This query is (mostly) efficient because the primary key on imports_unique is (to_path, from_path, from_module_path),
// so finding a package path is fast and its importers are contiguous, making
// the COUNT fast. But the NULLS FIRST means we can't use the index on the X_updated_at
// column. TODO: if this query is slow, use a UNION to write the two conditions
// as separate queries.
//
// The right join ensures that we get rows for packages with no imports,
// that therefore don't appear in imports_unique.
//
// The EXISTS check makes sure we only count modules in search_documents,
// avoiding alternative modules that might be in imports_unique.
const query = `
CREATE TEMPORARY TABLE computed_imported_by_counts (package_path, count) ON COMMIT DROP AS
WITH stale AS (
SELECT package_path
FROM search_documents
WHERE imported_by_module_count_updated_at < $1
OR imported_by_module_count_updated_at IS NULL
ORDER BY imported_by_module_count_updated_at NULLS FIRST
LIMIT $2
FOR NO KEY UPDATE SKIP LOCKED
)
SELECT stale.package_path, COUNT(DISTINCT u.from_module_path)
FROM imports_unique u
RIGHT JOIN stale ON (
u.to_path = stale.package_path
AND EXISTS (
SELECT 1
FROM search_documents s
WHERE s.module_path = u.from_module_path
)
)
GROUP BY 1;
`
return db.updateImporterCounts(ctx, query, "imported_by_module_count", opts)
}
// Update all rows in search_documents that haven't been updated in opts.Staleness.
// A row is updated even if it hasn't changed, in order to set its
// update time for future runs.
//
// Run the query to update importer counts repeatedly within opts.MaxTime.
// countCol is the column in search_documents to update.
//
// The query must have two parameters.
// $1 is the cutoff time, the current time minus opts.Staleness.
// Computing the cutoff date outside the query, instead of using an expression like
// "age(X) < Y", ensures that Postgres uses the index on the updated_at column.
//
// $2 is opts.BatchSize, the maximum number of rows it should process.
//
// To avoid deadlock with concurrent module inserts, which modify rows of
// search_documents in an order we can't easily match, the query must select
// the rows to update with FOR NO KEY UPDATE SKIP LOCKED. That locks the rows
// for the rest of the transaction, so the subsequent UPDATE never waits on
// another transaction for search_documents rows. Rows that are skipped remain
// stale and are picked up in a later batch or run. If every remaining stale row
// is locked, the loop ends early; a later run will pick them up.
// See https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE.
//
// Deadlock is still possible, though rare: when imported_by_count changes, a
// trigger updates symbol_search_documents, whose rows a concurrent module insert
// may have locked before it tries to lock the search_documents row we hold.
// If Postgres aborts our transaction to break such a deadlock, we just try
// again; the rows of the failed batch are still stale.
func (db *DB) updateImporterCounts(ctx context.Context, query, countCol string, opts ImporterCountOptions) (nUpdated int64, err error) {
defer derrors.WrapStack(&err, "updateImporterCounts(ctx, %+v)", opts)
// Give up after this many consecutive deadlocks, in case something
// other than the rare race described above is going on.
const maxDeadlocks = 3
nDeadlocks := 0
cutoff := time.Now().Add(-opts.Staleness)
deadline := time.Now().Add(opts.MaxTime)
for time.Now().Before(deadline) {
batchSize := opts.BatchSize
if opts.Limit > 0 {
rem := opts.Limit - int(nUpdated)
if rem <= 0 {
break
}
batchSize = min(batchSize, rem)
}
var nu int64
err = db.db.Transact(ctx, sql.LevelDefault, func(tx *database.DB) error {
if _, err := tx.Exec(ctx, query, cutoff, batchSize); err != nil {
return fmt.Errorf("creating temp table: %w", err)
}
var err error
nu, err = updateImportedByCounts(ctx, tx, countCol)
return err
})
if database.IsDeadlock(err) && nDeadlocks < maxDeadlocks {
nDeadlocks++
log.Warningf(ctx, "updating %s: deadlock (%d in a row); retrying: %v", countCol, nDeadlocks, err)
time.Sleep(100 * time.Millisecond)
continue
}
if err != nil {
return nUpdated, err
}
nDeadlocks = 0
if nu == 0 {
break
}
nUpdated += nu
}
return nUpdated, nil
}
// UpdateSearchDocumentsImportedByCountWithCounts inserts the given package import
// counts into search_documents, bypassing the logic that computes the counts. (Thus
// the counts may bear no relationship to the rest of the database.)
// It is for testing only (see tests/search).
func (db *DB) UpdateSearchDocumentsImportedByCountWithCounts(ctx context.Context, counts map[string]int) (err error) {
defer derrors.WrapStack(&err, "UpdateSearchDocumentsImportedByCountWithCounts")
const createTableQuery = `
CREATE TEMPORARY TABLE computed_imported_by_counts (
package_path TEXT NOT NULL,
count INTEGER NOT NULL
) ON COMMIT DROP;
`
return db.db.Transact(ctx, sql.LevelDefault, func(tx *database.DB) error {
if _, err := tx.Exec(ctx, createTableQuery); err != nil {
return fmt.Errorf("CREATE TABLE: %v", err)
}
var values []any
for p, c := range counts {
values = append(values, p, c)
}
columns := []string{"package_path", "count"}
if err := tx.BulkInsert(ctx, "computed_imported_by_counts", columns, values, ""); err != nil {
return err
}
_, err = updateImportedByCounts(ctx, tx, "imported_by_count")
return err
})
}