Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pluginkit/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ Releases of this module are tagged `pluginkit/vX.Y.Z`. Releases up to
## Unreleased

- The module needs Go 1.27.3.
- `NewHostWithMigrationStore` returns a host whose `Migrate` and `Start` skip each plugin a `MigrationStore` records as current, by the version its `MigrationVersioner` reports.
- `NewHostWithMigrationStore` returns a host whose `Migrate`, `Start` and `StartEach` skip each plugin a `MigrationStore` records as current, by the version its `MigrationVersioner` reports.
- An optional `MigratorOf` lookup gives that host the `Migrator` of a plugin that ships its schema as data.
- `Host.StartEach` migrates and then starts every plugin on its own, goes on past a failure or a panic, and answers a `StartOutcome` naming each plugin that started and each `Failure`.
- The `Plugin` doc promises that `Stop` may run more than once and that `Start` may run again after it, and asks `Start` to return by the time its context ends.
- The `Migrator` doc asks `Migrate` to return by the time its context ends, and the `RouteProvider` and `PublicPathProvider` docs ask `Routes` and `PublicPaths` to work whether or not `Start` ran or worked.

## 0.7.0 - 2026-10-15

Expand Down
64 changes: 32 additions & 32 deletions pluginkit/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ var (
errNoMigrationStore = errors.New("pluginkit: the migration store is nil")
// errEmptyMigrationVersion refuses a migration version that holds nothing to compare.
errEmptyMigrationVersion = errors.New("the version is empty")
// errNilMigrator refuses a lookup that claims a migrator and gives none.
errNilMigrator = errors.New("the lookup gave a nil migrator")
)

// Host starts and stops a fixed set of plugins.
Expand All @@ -33,7 +35,7 @@ func NewHost(plugins ...Plugin) *Host {
return &Host{plugins: plugins}
}

