Tutorials

Building a PagerDuty schedule

Develop an on-call schedule for an incident response platform

This tutorial assumes you already completed the steps described in the 5-minute getting started experience. To test that the Nextmv CLI is correctly configured, you can optionally run the following command on your terminal. It will get some files that are necessary to work with the Nextmv Platform. You can see the expected output as well.

nextmv install
Copy

The Nextmv Software Development Kit (SDK) lets you automate any operational decision in a way that looks and feels like writing other code. It provides the guardrails to turn your data into automated decisions and test and deploy them into production environments.

Introduction

This tutorial will walk you through our pager-duty template. To get the template, simply run the following command.

nextmv init -t pager-duty
Copy

You can check that all files are available in the newly created pager-duty folder. You can check the structure of the folder.

pager-duty
├── LICENSE
├── README.md
├── go.mod
├── go.sum
├── input.json
├── main.go
└── pager-duty.code-workspace

0 directories, 7 files
Copy
  • LICENSE contains the Apache License 2.0 under which we distribute this template.
  • README.md gives a short introduction to the PagerDuty problem and shows you how to run the template.
  • go.mod and go.sum define a Go module and are used to manage dependencies, including the Nextmv SDK.
  • input.json describes the input data for a specific PagerDuty problem that is solved by the template.
  • main.go contains the actual code of pager-duty app.
  • The pager-duty.code-workspace file should be used to open the template in Visual Studio Code. It is pre-configured for you so that you can run and debug the template without any further steps.

Now you can run the template with the Nextmv CLI, reading from the input.json file and writing to an output.json file. The following command shows you how to specify solver limits as well. You should obtain an output similar to the one shown.

nextmv run local main.go -- \
    -hop.runner.input.path input.json \
    -hop.runner.output.path output.json \
    -hop.solver.limits.duration 5s \
    -hop.solver.diagram.expansion.limit 1 \
    -hop.solver.limits.solutions 1
Copy

Note that transient fields are represented with "\u003c\u003cPRESENCE\u003e\u003e", which is the unicode representation of "<<PRESENCE>>", due to their dynamic nature: every time the input is run, these fields will have a different value. This representation is compliant with the jsonassert package.

Now we will show you, step by step, what the code inside the main.go achieves.

Dissecting the app

The first part of the main.go defines a package name, imports packages that are needed by the model and creates a main function - the starting point for the app. We create a runner using the Run function from the Nextmv run package. This function executes a solver, defined below.

package main

import (
	"fmt"
	"math"
	"time"

	"github.com/nextmv-io/sdk/model"
	"github.com/nextmv-io/sdk/run"
	"github.com/nextmv-io/sdk/store"
)

func main() {
	run.Run(solver)
}
Copy

The Input

But before we look into the solver function, we will examine the structs: input, user, and preference.

type input struct {
	ScheduleStart time.Time `json:"schedule_start"`
	ScheduleEnd   time.Time `json:"schedule_end"`
	Users         []user    `json:"users"`
}

type user struct {
	Name        string      `json:"name,omitempty"`
	ID          string      `json:"id,omitempty"`
	Type        string      `json:"type,omitempty"`
	Unavailable []time.Time `json:"unavailable,omitempty"`
	Preferences preference  `json:"preferences,omitempty"`
}

type preference struct {
	Days []time.Time `json:"days"`
}
Copy

The input struct has three fields. ScheduleStartand ScheduleEnd define the full scheduling window to plan for and Users, which holds a slice of users who need to be scheduled. The User struct has a Name (to identify each individual), Id and Type (PagerDuty specific fields), and Unavailable days and preference, an array of user Preferences, both in RFC3339 format for ease of passing into PagerDuty.

The Output

Now that we've defined the input that will be run through our model, we need to define the output. Since this data will be pushed through to PagerDuty, we created two output structs for ease of sending our solution directly to their API: override and assignedUser.

type override struct {
	Start    time.Time    `json:"start"`
	End      time.Time    `json:"end"`
	User     assignedUser `json:"user"`
	TimeZone string       `json:"time_zone"`
}

