Skip to content

v4.0.0-beta.3

Choose a tag to compare

@flc1125 flc1125 released this 17 Jul 00:55
· 229 commits to 4.x since this release
v4.0.0-beta.3
afda51a

Fries v4.0.0-beta.3

v4.0.0-beta.3 is the third beta release of Fries v4. It replaces the Foundation kernel with the Lifecycle component, redesigns Locker around explicit lock and lease ownership, consolidates Event and EventBus into one deterministic dispatcher, and removes the legacy polling and timeout helpers from Support.

This release focuses on explicit lifecycle ownership, context-aware cancellation, deterministic in-process execution, and safer resource coordination.

Important

This is a prerelease and contains four intentionally breaking refactors. Applications upgrading from v4.0.0-beta.2 should review every migration section below before updating dependencies.

Upgrade

Fries is a multi-module repository. Upgrade the root module and every component module used by the application to the same prerelease version.

go get github.com/go-fries/fries/v4@v4.0.0-beta.3
go get github.com/go-fries/fries/lifecycle/v4@v4.0.0-beta.3
go get github.com/go-fries/fries/locker/v4@v4.0.0-beta.3
go get github.com/go-fries/fries/locker/redis/v4@v4.0.0-beta.3
go get github.com/go-fries/fries/event/v4@v4.0.0-beta.3
go mod tidy
go test ./...

Replace the example component modules with those imported by the application. Applications using modules that depend on each other should keep those modules on the same Fries release.

The following modules no longer exist:

  • github.com/go-fries/fries/foundation/v4; migrate to github.com/go-fries/fries/lifecycle/v4.
  • github.com/go-fries/fries/eventbus/v4; migrate to github.com/go-fries/fries/event/v4.
  • github.com/go-fries/fries/event/example/v4; runnable usage now lives in the Event package examples and README.

Highlights

  • Replace the Foundation kernel with a context-aware Lifecycle runner that provides ordered startup, rollback, reverse-order shutdown, joined errors, and manual lifecycle integration.
  • Redesign Locker around reusable backends, named locks, and explicit leases, with context-aware Redis acquisition, token-safe release and renewal, and project-scoped key prefixes.
  • Consolidate Event and EventBus into one synchronous, exact-type Dispatcher with explicit subscriptions, deterministic errors, bounded concurrency, middleware, and optional package-level access.
  • Remove legacy Support polling and timeout helpers in favor of poll.Until and standard Go contexts.
  • Refresh dependencies and update CI to actions/setup-go@v7.

Breaking changes and migration

Support: legacy polling and timeout helpers removed

#2629 removes support.Until, support.UntilTimeout, and support.Timeout. These helpers could busy-loop, could not propagate condition errors consistently, and could return after a deadline without stopping the goroutine running the callback.

Use poll.Until for synchronous, context-aware state observation. Use context.WithTimeout or context.WithDeadline to bound the complete operation.

Support API mapping

Before After
support.Until(fn, interval) poll.Until(ctx, interval, condition)
support.UntilTimeout(fn, timeout, interval) context.WithTimeout with poll.Until
support.Timeout(fn, timeout) Pass a timeout Context to a context-aware operation
errors.IsTimeoutError(err) errors.Is(err, context.DeadlineExceeded) for the new polling path

Migrate polling

Before:

err := support.UntilTimeout(
	func() bool {
		return client.Ready()
	},
	30*time.Second,
	time.Second,
)

After:

ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
defer cancel()

err := poll.Until(ctx, time.Second, func(ctx context.Context) (bool, error) {
	return client.Ready(ctx)
})

The first condition check runs immediately. Poll waits only between incomplete checks and stops when the condition succeeds, returns an error, or the Context is canceled.

Migrate timeout wrappers

Before:

err := support.Timeout(func() error {
	return client.Call()
}, 30*time.Second)

After:

ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
defer cancel()

err := client.Call(ctx)

