init
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
)
|
||||
|
||||
// This file holds the queries the background jobs use: startup recovery, the
|
||||
// retention sweep and the blob collector.
|
||||
//
|
||||
// They are kept apart from the request-path queries because they answer a
|
||||
// different question. A handler works on state a transaction has just
|
||||
// established; these run against whatever a crash, a restored backup or a
|
||||
// half-finished sweep left behind, so each one has to be safe to run against a
|
||||
// world that already disagrees with itself, and safe to run again after being
|
||||
// interrupted partway.
|
||||
|
||||
// DeploymentRef names a deployment by the two values its directory is built
|
||||
// from, which is all an orphan sweep needs to know.
|
||||
type DeploymentRef struct {
|
||||
ProjectID int64
|
||||
PublicID string
|
||||
}
|
||||
|
||||
// AllDeploymentRefs lists every deployment the database knows about.
|
||||
//
|
||||
// Only the identifying pair, because the caller compares it against directory
|
||||
// names: reading full rows for a sweep that will normally delete nothing would
|
||||
// be a lot of scanning for no answer that changes.
|
||||
func (d *DB) AllDeploymentRefs(ctx context.Context) ([]DeploymentRef, error) {
|
||||
rows, err := d.r.QueryContext(ctx, `SELECT project_id, public_id FROM deployments`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var refs []DeploymentRef
|
||||
for rows.Next() {
|
||||
var ref DeploymentRef
|
||||
if err := rows.Scan(&ref.ProjectID, &ref.PublicID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs, rows.Err()
|
||||
}
|
||||
|
||||
// DeploymentsInState lists deployments in one state across every project,
|
||||
// oldest first. Recovery uses it to find the rows a previous sweep was in the
|
||||
// middle of deleting.
|
||||
func (d *DB) DeploymentsInState(ctx context.Context, state State, limit int) ([]*Deployment, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
rows, err := d.r.QueryContext(ctx,
|
||||
`SELECT `+deploymentColumns+` FROM deployments WHERE state = ? ORDER BY id LIMIT ?`,
|
||||
string(state), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Deployment
|
||||
for rows.Next() {
|
||||
dep, err := scanDeployment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, dep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// InactiveDeployments lists one project's deployments that are not the one it
|
||||
// serves, newest first. This is the input to the retention decision, and the
|
||||
// active deployment is excluded here rather than filtered later so that no
|
||||
// arithmetic on the caller's side can ever select it.
|
||||
func (d *DB) InactiveDeployments(ctx context.Context, projectID int64) ([]*Deployment, error) {
|
||||
rows, err := d.r.QueryContext(ctx,
|
||||
`SELECT `+deploymentColumns+` FROM deployments
|
||||
WHERE project_id = ? AND active = 0 ORDER BY id DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Deployment
|
||||
for rows.Next() {
|
||||
dep, err := scanDeployment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, dep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ExpireStaleDeployments fails uploads that were started before cutoff and
|
||||
// never finished, dropping their manifests so the blobs only they referenced
|
||||
// become collectable.
|
||||
//
|
||||
// Nothing distinguishes a CI job that died from one that is merely slow except
|
||||
// how long it has been, which is why the cutoff wants to be generous: expiring
|
||||
// an upload that was still going to succeed turns a slow deploy into a failed
|
||||
// one. Their blobs survive regardless — they are content-addressed, so the
|
||||
// retry finds them already present and skips them.
|
||||
func (d *DB) ExpireStaleDeployments(ctx context.Context, cutoff time.Time, reason string) (int, error) {
|
||||
var n int
|
||||
err := d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
// Tx re-runs this on a busy database, so the count is rebuilt from
|
||||
// scratch on each attempt rather than added to.
|
||||
n = 0
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id FROM deployments WHERE state IN (?, ?) AND created_at < ?`,
|
||||
StatePending, StateUploading, cutoff.Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, id := range ids {
|
||||
// The manifest rows go first: their delete trigger is what takes the
|
||||
// refcounts back down, and it is the only reason expiry frees
|
||||
// anything.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM deployment_files WHERE deployment_id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE deployments SET state = ?, error = ? WHERE id = ?`,
|
||||
StateFailed, truncate(reason, 1024), id); err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MarkDeploymentDeleting claims a deployment for deletion.
|
||||
//
|
||||
// The state change is committed before any file is removed, so a crash midway
|
||||
// through leaves a row that says what was happening and recovery can finish the
|
||||
// job. The active deployment can never be claimed: that check is here, in the
|
||||
// same statement, rather than in the caller.
|
||||
func (d *DB) MarkDeploymentDeleting(ctx context.Context, id int64) error {
|
||||
return d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE deployments SET state = ? WHERE id = ? AND active = 0`, StateDeleting, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
// Nothing matched, so either the row is gone — mapErr turns that into
|
||||
// ErrNotFound — or it is the active one, which is the only condition the
|
||||
// statement excludes.
|
||||
var active bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT active FROM deployments WHERE id = ?`, id).Scan(&active); err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
return fmt.Errorf("%w: this deployment is the one the project is serving", ErrConflict)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteDeployment removes a deployment's rows once its tree is gone.
|
||||
//
|
||||
// The manifest rows are deleted explicitly rather than left to ON DELETE
|
||||
// CASCADE. SQLite does not fire a child table's triggers for cascaded deletes
|
||||
// unless recursive_triggers is on, and relying on that pragma would make every
|
||||
// blob's refcount depend on a connection setting; deleting the rows here makes
|
||||
// the decrement unconditional.
|
||||
func (d *DB) DeleteDeployment(ctx context.Context, id int64) error {
|
||||
return d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
var active bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT active FROM deployments WHERE id = ?`, id).Scan(&active); err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
if active {
|
||||
return fmt.Errorf("%w: this deployment is the one the project is serving", ErrConflict)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM deployment_files WHERE deployment_id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM deployments WHERE id = ?`, id)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// EachPresentBlob calls fn for every digest the database believes is on disk.
|
||||
//
|
||||
// Streamed rather than returned as a slice because the caller wants to stat each
|
||||
// one and a large store has a lot of them; a read here never blocks a write, so
|
||||
// holding the cursor open across the filesystem calls costs nothing.
|
||||
func (d *DB) EachPresentBlob(ctx context.Context, fn func(cas.Digest) error) error {
|
||||
rows, err := d.r.QueryContext(ctx, `SELECT digest FROM blobs WHERE present = 1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
dg, err := cas.FromBytes(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(dg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// MarkBlobsAbsent records that content the database claimed is on disk is not.
|
||||
//
|
||||
// The rows stay. A manifest may still reference them, and the fix is not to
|
||||
// forget the blob but to ask for it again: the next deploy that names one of
|
||||
// these digests is told to upload it, and every deployment that referenced it
|
||||
// becomes deployable again as soon as one does.
|
||||
func (d *DB) MarkBlobsAbsent(ctx context.Context, digests []cas.Digest) error {
|
||||
if len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
stmt, err := tx.PrepareContext(ctx, `UPDATE blobs SET present = 0 WHERE digest = ?`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, dg := range digests {
|
||||
if _, err := stmt.ExecContext(ctx, dg.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UnreferencedBlobs lists blobs no manifest has referenced since before cutoff.
|
||||
//
|
||||
// Both halves of the condition matter. A refcount of zero says nothing points at
|
||||
// the content now; the cutoff adds that nothing has pointed at it for a while,
|
||||
// which is what gives a request that has already resolved a digest and is about
|
||||
// to open it time to finish. Oldest first, so a backlog drains in a stable order
|
||||
// rather than the collector revisiting the same rows every sweep.
|
||||
func (d *DB) UnreferencedBlobs(ctx context.Context, cutoff time.Time, limit int) ([]Blob, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5000
|
||||
}
|
||||
rows, err := d.r.QueryContext(ctx, `
|
||||
SELECT digest, size, present FROM blobs
|
||||
WHERE refcount = 0 AND last_ref_at < ?
|
||||
ORDER BY last_ref_at LIMIT ?`, cutoff.Unix(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Blob
|
||||
for rows.Next() {
|
||||
var b Blob
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw, &b.Size, &b.Present); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.Digest, err = cas.FromBytes(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteBlob removes one blob's row and its content together, reporting whether
|
||||
// there was anything to remove.
|
||||
//
|
||||
// remove runs inside the write transaction, and that is the whole point of this
|
||||
// signature. There is exactly one write connection, so no manifest can be
|
||||
// accepted between the row disappearing and the file doing so — which closes the
|
||||
// window where a deployment could come to reference content that was already on
|
||||
// its way out. remove has to be idempotent, because Tx re-runs its function on a
|
||||
// busy database; cas.Store.Remove is, deliberately.
|
||||
//
|
||||
// A blob that gained a reference since it was listed is left alone and reported
|
||||
// as false. If remove fails the row survives with it, and the next sweep finds
|
||||
// the pair again.
|
||||
func (d *DB) DeleteBlob(ctx context.Context, digest cas.Digest, remove func() error) (bool, error) {
|
||||
var deleted bool
|
||||
err := d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
deleted = false
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM blobs WHERE digest = ? AND refcount = 0`, digest.Bytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := remove(); err != nil {
|
||||
return err
|
||||
}
|
||||
deleted = true
|
||||
return nil
|
||||
})
|
||||
return deleted, err
|
||||
}
|
||||
Reference in New Issue
Block a user