Skip to content

Commit 815f546

Browse files
committed
Add initial code
1 parent f66e82a commit 815f546

19 files changed

Lines changed: 3290 additions & 0 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: golangci-lint
2+
on:
3+
push:
4+
branches:
5+
- master
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
pull-requests: read
11+
12+
jobs:
13+
golangci:
14+
name: lint
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v4
19+
- name: Setup Go
20+
uses: actions/setup-go@v5
21+
with:
22+
go-version: 1.24.6
23+
- name: Install dependencies
24+
run: go get .
25+
- name: Lint
26+
uses: golangci/golangci-lint-action@v8
27+
with:
28+
version: v2.4
29+
- name: Build
30+
run: go build -v ./...
31+
- name: Test with the Go CLI
32+
run: go test

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Taskgraph
2+
3+
Package taskgraph provides declarative modelling and orchestration of workflows which look like
4+
directed acyclic graphs.
5+
6+
Taskgraph was designed to be used for building operators which reconcile Kubernetes custom
7+
resources (custom resource reconciliation is often a multi-step process, where each step has
8+
dependencies on other steps). However, it is not in any way tied to Kubernetes, and can be used for
9+
other workflows as well.
10+
11+
Tasks form the nodes in the graph, and the dependencies between them form the edges between the
12+
tasks. The dependencies are modeled as typed `Key`s; the tasks declare which keys they depend on
13+
and which Keys they provide values for. There is thus an edge in the graph between the task which
14+
produces a key and each task which depends on the key. It is an error for multiple tasks to
15+
provide the same key.
16+
17+
When executing a graph, keys are bound to values; tasks which depend on a key can then read the
18+
bound value and use it to perform its own work. It is an error for a task to not bind a key which
19+
the task declares that it provides.
20+
21+
Execution of a graph starts with the "source" tasks whose dependencies are already fulfilled
22+
(either because they do not depend on any keys, or because the dependencies have been provided as
23+
inputs to the graph). As tasks complete and produce bindings for their provided keys, the
24+
dependent tasks are triggered (with checks to wait for all dependencies to be available). As a
25+
result, execution of the graph is performed as a parallelised breadth first search of the graph.
26+
27+
This package contains some creative APIs which work around limitations in Golang generics; chief
28+
of these is the `ID` type used in place of slices of keys because Golang slices are invariant.
29+
30+
## Limitations and sharp edges
31+
32+
* Taskgraph has no deadlock detection.
33+
* Taskgraph runs every task in its own goroutine, with no limitation on how many tasks can be
34+
running at the same time.