Migration requirements:

  1. Change polling conditions to accept context.Context and return (bool, error).
  2. Replace support.UntilTimeout with a timeout Context passed to poll.Until.
  3. Replace timeout checks with errors.Is(err, context.DeadlineExceeded) where the error comes from the new polling path.
  4. Pass timeout Contexts directly to underlying operations instead of wrapping func() error callbacks.
  5. Remove unused direct dependencies on support or errors/v4.

Note

A Context deadline cannot forcibly stop an operation that does not accept or observe the Context. Refactor callback-only operations when cancellation is required.

Foundation replaced by Lifecycle

#2631 removes github.com/go-fries/fries/foundation/v4 and introduces github.com/go-fries/fries/lifecycle/v4 for context-aware application startup, execution, rollback, and graceful shutdown.

Lifecycle starts providers in registration order and shuts down successfully started providers in reverse order. A startup failure rolls back providers that already started. Shutdown continues after individual failures and joins startup, handler, rollback, and shutdown errors where applicable.

The built-in providers in Config, Env, Event, Timezone, log/slog, and otel/otlp now implement the Lifecycle shutdown contract. A Runner also implements Provider, so a nested runner replaces the former provider-chain abstraction.

Lifecycle API mapping

Before After
github.com/go-fries/fries/foundation/v4 github.com/go-fries/fries/lifecycle/v4
foundation.NewKernel(...) lifecycle.New(...)
foundation.Provider.Terminate(ctx) lifecycle.Provider.Shutdown(ctx)
foundation.WithHandler(handler) Pass the handler to runner.Run(ctx, handler)
foundation.WithTerminateTimeout(timeout) lifecycle.WithShutdownTimeout(timeout)
kernel.Register(providers...) lifecycle.WithProviders(providers...)
foundation.NewChain(providers...) A nested lifecycle.Runner
foundation.TerminateFunc lifecycle.ShutdownFunc

Before:

kernel := foundation.NewKernel(
	foundation.WithHandler(foundation.HandlerFunc(application.Run)),
	foundation.WithTerminateTimeout(10*time.Second),
)
kernel.Register(configProvider, eventProvider, telemetryProvider)

err := kernel.Run(ctx)

After:

runner := lifecycle.New(
	lifecycle.WithProviders(
		configProvider,
		eventProvider,
		telemetryProvider,
	),
	lifecycle.WithShutdownTimeout(10*time.Second),
)

err := runner.Run(ctx, application.Run)

Manual integrations can use the runner methods directly:

runner := lifecycle.New(lifecycle.WithProviders(providers...))

bootstrap := runner.Bootstrap
shutdown := runner.Shutdown

Migration requirements:

  1. Replace Foundation imports with lifecycle/v4.
  2. Rename provider Terminate(context.Context) methods to Shutdown(context.Context).
  3. Replace NewKernel with New and move provider registration into WithProviders.
  4. Remove WithHandler and pass the handler directly to Runner.Run.
  5. Replace WithTerminateTimeout with WithShutdownTimeout and TerminateFunc with ShutdownFunc.
  6. Replace Chain with a nested runner when providers must be grouped.
  7. Replace custom Foundation options with exported Lifecycle options.
  8. Ensure every successful Bootstrap and Shutdown returns a non-nil Context derived from the Context it received.
  9. Construct a new runner for each application run. Startup is one-shot and a later or concurrent attempt returns ErrAlreadyStarted.

Managed shutdown preserves Context values produced during bootstrap without inheriting cancellation from the runtime Context. It applies the configured shutdown timeout instead. Direct calls to Shutdown use the caller-supplied Context unchanged.

Locker: explicit backends, locks, and leases

#2636 replaces the key-bound Locker contract with a reusable Locker -> Lock -> Lease model. The Redis adapter now provides cancellable acquisition waits, token-safe release and renewal, transferable ownership tokens, and project-scoped key prefixes.

Cache integrations use the same named-lock contract: cache.Store.Lock now returns locker.Lock, and successful Noop or NullStore acquisitions return a non-nil Noop lease.

Locker API mapping