type assignedUser struct {
	Name string `json:"name,omitempty"`
	ID   string `json:"id,omitempty"`
	Type string `json:"type,omitempty"`
}
Copy

override provides the Start, End, User, and Timezone of the override to work with PagerDuty. And assignedUser defines the Name, Id, and Type of the user assigned to that particular day.

Note that the json:"XXX" after the fields make use of a Go feature to automatically map the data from the input.json to those entities and fields.

The Solver

The solver function is where the model is defined. The function's signature adheres to the run.Run function we saw earlier already.

func solver(input input, opts store.Options) (store.Solver, error) {
Copy

When you first ran the template you passed in the parameter -hop.runner.input.path followed by the path to an input file. This file is automatically parsed and converted to our input struct. Other option arguments are also interpreted automatically and passed to the solver as an Options struct.

Our end goal is to assign a single user for each day in the schedule period. To accomplish this, within the solver function, we start by initializing an empty root store.

pagerDuty := store.New()
Copy

Next, add a starting state to store. Create a domain for each day and initialize each day to all users. In other words, the root state begins by assuming every user is available on every day and we'll trim down from there.

ndays := int(input.ScheduleEnd.Sub(input.ScheduleStart).Hours()/24) + 1
users := model.NewDomain(model.NewRange(0, len(input.Users)-1))
days := store.Repeat(pagerDuty, ndays, users)
Copy

Next, we initialize some variables for use later in the Value function. assignedDays will be used to ensure a fair and balanced schedule across the team while happiness tracks how often we fulfill user preferences.

assignedDays := store.NewSlice[int](pagerDuty)
for range input.Users {
	pagerDuty = pagerDuty.Apply(assignedDays.Append(0))
}
happiness := store.NewSlice[int](pagerDuty)
for range input.Users {
	pagerDuty = pagerDuty.Apply(happiness.Append(0))
}
Copy

Now, we make some modifications to our root schedule. We loop through each day and then user. If the user is unavailable on that day, we remove that user’s index from the domain of available users on that day using the days.Remove(..) logic below.

Note that any operations on store variables such as .Remove will return a slice of changes. We must then apply those changes to the root store in order to update it.

for _, unavailable := range user.Unavailable {
	if date.Equal(unavailable) {
		pagerDuty = pagerDuty.Apply(
			days.Remove(dateIndex, []int{userIndex}),
		)
	}
}
Copy

We populate a preferenceMap which creates a lookup table of date index to a slice of user indices for the users who prefer to be on-call that day.

preferenceMap := map[int][]int{}
		for _, preference := range user.Preferences.Days {
			if date == preference {
				preferenceMap[dateIndex] = append(preferenceMap[dateIndex], userIndex)
			}
		}
Copy

While looping through days, we check whether there's any day where no one is available to work. If so, this is considered infeasible, and the run will exit with an error.

if days.Domain(pagerDuty, dateIndex).Empty() {
	return nil, fmt.Errorf("problem is infeasible")
}
Copy

Lastly, we check whether there are any days with exactly 1 user available. If so, assign that user and increment their assignedDays by 1, and increase happiness by 1 if this was a preferred day.

// If there is only 1 person available, assign that person to the day.
if dayAssigned {
	userAssignedDays := assignedDays.Get(pagerDuty, assignedUser)

	// Add 1 to their day length.
	pagerDuty = pagerDuty.Apply(
		assignedDays.Set(assignedUser, userAssignedDays+1),
	)

	// Add 1 to their happiness score if they preferred to work this day.
	if preferredUsers, ok := preferenceMap[dateIndex]; ok {
		for _, p := range preferredUsers {
			if p == assignedUser {
				pagerDuty = pagerDuty.Apply(
					happiness.Set(p, happiness.Get(pagerDuty, p)+1),
				)
				break
			}
		}
	}
}
Copy

The Generate function

The Generate function is used to generate the search tree and traverse the search space. In this template, the Generate function finds the first day with 2 or more users available. It then loops through each available user for that day and creates a child store in which we assign that user to the day and increase assignedDays and happiness, where applicable.

This returns an eager generater, meaning all the child stores are created upfront.

	pagerDuty = pagerDuty.Generate(func(s store.Store) store.Generator {
// We define the method for generating child states from each parent
// state. Each time a new store is generated - we attempt to generate more
// stores.

// We find the first day with 2 or more users available.
dayIndex, ok := days.First(s)

if !ok {
	return nil
}

// Create a slice of stores where we'll attempt to assign each of those
// available users to the day.
stores := make([]store.Store, days.Domain(s, dayIndex).Len())
for i, user := range days.Domain(s, dayIndex).Slice() {
	// We increment the day length for the user we want to assign.
	userAssignedDays := assignedDays.Get(s, user)
	userAssignedDays++

	// We assign the user and apply changes for that assignment and the
	// day length increment.
	newStore := s.Apply(
		days.Assign(dayIndex, user),
		assignedDays.Set(user, userAssignedDays),
	)

	// If we were able to assign user to preferred day, increment
	// happiness score and apply changes.
	if preferredUsers, ok := preferenceMap[dayIndex]; ok {
		for _, p := range preferredUsers {
			if p == user {
				newStore = newStore.Apply(happiness.Set(p, happiness.Get(pagerDuty, p)+1))
			}
		}
	}

	stores[i] = newStore
}

// Use an Eager generator to return all child stores we just created for
// all users available on this day.
return store.Eager(stores...)
Copy

The Validate function

The Validate function checks whether a store is an operationally valid solution or not. For our PagerDuty schedule, this means that there’s exactly one user assigned to each day.

}).Validate(func(s store.Store) bool {
	// Next, we define operational validity on the store. Our plan is
	// operationally valid if all days are assigned exactly to one person.
	return days.Singleton(s)
Copy

The Value function

Now we calculate the store's value to evaluate whether one store is better than another. As mentioned before, we want to create a fair schedule by balancing days assigned across users and maximizing happiness.

In order to balance days assigned, we compute the minimum and maximum of the assignedDays slice. We want the difference between those two values to be small, meaning there’s a small range in the number of days assigned to each person.

In order to maximize happiness, we focus on maximizing the minimum happiness score. This inherently attempts to bump up the happiness for each user.

	}).Value(func(s store.Store) int {
// Now we define our objective value. This is the quantity we try to
// minimize (or maximize or satisfy). This balances days assigned to each
// user.

// Calculate sum of squared assigned days.
sumSquares := sumSquare(assignedDays.Slice(s))

// Calculate minimum happiness across users.
minHappiness := min(happiness.Slice(s))

// Balance days between users and maximize minimum happiness.
return sumSquares - minHappiness
Copy

The Format function

Using the Format function we change the output format to make it easier to read. In this case, we want to easily paste the output into a POST request to PagerDuty. We want to output an array of override. To do this, we loop through each day in our schedule and create an override for each day. (We also added on the min assigned days to our output for review as well.)

}).Format(func(s store.Store) any {
	// Next, we define the output format for our schedule.
	// We want to structure our output in a way that the PagerDuty API
	// understands.

	values, ok := days.Values(s)

	if !ok {
		return "No schedule found"
	}
	overrides := []override{}
	for v, nameIndex := range values {
		assignedUser := assignedUser{
			Name: input.Users[nameIndex].Name,
			ID:   input.Users[nameIndex].ID,
			Type: input.Users[nameIndex].Type,
		}
		overrides = append(overrides, override{
			Start:    input.ScheduleStart.AddDate(0, 0, v),
			End:      input.ScheduleStart.AddDate(0, 0, v+1),
			User:     assignedUser,
			TimeZone: "UTC",
		})
	}

	return map[string]any{
		"overrides":       overrides,
		"min_days_worked": min(assignedDays.Slice(s)),
	}
})
Copy

Returning the solver

Finally, we return a solver for our store input by using the Minimizer function passing in options that were given at the very beginning by the calling function. This solver is then executed by the run.Run function from the beginning.

return pagerDuty.Minimizer(opts), nil
Copy

Page last updated

Go to on-page nav menu