docs/contributing/go/service.md
A service is a component with a managed lifecycle: it starts, runs for the lifetime of the application, and stops gracefully.
Services are distinct from providers. A provider adapts an external dependency behind an interface. A service has a managed lifecycle that is tied to the lifetime of the application.
You need a service when your component needs to do work that outlives a single method call:
If your component only responds to calls and holds no state that requires cleanup, it is a provider, not a service. If it does both (responds to calls and needs a lifecycle), embed factory.Service in the provider interface; see How to create a service.
The factory.Service interface in pkg/factory/service.go defines two methods:
type Service interface {
// Starts a service. It should block and should not return until the service is stopped or it fails.
Start(context.Context) error
// Stops a service.
Stop(context.Context) error
}
Start must block. It should not return until the service is stopped (returning nil) or something goes wrong (returning an error). If Start returns an error, the entire application shuts down.
Stop should cause Start to unblock and return. It must be safe to call from a different goroutine than the one running Start.
Every service uses a stopC chan struct{} to coordinate shutdown:
stopC: make(chan struct{})<-stopC (or uses it in a select loop)close(stopC) to unblock StartThis is the standard pattern. Do not use context.WithCancel or other mechanisms for service-level shutdown coordination. See the examples in the next section.
Two shapes recur across the codebase (these are not exhaustive, if a new shape is needed, bring it up for discussion before going ahead with the implementation), implemented by convention rather than base classes.
The service does work during startup or shutdown but has nothing to do while running. Start blocks on <-stopC. Stop closes stopC and optionally does cleanup.
The JWT tokenizer (pkg/tokenizer/jwttokenizer/provider.go) is a good example. It validates and creates tokens on demand via method calls, but has no periodic work to do. It still needs the service lifecycle so the registry can manage its lifetime:
// pkg/tokenizer/jwttokenizer/provider.go
func (provider *provider) Start(ctx context.Context) error {
<-provider.stopC
return nil
}
func (provider *provider) Stop(ctx context.Context) error {
close(provider.stopC)
return nil
}
The instrumentation SDK (pkg/instrumentation/sdk.go) is idle while running but does real cleanup in Stop shutting down its OpenTelemetry tracer and meter providers:
// pkg/instrumentation/sdk.go
func (i *SDK) Start(ctx context.Context) error {
<-i.startCh
return nil
}
func (i *SDK) Stop(ctx context.Context) error {
close(i.startCh)
return errors.Join(
i.sdk.Shutdown(ctx),
i.meterProviderShutdownFunc(ctx),
)
}
The service runs an operation repeatedly on a fixed interval. Start runs a ticker loop with a select on stopC and the ticker channel.
The opaque tokenizer (pkg/tokenizer/opaquetokenizer/provider.go) garbage-collects expired tokens and flushes cached last-observed-at timestamps to the database on a configurable interval:
// pkg/tokenizer/opaquetokenizer/provider.go
func (provider *provider) Start(ctx context.Context) error {
ticker := time.NewTicker(provider.config.Opaque.GC.Interval)
defer ticker.Stop()
for {
select {
case <-provider.stopC:
return nil
case <-ticker.C:
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to get orgs data", "error", err)
continue
}
for _, org := range orgs {
if err := provider.gc(ctx, org); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to garbage collect tokens", "error", err, "org_id", org.ID)
}
if err := provider.flushLastObservedAt(ctx, org); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", "error", err, "org_id", org.ID)
}
}
}
}
}
Its Stop does a final gc and flush before returning, so no data is lost on shutdown:
// pkg/tokenizer/opaquetokenizer/provider.go
func (provider *provider) Stop(ctx context.Context) error {
close(provider.stopC)
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return err
}
for _, org := range orgs {
if err := provider.gc(ctx, org); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to garbage collect tokens", "error", err, "org_id", org.ID)
}
if err := provider.flushLastObservedAt(ctx, org); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", "error", err, "org_id", org.ID)
}
}
return nil
}
The key points:
select on stopC and the ticker. Errors in iterations are logged but do not cause the service to return (which would shut down the application).Start if the failure is unrecoverable.Stop to flush or drain any in-memory state before the process exits.There are two cases: a standalone service and a provider that is also a service.
A standalone service only has the factory.Service lifecycle i.e it does not serve as a dependency for other packages. The user reconciliation service is an example.
Define the service interface in your package. Embed factory.Service:
// pkg/modules/user/service.go
package user
type Service interface {
factory.Service
}
Create the implementation in an impl sub-package. Use an unexported struct with an exported constructor that returns the interface:
// pkg/modules/user/impluser/service.go
package impluser
type service struct {
settings factory.ScopedProviderSettings
// ... dependencies ...
stopC chan struct{}
}
func NewService(
providerSettings factory.ProviderSettings,
// ... dependencies ...
) user.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
// ... dependencies ...
stopC: make(chan struct{}),
}
}
func (s *service) Start(ctx context.Context) error { ... }
func (s *service) Stop(ctx context.Context) error { ... }
Many providers need a managed lifecycle: they poll, sync, or garbage-collect in the background. In this case, embed factory.Service in the provider interface. The implementation satisfies both the provider methods and Start/Stop.
// pkg/tokenizer/tokenizer.go
package tokenizer
type Tokenizer interface {
factory.Service
CreateToken(context.Context, *authtypes.Identity, map[string]string) (*authtypes.Token, error)
GetIdentity(context.Context, string) (*authtypes.Identity, error)
// ... other methods ...
}
The implementation (e.g. pkg/tokenizer/opaquetokenizer/provider.go) implements Start, Stop, and all the provider methods on the same struct. See the provider guide for how to set up the factory, config, and constructor. The stopC channel and Start/Stop methods follow the same patterns described above.
Wiring happens in pkg/signoz/signoz.go.
For a standalone service, call the constructor directly:
userService := impluser.NewService(providerSettings, store, module, orgGetter, authz, config.User.Root)
For a provider that is also a service, use factory.NewProviderFromNamedMap as described in the provider guide. The returned value already implements factory.Service.
Wrap the service with factory.NewNamedService and pass it to factory.NewRegistry:
registry, err := factory.NewRegistry(
instrumentation.Logger(),
// ... other services ...
factory.NewNamedService(factory.MustNewName("user"), userService),
)
The name must be unique across all services. The registry handles the rest:
errors.Join.You do not call Start or Stop on individual services. The registry does it.
Start blocks, Stop unblocks it.stopC chan struct{} for shutdown coordination. close(stopC) in Stop, <-stopC in Start.stopC) and scheduled (ticker loop with select).NewService constructor returning the interface.factory.ProviderSettings. Create scoped settings with factory.NewScopedProviderSettings.factory.Registry with factory.NewNamedService. The registry starts and stops everything.Start if the failure is unrecoverable. Log and continue for transient errors in polling loops.