v4.0.0-beta.3
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 togithub.com/go-fries/fries/lifecycle/v4.github.com/go-fries/fries/eventbus/v4; migrate togithub.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.Untiland 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:
- Change polling conditions to accept
context.Contextand return(bool, error). - Replace
support.UntilTimeoutwith a timeout Context passed topoll.Until. - Replace timeout checks with
errors.Is(err, context.DeadlineExceeded)where the error comes from the new polling path. - Pass timeout Contexts directly to underlying operations instead of wrapping
func() errorcallbacks. - Remove unused direct dependencies on
supportorerrors/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.ShutdownMigration requirements:
- Replace Foundation imports with
lifecycle/v4. - Rename provider
Terminate(context.Context)methods toShutdown(context.Context). - Replace
NewKernelwithNewand move provider registration intoWithProviders. - Remove
WithHandlerand pass the handler directly toRunner.Run. - Replace
WithTerminateTimeoutwithWithShutdownTimeoutandTerminateFuncwithShutdownFunc. - Replace
Chainwith a nested runner when providers must be grouped. - Replace custom Foundation options with exported Lifecycle options.
- Ensure every successful
BootstrapandShutdownreturns a non-nil Context derived from the Context it received. - 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:
- Create one reusable Redis backend with
lockerredis.Newand construct named locks withbackend.Lock(name, ttl). - Replace callback methods with
locker.Tryfor one attempt orlocker.Dowith a deadline Context for waiting. - Keep the returned
Leasewhen renewal, token transfer, or a dedicated release Context is required. - Replace owner-name handoff with
TransferableLease.Tokenand restore only caller-supplied tokens throughRestorableLock.Restore. - Replace old sentinels with
ErrNotAcquired,ErrLeaseLost,context.Canceled, andcontext.DeadlineExceeded. - Remove
LockedOwnerandForceReleaseusage. - Update custom
cache.Storeimplementations soLock(name, ttl)returnslocker.Lock. - Choose a project-specific prefix with
WithPrefixwhen 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:
- Replace
eventbus/v4imports withevent/v4. - Construct the Dispatcher with
event.Newand supply middleware throughevent.WithMiddleware. - Rename typed listeners to
event.Handler[T]or adapt functions withevent.HandlerFunc[T]. - Wrap typed handlers with
event.HandlerFor[T]and register them throughDispatcher.Subscribe. - Keep the returned
Subscriptionand callUnsubscribeinstead ofReset,Off, orOffAll. - Use the default serial, fail-fast behavior,
ContinueOnErrorwhen all matching handlers should run, andWithConcurrency(limit)only for bounded concurrent execution. - Inspect
*recovery.PanicErroror add outer middleware for logging, metrics, and alert reporting. Recovery no longer logs by default. - Replace
EmitAsyncwithparallelfor managed in-process background work orqueuefor 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