binder.go

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
package taskgraph
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"sync"
7+
8+
set "github.com/deckarep/golang-set/v2"
9+
)
10+
11+
var (
12+
// ErrDuplicateBinding is returned when Binder.Store is called with a binding whose ID has already
13+
// been stored (which implies that the graph being executed contains multiple tasks producing
14+
// bindings for the same Key).
15+
ErrDuplicateBinding = errors.New("duplicate binding")
16+
)
17+
18+
// BindStatus represents the tristate of a Binding.
19+
type BindStatus int
20+
21+
const (
22+
// Pending represents where the key is unbound (i.e. that no task has yet provided a binding for
23+
// the key, and no input binding was provided).
24+
Pending BindStatus = iota
25+
26+
// Absent represents where the key is explicitly unbound (i.e. that the task which provides it was
27+
// unable to provide a value). The binding will contain an error, which is ErrIsAbsent by default
28+
// but can be another error to propagate information between tasks. This allows for errors which
29+
// do not terminate the execution of a graph.
30+
Absent
31+
32+
// Present represents where the key is bound to a valid value (i.e. the task was able to provide a
33+
// value, or the value was bound as an input).
34+
Present
35+
)
36+
37+
func (bs BindStatus) String() string {
38+
return map[BindStatus]string{
39+
Pending: "PENDING",
40+
Absent: "ABSENT",
41+
Present: "PRESENT",
42+
}[bs]
43+
}
44+
45+
// A Binding is a tristate wrapper around a key ID and an optional value or error. See the
46+
// documentation for BindStatus for details of the 3 states. Bindings are produced by calling the
47+
// Bind, BindAbsent, or BindError methods on a Key.
48+
type Binding interface {
49+
// ID returns the ID of the key which is bound by this Binding.
50+
ID() ID
51+
52+
// Status returns the status of this binding.
53+
Status() BindStatus
54+
55+
// Value returns the value bound to the key. This should only be called if Status() returns Present.
56+
Value() any
57+
58+
// Value returns the error bound to the key. This should only be called if Status() returns Absent.
59+
Error() error
60+
}
61+
62+
type binding struct {
63+
id ID
64+
status BindStatus
65+
value any
66+
err error
67+
}
68+
69+
func (b *binding) ID() ID {
70+
return b.id
71+
}
72+
73+
func (b *binding) Status() BindStatus {
74+
return b.status
75+
}
76+
77+
func (b *binding) Value() any {
78+
return b.value
79+
}
80+
81+
func (b *binding) Error() error {
82+
return b.err
83+
}
84+
85+
func (b *binding) String() string {
86+
if b.status == Present {
87+
return fmt.Sprintf("%s(%s -> %v)", b.status, b.id, b.value)
88+
}
89+
return fmt.Sprintf("%s(%s)", b.status, b.id)
90+
}
91+
92+
// bind a value to a key ID.
93+
func bind(id ID, value any) Binding {
94+
return &binding{
95+
id: id,
96+
status: Present,
97+
value: value,
98+
}
99+
}
100+
101+
// bindAbsent produces an absent binding for the given Key ID.
102+
func bindAbsent(id ID) Binding {
103+
return bindAbsentWithError(id, ErrIsAbsent)
104+
}
105+
106+
// bindAbsent produces an absent binding for the given Key ID.
107+
func bindAbsentWithError(id ID, err error) Binding {
108+
return &binding{
109+
id: id,
110+
status: Absent,
111+
err: err,
112+
}
113+
}
114+
115+
// bindPending produces a pending binding; this is only ever used when calling Get on a Binder.
116+
func bindPending(id ID) Binding {
117+
return &binding{
118+
id: id,
119+
status: Pending,
120+
}
121+
}
122+
123+
// A Binder is the state store for tasks in a graph.
124+
type Binder interface {
125+
// Store adds bindings to the binder that can be retrieved with Get().
126+
Store(...Binding) error
127+
128+
// Returns whether the given IDs have all been bound (as Present or Absent).
129+
Has(...ID) bool
130+
131+
// Get a previously stored binding. If no binding with the given ID has yet been stored, a binding with Status() = Pending is generated.
132+
Get(ID) Binding
133+
134+
// GetAll returns all stored bindings. This is typically used only for tests.
135+
GetAll() []Binding
136+
}
137+
138+
type binder struct {
139+
// Protects against concurrent access to the map
140+
sync.RWMutex
141+
142+
bindings map[ID]Binding
143+
}
144+
145+
func (b *binder) Store(bs ...Binding) error {
146+
b.Lock()
147+
defer b.Unlock()
148+
149+
for _, binding := range bs {
150+
if _, ok := b.bindings[binding.ID()]; ok {
151+
return wrapStackErrorf("%w: %q", ErrDuplicateBinding, binding.ID())
152+
}
153+
b.bindings[binding.ID()] = binding
154+
}
155+
156+
return nil
157+
}
158+
159+
func (b *binder) Has(ids ...ID) bool {
160+
b.RLock()
161+
defer b.RUnlock()
162+
163+
for _, id := range ids {
164+
if _, ok := b.bindings[id]; !ok {
165+
return false
166+
}
167+
}
168+
return true
169+
}
170+
171+
func (b *binder) Get(id ID) Binding {
172+
b.RLock()
173+
defer b.RUnlock()
174+
175+
if binding, ok := b.bindings[id]; ok {
176+
return binding
177+
}
178+
return bindPending(id)
179+
}
180+
181+
func (b *binder) GetAll() []Binding {
182+
b.RLock()
183+
defer b.RUnlock()
184+
185+
res := make([]Binding, 0, len(b.bindings))
186+
for _, binding := range b.bindings {
187+
res = append(res, binding)
188+
}
189+
return res
190+
}
191+
192+
// NewBinder returns a new binder.
193+
func NewBinder() Binder {
194+
return &binder{
195+
bindings: map[ID]Binding{},
196+
}
197+
}
198+
199+
// overlayBinder implements Binder to provide an overlay over an existing binder, such that newly
200+
// stored keys are added to the overlay only, but bindings can still be read from the base. This
201+
// still does not allow duplicate bindings (attempting to Store() a binding already present in the
202+
// base will return an error)
203+
type overlayBinder struct {
204+
base, overlay Binder
205+
}
206+
207+
func (ob *overlayBinder) Store(bindings ...Binding) error {
208+
for _, b := range bindings {
209+
if ob.base.Has(b.ID()) {
210+
return wrapStackErrorf("%w: %q", ErrDuplicateBinding, b.ID())
211+
}
212+
}
213+
return ob.overlay.Store(bindings...)
214+
}
215+
216+
func (ob *overlayBinder) Has(ids ...ID) bool {
217+
for _, id := range ids {
218+
if !ob.overlay.Has(id) && !ob.base.Has(id) {
219+
return false
220+
}
221+
}
222+
return true
223+
}
224+
225+
func (ob *overlayBinder) Get(id ID) Binding {
226+
if b := ob.overlay.Get(id); b.Status() != Pending {
227+
return b
228+
}
229+
return ob.base.Get(id)
230+
}
231+
232+
func (ob *overlayBinder) GetAll() []Binding {
233+
return append(ob.base.GetAll(), ob.overlay.GetAll()...)
234+
}
235+
236+
// NewOverlayBinder creates a new overlay binder.
237+
func NewOverlayBinder(base, overlay Binder) Binder {
238+
return &overlayBinder{
239+
base: base,
240+
overlay: overlay,
241+
}
242+
}
243+
244+
// graphTaskBinder implements Binder to run a Graph as a task. Any bindings for keys that should be
245+
// exposed are immediately added to the Binder of the parent graph, so that dependent tasks outside
246+
// this graph do not have to wait for every task in this graph to complete.
247+
type graphTaskBinder struct {
248+
internal, external Binder
249+
exposeKeys set.Set[ID]
250+
}
251+
252+
func (gtb *graphTaskBinder) Store(bindings ...Binding) error {
253+
for _, binding := range bindings {
254+
if gtb.exposeKeys.Contains(binding.ID()) {
255+
if err := gtb.external.Store(binding); err != nil {
256+
return err
257+
}
258+
} else {
259+
if err := gtb.internal.Store(binding); err != nil {
260+
return err
261+
}
262+
}
263+
}
264+
return nil
265+
}
266+
267+
func (gtb *graphTaskBinder) Has(ids ...ID) bool {
268+
for _, id := range ids {
269+
if !gtb.internal.Has(id) && !gtb.external.Has(id) {
270+
return false
271+
}
272+
}
273+
return true
274+
}
275+
276+
func (gtb *graphTaskBinder) Get(id ID) Binding {
277+
if ib := gtb.internal.Get(id); ib.Status() != Pending {
278+
return ib
279+
}
280+
return gtb.external.Get(id)
281+
}
282+
283+
func (gtb *graphTaskBinder) GetAll() []Binding {
284+
return append(gtb.internal.GetAll(), gtb.external.GetAll()...)
285+
}
286+
287+
// TestOnlyNewGraphTaskBinder creates a new graph task binder. This is exported for testing, and
288+
// should not be called in production code.
289+
func TestOnlyNewGraphTaskBinder(internal, external Binder, exposeKeys set.Set[ID]) Binder {
290+
return &graphTaskBinder{
291+
internal: internal,
292+
external: external,
293+
exposeKeys: exposeKeys,
294+
}
295+
}

0 commit comments

Comments
 (0)