Before After
Key-bound locker.Locker Reusable locker.Locker, named locker.Lock, owned locker.Lease
lockerredis.NewLocker(client, WithName(name), WithTTL(ttl)) lockerredis.New(client).Lock(name, ttl)
lock.Try(ctx, func()) locker.Try(ctx, lock, func(context.Context) error)
lock.Until(ctx, timeout, func()) context.WithTimeout with locker.Do
lock.Get(ctx) lock.TryAcquire(ctx)
Owner Lease
owner.Name() lease.(locker.TransferableLease).Token() when supported
NewOwner(..., WithOwnerName(token)) lock.(locker.RestorableLock).Restore(token)
lock.Release(ctx, owner) lease.Release(ctx)
LockedOwner(ctx) Removed without replacement
ForceRelease(ctx) Wait for TTL expiry or use an explicit Redis administration path
ErrLocked ErrNotAcquired
ErrTimeout context.DeadlineExceeded
ErrNotLocked ErrLeaseLost

Before:

lock := lockerredis.NewLocker(
	client,
	lockerredis.WithName("orders:123"),
	lockerredis.WithTTL(30*time.Second),
	lockerredis.WithSleep(100*time.Millisecond),
)

err := lock.Until(ctx, 5*time.Second, func() {
	processOrder()
})

After:

backend := lockerredis.New(
	client,
	lockerredis.WithPrefix("billing:locker"),
	lockerredis.WithWaitInterval(50*time.Millisecond, 100*time.Millisecond),
)
lock := backend.Lock("orders:123", 30*time.Second)

waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

err := locker.Do(waitCtx, lock, func(ctx context.Context) error {
	return processOrder(ctx)
})

Acquire a lease explicitly when renewal, token transfer, or custom release timing is required:

lease, err := lock.Acquire(ctx)
if err != nil {
	return err
}

releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()

if renewable, ok := lease.(locker.RenewableLease); ok {
	if err := renewable.Refresh(ctx, 30*time.Second); err != nil {
		return errors.Join(err, lease.Release(releaseCtx))
	}
}

return lease.Release(releaseCtx)

Migration requirements:

  1. Create one reusable Redis backend with lockerredis.New and construct named locks with backend.Lock(name, ttl).
  2. Replace callback methods with locker.Try for one attempt or locker.Do with a deadline Context for waiting.
  3. Keep the returned Lease when renewal, token transfer, or a dedicated release Context is required.
  4. Replace owner-name handoff with TransferableLease.Token and restore only caller-supplied tokens through RestorableLock.Restore.
  5. Replace old sentinels with ErrNotAcquired, ErrLeaseLost, context.Canceled, and context.DeadlineExceeded.
  6. Remove LockedOwner and ForceRelease usage.
  7. Update custom cache.Store implementations so Lock(name, ttl) returns locker.Lock.
  8. Choose a project-specific prefix with WithPrefix when applications share a Redis deployment.

Warning

Redis keys now use the locker: prefix by default. Old versions use unprefixed keys, so old and new processes do not mutually exclude each other. Drain old holders or wait for their maximum TTL before enabling new workers that contend for the same logical locks.

A Redis lock remains an expiring lease. Renewal does not eliminate process pauses, network partitions, or asynchronous Redis failover. The component does not provide fencing tokens, Redlock arbitration, exactly-once execution, or a substitute for idempotency at the protected resource.

Event and EventBus consolidated into one Dispatcher

#2643 replaces the overlapping Event and EventBus implementations with one synchronous, type-aware event.Dispatcher. Events are routed by exact concrete Go type, subscriptions are explicit, and every dispatch has a deterministic completion point and error result.

Event API mapping

Before After
event.NewDispatcher(...) event.New(...)
dispatcher.Use(middleware...) event.New(event.WithMiddleware(middleware...))
event.AdaptListener(handler) event.HandlerFor[OrderPaid](handler)
dispatcher.RegisterListeners(...) subscription := dispatcher.Subscribe(...)
dispatcher.Reset() subscription.Unsubscribe()
event.WithDispatchParallel(n) event.WithConcurrency(n)
event.WithDispatchWithoutError() event.ContinueOnError() and explicit error handling
eventbus.NewEvent[OrderPaid]() event.New() with event.HandlerFor[OrderPaid](...)
topic.Emit(ctx, value) dispatcher.Dispatch(ctx, value)
topic.EmitAsync(ctx, value) parallel or queue, depending on delivery requirements