// NewHostWithMigrationStore returns a [Host] whose Migrate skips every plugin store records as current.
// NewHostWithMigrationStore returns a [Host] whose migrations skip every plugin store records as current.
func NewHostWithMigrationStore(store MigrationStore, migratorOf MigratorOf, plugins ...Plugin) (*Host, error) {
if store == nil {
return nil, errNoMigrationStore
Expand Down Expand Up @@ -79,26 +81,26 @@ func (h *Host) rollBack(ctx context.Context, stopGrace time.Duration, index int)
return h.stopDownFrom(stopping, index)
}

// Migrate applies the schema of every [Migrator] plugin in order, past those its store records as current.
// Migrate applies every [Migrator] plugin's schema in order, past those its store records as current, until one fails.
func (h *Host) Migrate(ctx context.Context) error {
if h.store == nil {
return h.migrateEvery(ctx)
}
versions, err := h.readVersions(ctx)
if err != nil {
return err
}
for _, p := range h.plugins {
if err := h.migrateUnlessCurrent(ctx, p, versions); err != nil {
if _, err := h.migrateUnlessCurrent(ctx, p, versions); err != nil {
return err
}
}
return nil
}

// readVersions returns every migration version the store records, in one read.
// readVersions returns every migration version the store records, in one read, and none on a host with no store.
func (h *Host) readVersions(ctx context.Context) (map[string]string, error) {
var versions map[string]string
if h.store == nil {
return versions, nil
}
err := guarded(ctx, "read the migration versions", func(ctx context.Context) error {
var err error
versions, err = h.store.Versions(ctx)
Expand All @@ -107,24 +109,24 @@ func (h *Host) readVersions(ctx context.Context) (map[string]string, error) {
return versions, err
}

// migrateUnlessCurrent applies the schema of p unless versions holds its version, then records the one it applied.
func (h *Host) migrateUnlessCurrent(ctx context.Context, p Plugin, versions map[string]string) error {
// migrateUnlessCurrent applies and records p's schema unless versions holds it, answering the step an error came from.
func (h *Host) migrateUnlessCurrent(ctx context.Context, p Plugin, versions map[string]string) (Step, error) {
migrator, ok, err := h.migratorFor(ctx, p)
if err != nil || !ok {
return err
return StepMigrate, err
}
versioner, ok := migrator.(MigrationVersioner)
if !ok {
return safeCall(ctx, p.ID(), "migrate", migrator.Migrate)
if !ok || h.store == nil {
return StepMigrate, safeCall(ctx, p.ID(), "migrate", migrator.Migrate)
}
version, err := migrationVersion(ctx, p.ID(), versioner)
if err != nil || version == versions[p.ID()] {
return err
return StepMigrate, err
}
if err := safeCall(ctx, p.ID(), "migrate", migrator.Migrate); err != nil {
return err
return StepMigrate, err
}
return safeCall(ctx, p.ID(), "record", func(ctx context.Context) error {
return StepRecord, safeCall(ctx, p.ID(), "record", func(ctx context.Context) error {
return h.store.Record(ctx, p.ID(), version)
})
}
Expand All @@ -134,6 +136,9 @@ func (h *Host) migratorFor(ctx context.Context, p Plugin) (migrator Migrator, ok
if h.migratorOf != nil {
err = safeCall(ctx, p.ID(), "migrator lookup", func(context.Context) error {
migrator, ok = h.migratorOf(p)
if ok && migrator == nil {
return errNilMigrator
}
return nil
})
if err != nil || ok {
Expand All @@ -157,28 +162,15 @@ func migrationVersion(ctx context.Context, id string, versioner MigrationVersion
return version, err
}

// migrateEvery applies the schema of every [Migrator] plugin in registration order, stopping at the first failure.
func (h *Host) migrateEvery(ctx context.Context) error {
return callEach(ctx, h.plugins, "migrate", Migrator.Migrate)
}

// Seed asks every [Seeder] plugin to fill its schema in registration order,
// stopping at the first failure.
func (h *Host) Seed(ctx context.Context) error {
return callEach(ctx, h.plugins, "seed", Seeder.Seed)
}

// callEach calls step on every plugin that is a T, in registration order, stopping at the first failure.
func callEach[T any](
ctx context.Context, plugins []Plugin, operation string, step func(T, context.Context) error,
) error {
for _, p := range plugins {
capable, ok := p.(T)
for _, p := range h.plugins {
seeder, ok := p.(Seeder)
if !ok {
continue
}
call := func(ctx context.Context) error { return step(capable, ctx) }
if err := safeCall(ctx, p.ID(), operation, call); err != nil {
if err := safeCall(ctx, p.ID(), "seed", seeder.Seed); err != nil {
return err
}
}
Expand Down Expand Up @@ -236,11 +228,19 @@ func safeCall(ctx context.Context, id, operation string, fn func(context.Context
func guarded(ctx context.Context, name string, fn func(context.Context) error) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("pluginkit: %s panicked: %v", name, recovered)
err = panicError(fmt.Sprintf("pluginkit: %s panicked: %v", name, recovered))
}
}()
if err := fn(ctx); err != nil {
return fmt.Errorf("pluginkit: %s: %w", name, err)
}
return nil
}

// panicError is a panic [guarded] recovered, holding the text that names where it came from.
type panicError string

// Error returns the text of the panic.
func (e panicError) Error() string {
return string(e)
}
6 changes: 5 additions & 1 deletion pluginkit/pluginkit.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ import (
// managed lifecycle.
type Plugin interface {
ID() string
// Start readies the plugin, returning by the time ctx ends, and may run again after Stop.
Start(ctx context.Context) error
// Stop releases what the plugin holds, returning by the time ctx ends, whether or not Start ran.
// Stop releases what the plugin holds by the time ctx ends, whether or not Start ran, and may run more than once.
Stop(ctx context.Context) error
}

// Migrator is implemented by plugins that own database schema, which a host migrates before starting any
// plugin, and which a host with a [MigrationStore] skips while its recorded version is current.
type Migrator interface {
// Migrate applies the plugin's schema, returning by the time ctx ends.
Migrate(ctx context.Context) error
}

Expand Down Expand Up @@ -50,11 +52,13 @@ type Seeder interface {
// RouteProvider is implemented by plugins that expose HTTP endpoints
// under their own namespace.
type RouteProvider interface {
// Routes returns the plugin's handler, whether or not Start ran or worked.
Routes() http.Handler
}

// PublicPathProvider is implemented by plugins declaring namespace-relative paths that must stay
// reachable without a session. Paths match exactly, for every HTTP method, and all else stays protected.
type PublicPathProvider interface {
// PublicPaths returns those paths, whether or not Start ran or worked.
PublicPaths() []string
}
30 changes: 30 additions & 0 deletions pluginkit/seed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,36 @@ func TestHostTagsASeedPanicWithItsPlugin(t *testing.T) {
}
}

// contextSeeder is a seeder that records the caller value its context carries and whether that context can end.
type contextSeeder struct {
fakePlugin
seen *[]any
}

// Seed records the caller value of ctx and whether ctx can end.
func (c *contextSeeder) Seed(ctx context.Context) error {
*c.seen = append(*c.seen, ctx.Value(callerKey{}), ctx.Done() != nil)
return nil
}

func TestHostSeedHandsTheCallerContextToEverySeeder(t *testing.T) {
t.Parallel()

var calls []string
var seen []any
host := pluginkit.NewHost(&contextSeeder{fakePlugin: fakePlugin{id: "alpha", calls: &calls}, seen: &seen})
ctx, cancel := context.WithCancel(context.WithValue(t.Context(), callerKey{}, "caller"))
defer cancel()

if err := host.Seed(ctx); err != nil {
t.Fatalf("Seed() error = %v, want nil", err)
}

if want := []any{"caller", true}; !slices.Equal(want, seen) {
t.Errorf("context seen = %v, want %v", seen, want)
}
}

func TestHostSeedsNothingWithoutASeeder(t *testing.T) {
t.Parallel()

Expand Down
93 changes: 93 additions & 0 deletions pluginkit/start_each.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: Apache-2.0

package pluginkit

import (
"context"
"time"
)

// Step names the part of a plugin's boot a [Failure] happened in.
type Step string

const (
// StepMigrate is the migration pass, and a plugin that fails it does not start.
StepMigrate Step = "migrate"
// StepRecord is the note of a migration that ran, and a plugin that fails it still starts.
StepRecord Step = "record"
// StepStart is a plugin's Start.
StepStart Step = "start"
)

// Failure is one step one plugin failed, or was still waiting for when the context ended.
type Failure struct {
ID string
Step Step
// Panicked is true only for a panic this step recovered, never for an error that merely holds one.
Panicked bool
Err error
}

// StartedPlugin is one plugin whose Start returned nil, and how long that Start took.
type StartedPlugin struct {
ID string
Took time.Duration
}

// StartOutcome is what [Host.StartEach] answers, both lists in registration order, a failed record beside its start.
type StartOutcome struct {
Started []StartedPlugin
Failed []Failure
}

// StartEach migrates then starts each plugin on its own past any failure, stopping none, and errs only on a store read.
func (h *Host) StartEach(ctx context.Context) (StartOutcome, error) {
versions, err := h.readVersions(ctx)
if err != nil {
return StartOutcome{}, err
}
migrations := make([]Failure, len(h.plugins))
for i, p := range h.plugins {
migrations[i] = h.migrateUnlessEnded(ctx, p, versions)
}
var outcome StartOutcome
for i, p := range h.plugins {
outcome.startAfter(ctx, p, migrations[i])
}
return outcome, nil
}

// migrateUnlessEnded migrates p while ctx lasts, answering a [Failure] whose Err is nil when nothing failed.
func (h *Host) migrateUnlessEnded(ctx context.Context, p Plugin, versions map[string]string) Failure {
if err := safeCall(ctx, p.ID(), string(StepMigrate), context.Context.Err); err != nil {
return failureOf(p.ID(), StepMigrate, err)
}
step, err := h.migrateUnlessCurrent(ctx, p, versions)
return failureOf(p.ID(), step, err)
}

// startAfter lists what p's migration did when it failed, then starts p unless that failure rules it out.
func (o *StartOutcome) startAfter(ctx context.Context, p Plugin, migration Failure) {
if migration.Err != nil {
o.Failed = append(o.Failed, migration)
}
if migration.Err != nil && migration.Step != StepRecord {
return
}
began := time.Now()
err := safeCall(ctx, p.ID(), string(StepStart), context.Context.Err)
if err == nil {
err = safeCall(ctx, p.ID(), string(StepStart), p.Start)
}
if err != nil {
o.Failed = append(o.Failed, failureOf(p.ID(), StepStart, err))
return
}
o.Started = append(o.Started, StartedPlugin{ID: p.ID(), Took: time.Since(began)})
}

// failureOf returns the failure of the plugin id at step, marked as panicked only when err is that step's own panic.
func failureOf(id string, step Step, err error) Failure {
_, panicked := err.(panicError)
return Failure{ID: id, Step: step, Panicked: panicked, Err: err}
}
Loading
Loading