/ GOLANG

Introduction to xrun: A Flexible Package for Managing Component Lifecycles in Go

Hello, Go community! Today, I am excited to announce a Golang package I have been working on named xrun. This package provides a streamlined way of running multiple components, specifically long-running components like an HTTP server or background worker. In this blog post, I will walk you through the basic functionality of xrun and how it can help you manage your long-running components more efficiently.

Introducing xrun

As the size and complexity of a Go service increase, managing the lifecycles of its various components becomes a daunting task. This post introduces xrun, a flexible and versatile Go package that simplifies this process, making it easier to reason about and manage your application’s components.

The package provides a Manager struct that handles starting and stopping components, coordinating their lifecycles in a way that makes your application more maintainable and less prone to bugs.

The Manager works by starting each component in its own goroutine and waiting for them to either finish or fail. It also ensures that the stop signals are propagated in reverse order of the components’ starting order, ensuring graceful shutdowns.

xrun is available at github.com/gojekfarm/xrun, and you can read the full documentation on pkg.go.dev.

Getting Started

Let’s start by importing the xrun package:

import "github.com/gojekfarm/xrun"

To illustrate how xrun works, let’s create an instance of an HTTP server:

import "net/http"

server := http.Server{Addr: ":9090"}

Next, create a new manager with xrun.NewManager(). Then use m.Add() to add the HTTP server to the manager:

import (
	"github.com/gojekfarm/xrun"
	"github.com/gojekfarm/xrun/component"
)

m := xrun.NewManager()
_ = m.Add(component.HTTPServer(component.HTTPServerOptions{Server: &server}))

Finally, run the manager using m.Run(ctx). If there is an error, the process will exit:

import (
	"os"
	"os/signal"
)

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

if err := m.Run(ctx); err != nil {
	os.Exit(1)
}

Features of xrun

Now, let’s dive into some features of xrun.

Component

The Component interface is the foundation of xrun. It allows you to start a component that will run until the context is closed or an error occurs:

type Component interface {
    Run(context.Context) error
}

ComponentFunc

ComponentFunc is a helper that allows you to implement Component inline. It’s perfect for writing custom components on the fly:

type ComponentFunc func(ctx context.Context) error

Manager

The Manager type helps you to run multiple components and waits for them to complete. You can add components with the Add() method and run them with the Run() method:

type Manager struct {
    // ...
}

func (m *Manager) Add(c Component) error {...}
func (m *Manager) Run(ctx context.Context) (err error) {...}

Note: Since Manager has a Run(context.Context) error method, it is a Component in itself, and hence, it is possible to nest multiple Manager(s).

Creating and Using Components

A component in xrun is anything that implements the Component interface. This interface only requires a single method: Run(context.Context) error.

Here’s an example of a basic component, a ScheduledTask:

type ScheduledTask struct {
	Ticker *time.Ticker
	Task   func()
}

func (s *ScheduledTask) Run(ctx context.Context) error {
	for {
		select {
		case <-s.Ticker.C:
			s.Task()
		case <-ctx.Done():
			return nil
		}
	}
}

In the main function, you create a manager and add your components to it:

m := xrun.NewManager()

tick := time.NewTicker(1 * time.Second)
task := func() { fmt.Println("Task executed") }
s := &ScheduledTask{Ticker: tick, Task: task}

_ = m.Add(s)

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

if err := m.Run(ctx); err != nil {
    log.Fatal(err)
}

Combining Components with xrun.All

Components can also be combined together using the xrun.All function. This function creates a new Component that starts all of the provided components in the order they were given and stops them in the reverse order.

Here is an example of its usage:

tick1 := time.NewTicker(1 * time.Second)
task1 := func() { fmt.Println("Task1 executed") }
s1 := &ScheduledTask{Ticker: tick1, Task: task1}

tick2 := time.NewTicker(2 * time.Second)
task2 := func() { fmt.Println("Task2 executed") }
s2 := &ScheduledTask{Ticker: tick2, Task: task2}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

if err := xrun.All(xrun.NoTimeout, s1, s2).Run(ctx); err != nil {
    log.Fatal(err)
}

Extending xrun: Custom Component Examples

While xrun provides some built-in components, you can create your own to suit your specific needs.

HTTP Server

The xrun package includes an HTTP server component out of the box. It is available in the xrun/component subpackage.

gRPC Server (Experimental)

For more advanced use cases, you can create custom components like a gRPC server. An experimental gRPC server component is available in the component/x/grpc subpackage.

Here’s an example of how you might create such a component:

package grpc

import (
	"context"
	"net"

	"github.com/gojekfarm/xrun"
	"google.golang.org/grpc"
)

// Options holds options for Server
type Options struct {
	Server      *grpc.Server
	NewListener func() (net.Listener, error)
}

// Server is a helper which returns a xrun.ComponentFunc to start a grpc.Server
func Server(opts Options) xrun.ComponentFunc {
	srv := opts.Server
	nl := opts.NewListener

	return func(ctx context.Context) error {
		l, err := nl()
		if err != nil {
			return err
		}

		errCh := make(chan error, 1)

		go func(errCh chan error) {
			if err := srv.Serve(l); err != nil && err != grpc.ErrServerStopped {
				errCh <- err
			}
		}(errCh)

		select {
		case <-ctx.Done():
		case err := <-errCh:
			return err
		}
		srv.GracefulStop()

		return nil
	}
}

func NewListener(address string) func() (net.Listener, error) {
	return func() (net.Listener, error) {
		return net.Listen("tcp", address)
	}
}

In this example, we define a new Options struct that includes our gRPC server instance and a NewListener function. This function creates a network listener on the given address.

Next, we define a Server function that takes an Options instance and returns a xrun.ComponentFunc. This ComponentFunc starts the gRPC server and manages its lifecycle. It starts the server in a goroutine and then enters a select block. If the context is done, the gRPC server is stopped gracefully. If an error occurs while serving, it’s returned.

The NewListener function is a helper that generates a function for creating a network listener.

Here’s how to use it:

package main

import (
	"context"
	"net"
	"os"
	"os/signal"

	xgrpc "yourgrpcpackage"
	"github.com/gojekfarm/xrun"
	"google.golang.org/grpc"
)

func main() {
	s := grpc.NewServer()
	m := xrun.NewManager()

	grpcComponent := xgrpc.Server(xgrpc.Options{
		Server:      s,
		NewListener: xgrpc.NewListener(":8500"),
	})

	_ = m.Add(grpcComponent)

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	if err := m.Run(ctx); err != nil {
		os.Exit(1)
	}
}

Conclusion

The xrun package makes it easier to manage component lifecycles in your Go applications. By allowing you to define and control how each component starts and stops, you can make your application more maintainable and robust.

Finally, I’d like to give credits to the authors at the Kubernetes community. The manager source of xrun has been heavily influenced by the sigs.k8s.io/controller-runtime package.

Thanks for reading, and I hope you find xrun as useful as I do!


Contributions, questions, and feedback are most welcome on the xrun GitHub repository. Happy coding, Gophers!