Before:

dispatcher := event.NewDispatcher(event.WithError())
dispatcher.Use(recovery.New())
dispatcher.RegisterListeners(
	event.AdaptListener(ReceiptHandler{}),
)

if err := dispatcher.Dispatch(ctx, OrderPaid{OrderID: "123"}); err != nil {
	return err
}

After:

dispatcher := event.New(
	event.WithMiddleware(recovery.New()),
)

subscription := dispatcher.Subscribe(
	event.HandlerFor[OrderPaid](ReceiptHandler{}),
)
defer subscription.Unsubscribe()

if err := dispatcher.Dispatch(ctx, OrderPaid{OrderID: "123"}); err != nil {
	return err
}

Migration requirements:

  1. Replace eventbus/v4 imports with event/v4.
  2. Construct the Dispatcher with event.New and supply middleware through event.WithMiddleware.
  3. Rename typed listeners to event.Handler[T] or adapt functions with event.HandlerFunc[T].
  4. Wrap typed handlers with event.HandlerFor[T] and register them through Dispatcher.Subscribe.
  5. Keep the returned Subscription and call Unsubscribe instead of Reset, Off, or OffAll.
  6. Use the default serial, fail-fast behavior, ContinueOnError when all matching handlers should run, and WithConcurrency(limit) only for bounded concurrent execution.
  7. Inspect *recovery.PanicError or add outer middleware for logging, metrics, and alert reporting. Recovery no longer logs by default.
  8. Replace EmitAsync with parallel for managed in-process background work or queue for reliable asynchronous delivery.

The Lifecycle provider no longer embeds *event.Dispatcher. Access the Dispatcher through explicit injection, event.FromContext, or event.Default. Provider shutdown no longer waits for event work because dispatch is synchronous.

Dispatch remains synchronous when bounded concurrency is enabled: it waits for every started handler before returning. Context cancellation stops handlers that have not started; running handlers must observe the Context themselves. Value and pointer event types are distinct, and interface-based subscriptions are not supported.

What's changed

Breaking API changes

  • #2629 refactor(support)!: remove legacy polling and timeout helpers
  • #2631 refactor(lifecycle)!: replace foundation kernel
  • #2636 refactor(locker)!: redesign lock and lease contracts
  • #2643 refactor(event)!: redesign in-process event dispatching

CI

  • #2639 chore(deps): update actions/setup-go action to v7

Dependencies

  • #2632 fix(deps): update module github.com/cheggaaa/pb/v3 to v3.2.0
  • #2630 chore(deps): update module github.com/rogpeppe/go-internal to v1.15.0
  • #2627 fix(deps): update module github.com/aws/aws-sdk-go-v2/service/s3 to v1.105.1
  • #2626 chore(deps): update module buf.build/gen/go/bufbuild/registry/protocolbuffers/go to v1.36.11-20260713175918-10d915f5b43b.1
  • #2624 chore(deps): update googleapis to f5fc221
  • #2642 chore(deps): update github.com/petermattis/goid digest to a9b348f
  • #2640 chore(deps): update module github.com/mattn/go-isatty to v0.0.23
  • #2638 fix(deps): update module google.golang.org/grpc to v1.82.1
  • #2625 chore(deps): update module buf.build/gen/go/bufbuild/registry/connectrpc/go to v1.20.0-20260713175918-10d915f5b43b.1
  • #2637 chore(deps): update googleapis to e75dac1
  • #2645 chore(deps): update module github.com/docker/cli to v29.6.2+incompatible
  • #2646 chore(deps): update github.com/pingcap/tidb/pkg/parser digest to 94b834d
  • #2647 fix(deps): update module github.com/aws/smithy-go to v1.27.4

Full Changelog: v4.0.0-beta.2...v4.0.0-beta.3