package storage
import (
"albedo/cli/internal/daemon"
"bytes"
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"strings"
)
//go:embed maintenance.py
var maintenanceScript string
// maintenance owns both mutations and the lock. Closing input revokes ownership.
type maintenance struct {
command *exec.Cmd
input io.WriteCloser
output *json.Decoder
stderr bytes.Buffer
waited bool
}
type maintenanceMessage struct {
Result *CleanupResult `json:"result"`
Ready bool `json:"ready"`
}
func startMaintenance(ctx context.Context, home string, files []daemon.StorageFile) (*maintenance, error) {
helper := &maintenance{command: exec.CommandContext(ctx, "python3", "-c", maintenanceScript, home)}
helper.command.Stderr = &helper.stderr
input, err := helper.command.StdinPipe()
if err != nil {
return nil, err
}
helper.input = input
output, err := helper.command.StdoutPipe()
if err != nil {
_ = input.Close()
return nil, err
}
helper.output = json.NewDecoder(output)
helper.output.DisallowUnknownFields()
if err := helper.command.Start(); err != nil {
_ = input.Close()
return nil, err
}
paths := make([]string, 0, len(files))
for _, file := range files {
paths = append(paths, file.Path)
}
if err := json.NewEncoder(input).Encode(struct {
Paths []string `json:"paths"`
}{paths}); err != nil {
helper.close()
return nil, helper.failure(ctx, err)
}
var message maintenanceMessage
if err := helper.output.Decode(&message); err != nil {
helper.close()
return nil, helper.failure(ctx, err)
}
if !message.Ready || message.Result != nil {
helper.close()
return nil, helper.failure(ctx, errors.New("invalid maintenance readiness"))
}
return helper, nil
}
// close always reaps the child, including revalidation failures before authorization.
func (m *maintenance) close() {
_ = m.input.Close()
if !m.waited {
_ = m.command.Process.Kill()
_ = m.command.Wait()
m.waited = true
}
}
func (m *maintenance) failure(ctx context.Context, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("storage maintenance: %w: %s", err, strings.TrimSpace(m.stderr.String()))
}
func (m *maintenance) apply(ctx context.Context, vacuum bool, fresh daemon.StorageReport) (CleanupResult, error) {
var result CleanupResult
err := json.NewEncoder(m.input).Encode(struct {
Apply bool `json:"apply"`
Vacuum bool `json:"vacuum"`
Before int64 `json:"before"`
FreePages int64 `json:"free_pages"`
}{true, vacuum, fresh.Database, fresh.DB.FreePages})
if err != nil {
m.close()
return result, m.failure(ctx, err)
}
var message maintenanceMessage
if err = m.output.Decode(&message); err != nil {
m.close()
return result, m.failure(ctx, err)
}
if message.Ready || message.Result == nil {
m.close()
return result, m.failure(ctx, errors.New("invalid maintenance result"))
}
// Keep input open until the helper finishes, so its EOF watcher cannot
// interrupt successful work. A valid result alone does not prove success.
err = m.command.Wait()
m.waited = true
if ctx.Err() != nil {
return result, ctx.Err()
}
if err != nil {
return result, m.failure(ctx, err)
}
return *message.Result, nil
}