Reactive planing in Golang. Reach a desired number adding and subtracting random numbers

26 Oct 2020 · Nine minute read · software design, golang, go, reconciliation loop, reactive planning

Ciao! A few months ago, probably a year, I wrote a small library called planner. It comes from my experience using reactive planning and Kubernetes. I am really in love with this way of writing code because it sounds very reliable to me.

Over the last couple of days, I decided to write documentation for it! So now it is presentable; I streamed that with Twitch if you like to watch people coding!

As part of the library’s readme, I wrote a small program, and I left a couple of exercises to the reader. With this article, I want to solve them.

You can follow this article and try it yourself, starting from play.golang.com.

package main

import (
	"context"
	"time"

	"github.com/gianarb/planner"
	"go.uber.org/zap"
)

func main() {
	ctx, done := context.WithTimeout(context.Background(), 10*time.Second)
	defer done()

	countPlan := &CountPlan{
		Target: 20,
	}
	scheduler := planner.NewScheduler()
	scheduler.WithLogger(initLogger())

	scheduler.Execute(ctx, countPlan)
}

type CountPlan struct {
	Target  int
	current int
}

func (p *CountPlan) Create(ctx context.Context) ([]planner.Procedure, error) {
	if p.current < p.Target {
		return []planner.Procedure{&AddNumber{plan: p}}, nil
	}
	return nil, nil
}

func (p *CountPlan) Name() string {
	return "count_plan"
}

type AddNumber struct {
	plan *CountPlan
}

func (a *AddNumber) Name() string {
	return "add_number"
}

func (a *AddNumber) Do(ctx context.Context) ([]planner.Procedure, error) {
	a.plan.current = a.plan.current + 1
	return nil, nil
}

func initLogger() *zap.Logger {
	cfg := zap.NewProductionConfig()
	cfg.Encoding = "console"
	l, _ := cfg.Build()
	return l
}

This program tries to reach the Target (20 in this case) adding numbers to the current state. If you execute this program as it is you will get the following logs:

1.257894e+09	info	planner@v0.0.1/scheduer.go:41	Started execution plan count_plan	{"execution_id": "98d28eed-9b3b-4ad8-bfbd-1b5338d1a649"}
1.257894e+09	info	planner@v0.0.1/scheduer.go:59	Plan executed without errors.	{"execution_id": "98d28eed-9b3b-4ad8-bfbd-1b5338d1a649", "execution_time": "0s", "step_executed": 20}

As you can see, the scheduler executed the plan count_plan successfully, and it took 20 steps to get there (step_executed: 20).

Reasonable because, as you can see, the CounterPlan.Create function returns an AddNumber procedure and that procedure only adds 1 to the current state. It is just a counter; let’s make it a bit more fun. I want to add or substract random number until the target is reached. The program adds when the current state is above the target, when above it subtracts. If it’s equal we are done. This is a simple way to simulate something that has to adapt, too simple to sound cool but still something understandable.

Change the AddNumber to use a randomly generated number.

We need to change the AddNumber in order to add not 1 but a random number. Let’s do it:

var random *rand.Rand
func initRandom() {
    s1 := rand.NewSource(time.Now().UnixNano())
    random = rand.New(s1)
}

At this point, we can use random as part of the AddNumber.Do function.

type AddNumber struct {
	plan *CountPlan
}

func (a *AddNumber) Name() string {
	return "add_number"
}

func (a *AddNumber) Do(ctx context.Context) ([]planner.Procedure, error) {
	a.plan.current = a.plan.current + random.Intn(10)
	return nil, nil
}

For simplicity, I am taking a random number between 0 and 10. What happens now? The problem now is that we can go above the target, so we have to make our CounterPlan.Create function and our logic a bit more complicated.

Evolve the Create function to subtract numbers from the current state

func (p *CountPlan) Create(ctx context.Context) ([]planner.Procedure, error) {
	if p.current < p.Target {
		return []planner.Procedure{&AddNumber{plan: p}}, nil
	} else if p.current > p.Target {
		return []planner.Procedure{&SubtractNumber{plan: p}}, nil
	}
	return nil, nil
}

When we go above the target, the Plan subtracts a random number, and it keeps going until we get to it. SubtractNumber does the opposite of what AddNumber does, it subtracts a random number between 0 an 10.

type SubtractNumber struct {
	plan *CountPlan
}

func (a *SubtractNumber) Name() string {
	return "subtract_number"
}

func (a *SubtractNumber) Do(ctx context.Context) ([]planner.Procedure, error) {
	a.plan.current = a.plan.current - random.Intn(10)
	return nil, nil
}

You can run the result here, and you will see that based on the random numbers, it adds or subtracts the number of executed steps changes.

NOTE: the golang playground always starts from the same time; in my example, I use time as Seed; for this reason, to see a variation in the number of steps, you will have to run the code locally.

Conclusion

This is probably a too straightforward example, but let’s imagine that your Target is not fixed and varies based on external factors. Your house’s temperature and this program is a thermostat that has to keep your room at the desired temperature. Or the number of instances running in your cloud provider, and you have to keep them balanced. This last use case is the exact problem I solved writing keepit a replica set for Equinix Metal servers. I used planner, so check it out.

I didn’t highlight this example because this pattern gives you an excellent way to measure how reliable your program is. Think about it in this way; you can programmatically handle errors returning a procedure or more than one that can mitigate the error itself. It can be a “sleep for 5 minutes and retry”, or you can do something more complicated, and until the Plan keeps returning work to do, you will have the opportunity to succeed. I extracted an highlight from the Twitch stream rambling about this.

Have a nice week!