m/pkg/combinectx: implement

This implements combinectx, a Go library for combining two contexts into
a single one. We need this for the new curator logic (where we want to
cancel RPC calls both when the incoming request gets canceled but also
when leadership status changes), and this functionality has been
factored out as a reusable, generic library.

Prior art:

1) https://github.com/golang/go/issues/36503
   Proposal to add Merge() to context stdlib package. Unimplemented.

2) https://github.com/teivah/onecontext
   Complex reflect-based logic for arbitrary amount of contexts to join,
   no functionality to detect which context caused the joined context to
   be canceled.

3) https://github.com/LK4D4/joincontext
   No functionality to detect which context caused the joined context to
   be canceled.

Change-Id: I774607da38b06c192ff0fee133eb258abd500864
Reviewed-on: https://review.monogon.dev/c/monogon/+/123
Reviewed-by: Leopold Schabel <leo@nexantic.com>
diff --git a/metropolis/pkg/combinectx/example_test.go b/metropolis/pkg/combinectx/example_test.go
new file mode 100644
index 0000000..a6bb3aa
--- /dev/null
+++ b/metropolis/pkg/combinectx/example_test.go
@@ -0,0 +1,77 @@
+package combinectx_test
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	"source.monogon.dev/metropolis/pkg/combinectx"
+)
+
+// ExampleCombine shows how to combine two contexts for use with a contextful
+// method.
+func ExampleCombine() {
+	// Let's say you're trying to combine two different contexts: the first one is
+	// some long-term local worker context, while the second is a context from some
+	// incoming request.
+	ctxA, cancelA := context.WithCancel(context.Background())
+	ctxB, cancelB := context.WithTimeout(context.Background(), time.Millisecond * 100)
+	defer cancelA()
+	defer cancelB()
+
+	// doIO is some contextful, black box IO-heavy function. You want it to return
+	// early when either the long-term context or the short-term request context
+	// are Done().
+	doIO := func(ctx context.Context) (string, error) {
+		t := time.NewTicker(time.Second)
+		defer t.Stop()
+
+		select {
+		case <-ctx.Done():
+			return "", ctx.Err()
+		case <-t.C:
+			return "successfully reticulated splines", nil
+		}
+	}
+
+	// Combine the two given contexts into one...
+	ctx := combinectx.Combine(ctxA, ctxB)
+	// ... and call doIO with it.
+	v, err := doIO(ctx)
+	if err == nil {
+		fmt.Printf("doIO success: %v\n", v)
+		return
+	}
+
+	fmt.Printf("doIO failed: %v\n", err)
+
+	// The returned error will always be equal to the combined context's Err() call
+	// if the error is due to the combined context being Done().
+	if err == ctx.Err() {
+		fmt.Printf("doIO err == ctx.Err()\n")
+	}
+
+	// The returned error will pass any errors.Is(err, context....) checks. This
+	// ensures compatibility with blackbox code that performs special actions on
+	// the given context.
+	if errors.Is(err, context.DeadlineExceeded) {
+		fmt.Printf("doIO err is context.DeadlineExceeded\n")
+	}
+
+	// The returned error can be inspected by converting it to a *Error and calling
+	// .First()/.Second()/.Unwrap(). This lets the caller figure out which of the
+	// parent contexts caused the combined context to expires.
+	cerr := &combinectx.Error{}
+	if errors.As(err, &cerr) {
+		fmt.Printf("doIO err is *combinectx.Error\n")
+		fmt.Printf("doIO first failed: %v, second failed: %v\n", cerr.First(), cerr.Second())
+	}
+
+	// Output:
+	// doIO failed: context deadline exceeded
+	// doIO err == ctx.Err()
+	// doIO err is context.DeadlineExceeded
+	// doIO err is *combinectx.Error
+	// doIO first failed: false, second failed: true
+}