waltz

package module
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 30, 2026 License: MIT Imports: 19 Imported by: 0

README

waltz

The logic of a write-ahead log for the Temporal server's history shards. You bring the storage — both ends of it: the log, wal.Log, and the database, cold.Store. Five methods on the log and two on the database are nearly the whole of what waltz asks you to implement.

Temporal's history service is write-heavy: one workflow moves through hundreds of state transitions, and each is a database transaction that rewrites the same mutable-state rows and inserts task rows that are often deleted moments later. The idea is to put a durable per-shard log in front of that database, acknowledge a write as soon as the log holds it, and fold many acknowledged writes into one later transaction. A hundred transitions become a hundred log appends plus one database transaction instead of a hundred — whatever an append costs.

waltz is not a persistence implementation, and that is the first thing to understand about it. It writes to no disk, opens no connection and speaks no wire protocol; it contains no line of code that would. What it is, is everything between two interfaces you implement: the log an acknowledgement lands in (wal.Log) and the database a fold lands on (cold.Store). Both are yours to write over whatever storage you run. What waltz owns is the part that is genuinely hard — the window, the fold, the fencing, the replay, the bound on unapplied work, and what each of them must do when a write, a process or a shard handover fails.

The server does not know any of this. waltz ships as a persistence decorator: you compose it over the plugin that owns your data and hand the result to temporal.WithCustomDataStoreFactory, the same door a custom persistence backend already goes through. Temporal goes on believing it writes to a database, reads from a database, and that what it read is true.

Status: read this first

This is a research materialisation, not a deployable system. The logic is complete and tested; the storage is not there.

Because the storage is yours, this repository has to supply something at both seams in order to run at all — so it ships two implementations that live in the test process. They are why go test ./... boots a real Temporal server and runs a real workflow on a fresh clone. They are also why:

  • nothing here is durable. Both live in one process's memory and die with it. No fsync, no network and no quorum has ever been in the path of anything in this tree.
  • no process has ever been killed. Replay is exercised in process; a real handover between two owners on two machines is not.
  • no backend has been run under load, and no number produced here is a performance claim.

Taking waltz to production means writing a wal.Log over real storage and a cold.Store over your real database, and testing the parts this repository cannot reach. Chapter 15 is that boundary in full.

Built against go.temporal.io/server v1.29.6 and Go 1.26. The server version is not a soft floor. A log entry mirrors that version's write-request structs field by field, hand-written rather than reflected, so a field a newer server adds is a field this codec has nowhere to put — an acknowledged write folded into the database with part of it missing. A guard in mutation makes that a failing test at the bump rather than a lost column.

The first rule

Data once acknowledged is never lost. Refusing a write is acceptable. Taking the server down is acceptable. Losing a write that was acknowledged is not — and it is not made acceptable by the storage underneath having been configured badly. A warning followed by a successful write is a lie the caller acts on.

This outranks throughput and availability, and it is what the design is shaped by. An early acknowledgement is easy; keeping it honest across a crash is the whole problem. It is why one drain is one transaction with its watermark inside it, and why a drain whose outcome is unknown is resolved by reading that watermark back rather than by re-applying.

DURABILITY.md is the standing list of every known way that rule can break: what is closed, with the mechanism and a test that fails without it; what is open, including the two shipped implementations dying with the process; and what nobody has established, which is treated as open. Read it before trusting a green run, and add to it before fixing anything it does not name.

Try it

git clone https://github.com/aromanovich/waltz && cd waltz
make test    # go test ./... -count=1
make race    # the same under -race, on a tenth of the acceptance stream
make lint    # golangci-lint plus gopls's modernize, both pinned in the Makefile
make vuln    # govulncheck over the module and the toolchain

No cluster, no container, no fixed port, no cgo, no build tag. A fresh clone runs everything there is, including a four-service Temporal server booting over waltz and completing a workflow through the SDK (internal/verify/e2e).

The composition

package main

import (
	"context"
	"os"
	"os/signal"
	"syscall"
	"time"

	"go.temporal.io/server/common/config"
	"go.temporal.io/server/common/log"
	"go.temporal.io/server/common/persistence/client"
	"go.temporal.io/server/temporal"

	"github.com/aromanovich/waltz"
	"github.com/aromanovich/waltz/cold/memcold"
	"github.com/aromanovich/waltz/cycle"
	"github.com/aromanovich/waltz/wal/memwal"
)

func main() {
	// The cold store. memcold is the one shipped here — Temporal's own SQL
	// persistence over a database in this process — and a deployment puts its
	// own cold.Store here instead.
	store, release, err := memcold.New("active")
	if err != nil {
		panic(err)
	}
	defer release()

	layer, err := waltz.Compose(
		waltz.Backends{
			Log:  memwal.New(), // your wal.Log; memwal is the one shipped here
			Cold: store,        // your cold.Store
		},
		cycle.Fixed(cycle.Defaults()),
		waltz.DefaultTaskCategories(),
		log.NewCLILogger(),
		nil, // the server's metrics handler arrives later, through the factory
	)
	if err != nil {
		panic(err)
	}

	// base is the persistence factory that owns the cold data — memcold's here,
	// your plugin's otherwise.
	var base client.AbstractDataStoreFactory = memcold.NewAbstractDataStoreFactory(store)

	// The server's own configuration, read the way a stock server reads it. It
	// must name a custom datastore in Persistence.DataStores; that naming is the
	// whole of how the factory below enters its persistence graph.
	cfg, err := config.LoadConfig("development", "config", "")
	if err != nil {
		panic(err)
	}

	s, err := temporal.NewServer(
		temporal.WithConfig(cfg),
		temporal.WithCustomDataStoreFactory(layer.AbstractFactory(base)),
	)
	if err != nil {
		panic(err)
	}
	if err := s.Start(); err != nil {
		panic(err)
	}
	defer func() {
		_ = s.Stop()
		// After the server has stopped: the shutdown drain still needs a store
		// to write to. `defer release()` above runs after this one, which is the
		// order that leaves the drain a database.
		if err := layer.Shutdown(context.Background(), 30*time.Second); err != nil {
			panic(err)
		}
	}()

	// Start returns once the services are up, so a main that did not wait here
	// would run both deferred shutdowns immediately — and the ordering they are
	// written for is the whole point of them.
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
	<-stop
}

The lifecycle brackets the server's, and both ends matter. Compose opens nothing and reaches nothing, so a policy whose memory budget does not add up stops a process that has connected to nothing yet, rather than a node already serving. Layer.Shutdown runs last so that every window still open has somewhere to drain.

Configuration is a wal section inside the custom datastore's own options: absent means passthrough, malformed means a refusal to start rather than a node quietly running the other mode. The operating numbers live in the server's dynamic config: nine settings, five of them re-read at the decision that consults them and four read once, when the node builds its policy. Chapter 08 is every key.

The two seams

waltz sits between two things it does not own, and a deployment replaces both.

the contract shipped here what judges your implementation
the log wal.Log wal/memwal, in process memory wal/waltest — this repository's conformance suite: twenty-one cases, one call
the database cold.Store cold/memcold, Temporal's own SQL persistence over in-process SQLite Temporal's four exported persistence suites, which memcold runs unmodified

wal.Log is an append-only, fenced, gap-free sequence of entries per shard — five methods, opaque payloads, no Temporal type anywhere in it. Running the suite against your backend is one call:

func TestMyBackendKeepsTheContract(t *testing.T) {
	waltest.RunContractSuite(t, myBackend())
}

Two obligations the suite cannot ask about, and both matter more than any case in it. Every case drives one wal.Log value in one process, so it reads back through the thing that appended: a backend acking into memory it never gets out of the process passes all twenty-one, and so does one whose owning epoch is a field rather than a row. waltest.CheckReopen asks the half that needs no second process — it takes a way of opening your log, writes a run, closes it, opens the storage again and asks the fresh value for the entries, for who owns the shard, and for the position to continue at. And waltest.CheckRetention asks the half that needs the clock, since a trim is the only removal the contract excuses and the suite runs in milliseconds. Both return an error rather than taking a *testing.T, so a deployment runs them from whatever harness it has.

cold.Store is two methods: Apply, which commits a folded window as one transaction, and Watermark, which reads back the seqno that transaction carried. It is one interface rather than the two halves it is made of because one value has to answer both — a watermark is only meaningful about the transactions that wrote it, so a layer reading it from anywhere else trims a log against a witness that never saw it.

What waltz asks for beyond those two is one read, and it is on the persistence plugin the layer decorates rather than on cold.Store: a current-execution row with last_write_version beside it, the column Temporal's own response type has nowhere to carry, and the one a create's condition is decided on. A base store that cannot answer it is refused while the server is still starting, rather than run in a reduced mode.

The two rows are not mirror images, and the asymmetry is worth knowing before you start. The log's contract is waltz's own invention, so waltz owes it a suite and ships one. What a database owes a Temporal server is Temporal's to state, and Temporal states it as four suites it exports — so nothing exported from here judges somebody else's cold.Store. What is written down instead is the four obligations an implementation carries, with cold/memcold as the worked example of all four: chapter 04 has both.

Documentation

docs/handbook is the book, and it is where every question this page raises is answered properly. Chapters 01–11 are the reference — the components, the contracts, the write and read paths, the shard lifecycle, the configuration keys, the metrics, the runbooks, the suites. Chapters 12–15 are the reasoning the reference states without arguing for it: what one write cost before any of this existed, which designs were tried and refused, where each shipped default came from, and what a green test run does not say.

Start at chapter 01. Every identifier, default and count named in those pages exists in the code, and every chapter ends with the files it draws from.

docs/adr holds the decisions somebody will otherwise try to reverse, and CONTEXT.md is the vocabulary they are written in.

Licence

MIT — see LICENSE. Parts of cold/memcold are derived from Temporal's own persistence implementation, which is MIT-licensed; NOTICE reproduces that licence and says which files are derived and how.

Documentation

Overview

Package waltz puts a write-ahead log in front of a Temporal history shard's cold store, so that many mutations are acked into the log and folded into one cold-store transaction. It implements no persistence itself: the log is a wal.Log and the cold store is a cold.Store, both the caller's. One implementation of each ships here, wal/memwal and cold/memcold, and both die with the process.

waltz is developed against go.temporal.io/server v1.29.6 and needs Go 1.26 or newer. The requirement on the server is a floor under minimal version selection and not a pin, so a consumer already on a newer one builds against it with no diagnostic: the WAL record format mirrors v1.29.6's request structs field-for-field, and the mirror's completeness is checked against that version here, never in a consumer's build. A field a newer server adds is a field this codec drops from a write it has already acked.

Compose is the only composition; a new caller's need belongs there as a parameter. Layer.AbstractFactory is the door out: the value a custom main hands to temporal.WithCustomDataStoreFactory, which is the whole of how a server is built over this layer.

The configuration is a `wal` section inside the custom datastore's own options. An absent section is passthrough; a malformed one is a refusal to start ([ADR 0006]).

The layer's lifecycle brackets the server's: it is composed before the server is built, so a failed budget assertion stops the binary, and Layer.Shutdown runs after it has stopped, so the shutdown drain still has a store to write to.

[ADR 0006]: docs/adr/0006-the-wal-configuration-is-a-section-of-the-datastore-options.md

Example

README's "The composition" section carries this function's body as its sample, so an edit to either belongs in both. It has no `// Output:` comment and is therefore compiled and never run, which is the point: it starts a server, and what it is here to catch is a rename that leaves the README's code not compiling.

package main

import (
	"context"
	"os"
	"os/signal"
	"syscall"
	"time"

	"go.temporal.io/server/common/config"
	"go.temporal.io/server/common/log"
	"go.temporal.io/server/common/persistence/client"
	"go.temporal.io/server/temporal"

	"github.com/aromanovich/waltz"
	"github.com/aromanovich/waltz/cold/memcold"
	"github.com/aromanovich/waltz/cycle"
	"github.com/aromanovich/waltz/wal/memwal"
)

func main() {
	// The cold store. memcold is the one shipped here — Temporal's own SQL
	// persistence over a database in this process — and a deployment puts its
	// own cold.Store here instead.
	store, release, err := memcold.New("active")
	if err != nil {
		panic(err)
	}
	defer release()

	layer, err := waltz.Compose(
		waltz.Backends{
			Log:  memwal.New(), // your wal.Log; memwal is the one shipped here
			Cold: store,        // your cold.Store
		},
		cycle.Fixed(cycle.Defaults()),
		waltz.DefaultTaskCategories(),
		log.NewCLILogger(),
		nil, // the server's metrics handler arrives later, through the factory
	)
	if err != nil {
		panic(err)
	}

	// base is the persistence factory that owns the cold data — memcold's here,
	// your plugin's otherwise.
	var base client.AbstractDataStoreFactory = memcold.NewAbstractDataStoreFactory(store)

	// The server's own configuration, read the way a stock server reads it. It
	// must name a custom datastore in Persistence.DataStores; that naming is the
	// whole of how the factory below enters its persistence graph.
	cfg, err := config.LoadConfig("development", "config", "")
	if err != nil {
		panic(err)
	}

	s, err := temporal.NewServer(
		temporal.WithConfig(cfg),
		temporal.WithCustomDataStoreFactory(layer.AbstractFactory(base)),
	)
	if err != nil {
		panic(err)
	}
	if err := s.Start(); err != nil {
		panic(err)
	}
	defer func() {
		_ = s.Stop()
		// After the server has stopped: the shutdown drain still needs a store
		// to write to. `defer release()` above runs after this one, which is the
		// order that leaves the drain a database.
		// A main that finds entries here logs them before it exits: they are in
		// the log for the next owner, and nothing replays them if this node comes
		// back without the wal section.
		if err := layer.Shutdown(context.Background(), 30*time.Second); err != nil {
			panic(err)
		}
	}()

	// Start returns once the services are up, so a main that did not wait here
	// would run both deferred shutdowns immediately — and the ordering they are
	// written for is the whole point of them.
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
	<-stop
}

Index

Examples

Constants

View Source
const SectionKey = "wal"

SectionKey is the key the WAL layer's configuration lives under, inside the custom datastore's options. A deployment or a test writing a config writes this key.

Variables

View Source
var (
	WindowMutations = dynamicconfig.NewGlobalIntSetting(
		"wal.windowMutations", measured.Mutations,
		`WindowMutations is the drain trigger in mutations: the window is applied as one
transaction when it holds this many. It sits at the measured collapse knee, so lowering it
gives collapse away and raising it holds more unapplied work per shard.`)

	WindowBytes = dynamicconfig.NewGlobalIntSetting(
		"wal.windowBytes", measured.Bytes,
		`WindowBytes is the same trigger in encoded bytes, whichever trips first.`)

	WindowAge = dynamicconfig.NewGlobalDurationSetting(
		"wal.windowAge", measured.Age,
		`WindowAge drains a tail nothing is pushing on. It is a recovery-budget choice rather
than a measured one: it bounds how long an idle shard's acked-but-unapplied work waits, and
therefore what the next owner would replay. A change takes effect within one window, since
the age timer is re-armed at every tick — which is also why zero or a negative value is read
as the default rather than obeyed: it is the interval that timer is re-armed at.`)

	TrimEvery = dynamicconfig.NewGlobalIntSetting(
		"wal.trimEvery", measured.TrimEvery,
		`TrimEvery is the trim cadence in drains: the log below the applied watermark is
deleted every this many drains. A DeleteRange per drain is a transaction per drain for no
gain; raising it leaves more of the log behind, which is what a post-mortem reads.`)

	TrimAfter = dynamicconfig.NewGlobalDurationSetting(
		"wal.trimAfter", measured.TrimAfter,
		`TrimAfter is the same cadence in time, whichever trips first. Raising TrimEvery alone
does not keep a log: this one fires anyway.`)

	HardMaxEntries = dynamicconfig.NewGlobalIntSetting(
		"wal.hardMaxEntries", measured.HardMaxEntries,
		`HardMaxEntries is invariant I10's bound on one shard's tail in entries: what has been
acked and not yet applied. A shard at the bound refuses its writers with ResourceExhausted
rather than parking them behind the apply. READ AT START-UP: a change needs the history
services restarted. It is read once because it is one half of a bound whose other half is
wal.hardMaxBytes — neither unit works alone, and a node honouring one of the two from a
different edit than the other is a bound nobody wrote.`)

	HardMaxBytes = dynamicconfig.NewGlobalIntSetting(
		"wal.hardMaxBytes", measured.HardMaxBytes,
		`HardMaxBytes is the same bound in encoded bytes — two units because neither works
alone: one workflow near the server's own 8 MB mutable-state limit turns an entries-only bound
into a byte budget with no ceiling. It is arithmetic and not taste: the node's tail budget
divided by the shards it may own. READ AT START-UP: it is a factor of the product
cycle.Config.CheckBudget asserts before the node boots, and a factor that moved afterwards
would be that refusal with nothing behind it.`)

	MaxShards = dynamicconfig.NewGlobalIntSetting(
		"wal.maxShards", measured.MaxShards,
		`MaxShards is what one node may own at once — not the cluster's shard count: the default
256 is 128 in steady state, doubled for a failover. It is a premise of the budget assertion and not
a limit anything enforces: no acquire is refused past it, deliberately, since a node over its share
is a cluster that has just lost hosts. Size it for the worst assignment you intend to survive, not
for the steady one. READ AT START-UP.`)

	TailBudgetBytes = dynamicconfig.NewGlobalIntSetting(
		"wal.tailBudgetBytes", measured.TailBudgetBytes,
		`TailBudgetBytes is the RAM one node may hold as unapplied tail. hardMaxBytes × maxShards
must fit in it or the node refuses to start — cycle.Config.CheckBudget is run rather than
written down, because a doc line does not survive a config edit. READ AT START-UP.`)
)

The policy's dynamic half as settings of the server's dynamic config, under the same `wal.` name the section has. Five are read at the decision that consults them; the other four are read once, when the policy is built ([atStart]), and their descriptions say so.

Exported so a process configuring the layer from flags or an environment variable can build a dynamicconfig.StaticClient over these keys rather than a second spelling of them.

Functions

func AbstractFactory

AbstractFactory is the value a custom main hands to temporal.WithCustomDataStoreFactory: base — the persistence plugin that owns the cold store — decorated with opts. opts is the whole of the mode, the zero value being passthrough, so this is the door a binary with no `wal` section takes as well.

Metrics are not an argument: the server's handler does not exist yet and arrives at wrapper.AbstractDataStoreFactory.NewFactory, which fills it into the stores and hands it to the layer through wrapper.MetricsSink.

func NewPolicy

func NewPolicy(dc *dynamicconfig.Collection, w WAL) cycle.Policy

NewPolicy is the whole policy a node runs: the section's fields and the start-only settings read once, the live settings read at every decision that consults one.

A nil collection means the settings' own defaults, not a refusal — it is what a process with no dynamic config has. A caller that holds a cycle.Config already passes cycle.Fixed straight to Compose instead.

Types

type Backends

type Backends struct {
	Log  wal.Log
	Cold cold.Store
}

Backends is where a composed layer's bytes go: the log its appends are ordered in, and the cold store one drain becomes a transaction on and an unknown outcome is read back from.

They are Compose's parameter rather than something it builds, and that is the point of the type: this library implements neither. Both are seams the layer is meant to be answerable at without a cluster — wal/memwal is a whole implementation of the WAL contract (ADR 0002), and cold.Store exists so a drain's outcome can be varied without one. A caller that wants the intercept path in process reaches it here rather than by building a second registry, which is the one thing this package asks callers not to do.

type Config

type Config struct {
	Enabled bool
	WAL     WAL
}

Config is one node's WAL configuration as this layer reads it: whether the layer is on at all, and the section saying how it behaves. The rest of the custom datastore's options belong to the plugin holding the cold store, and this package neither reads nor validates them.

Enabled false is passthrough and not an error; the zero Config is that.

func Parse

func Parse(options map[string]any) (Config, error)

Parse reads the `wal` section out of a custom datastore's options. An absent section is passthrough with no error, and naming the section is what turns intercept mode on.

An unknown key inside the section is an error, because a misspelt `snyc` would otherwise be a server silently running the other mode. An unknown key outside the section stays the cold store plugin's business, except one spelt like the section itself ([checkSectionMiscased]).

type Layer

type Layer struct {
	// contains filtered or unexported fields
}

Layer is the WAL layer this process owns: the registry the wrapper talks to, over the backends it was composed with.

Exactly one per process, not one per data store factory: a shard's cycle carries its epoch from the acquire through the writes that follow, so two registries would be two windows for one shard, each unaware of the other.

func Compose

func Compose(
	backends Backends,
	policy cycle.Policy,
	categories Registry,
	logger log.Logger,
	handler metrics.Handler,
) (*Layer, error)

Compose is the one graph every process running intercept mode builds. Its five varying inputs:

  • backends is where the bytes go, and the reason it is a parameter is on Backends;
  • policy is required, and is a cycle.Policy: a source read at each decision, so a caller holding one that moves (NewPolicy) and one holding fixed numbers (cycle.Fixed) reach the same composition;
  • categories is required: it is what replay decodes a tail with, and cycle.NewManager refuses nil rather than starting a node whose recovery is silently off;
  • logger is optional, and nil is a noop: the layer's own warnings — a shutdown drain that did not commit, a trim retried at the next cadence — then have nowhere to go;
  • handler is optional, and nil is the production value: the server hands one down through wrapper.MetricsSink after this runs.

It opens nothing, reaches nothing and takes no context: everything that talks to a cluster happens while the backends are built. So whatever they hold stays the caller's, and must outlive the layer, since Layer.Shutdown drains through it.

func (*Layer) AbstractFactory

AbstractFactory is AbstractFactory carrying this layer's own options, and the reason it is a method: the pairing of a composition with the factory that carries it was written out at every call site, and a layer composed but never handed to one is a node running passthrough with a `wal` section that says otherwise — which nothing reports, since that is what an empty layer looks like from outside. So the whole of building a server over this library is

temporal.WithCustomDataStoreFactory(layer.AbstractFactory(base))

where base is the plugin whose stores hold the cold data.

func (*Layer) Options

func (l *Layer) Options() wrapper.Options

Options is the whole of what the wrapper is composed with: the registry is the shard observer, the write path and the read path at once, and the emitter is the one this layer's cycles record through, so a store built over these options and the cycle behind it report to the same handler — the one wrapper.MetricsSink hands over.

func (*Layer) Policy

func (l *Layer) Policy() cycle.Policy

Policy is what this layer's cycles read: the section's half and the dynamic config's, as one source. Calling it answers the policy now, which for the live settings need not be what it answered a minute ago.

func (*Layer) RetireShard

func (l *Layer) RetireShard(shard wal.ShardID, epoch wal.Epoch) bool

RetireShard stops one shard's cycle without draining it, and reports whether the epoch named is the one this node still holds. It is what a process that died leaves behind, which is why it is named apart from Layer.Shutdown: a drain writes, and a kill does not.

epoch is which acquisition is being retired, and a mismatch retires nothing. Without it a late unload — a shard context cleaned up after the shard was reacquired above it — stops the owner that superseded it, since the caller has no other way to say which of the two it means. It is the check cycle.Manager.Write makes for the same reason, in the one other door that names an epoch.

The stopped cycle stays the shard's, and that is not an omission: its tail is acked entries still in the log, so Layer.ShardStats and Layer.Totals go on answering for it off the mirror. Removing it here would answer a shard nobody holds, which is the zero a caller reads as "nothing stranded".

func (*Layer) ShardStats

func (l *Layer) ShardStats(shard wal.ShardID) (cycle.Stats, bool)

ShardStats is what one shard's cycle knows about itself, and false if this node holds none. These and not the registry: the registry is also a write path, and wrapper.Options is the one that has an epoch check in front of it.

A value and not the cycle: callers may observe its counters and epoch, but may not gain its Retire and Close controls.

func (*Layer) Shutdown

func (l *Layer) Shutdown(ctx context.Context, budget time.Duration) error

Shutdown stops the layer: every shard that still holds a window is drained into the cold store, and the log is released.

It must run after the server has stopped: the drain writes to the cold store the mutations of writers the server is shutting down. budget bounds the apply transactions — one per shard, in sequence — and not a trim already in flight, which is waited out on the minute of its own detached context; a drain the budget cuts short leaves a tail, not lost data (invariant I2: it is in the log, acked), which the next owner's replay picks up.

The budget is put on a context detached from the caller's cancellation, and that detach is why this is a door rather than an idiom every caller writes. A shutdown drain runs where a context has just been cancelled — that is what "shutdown" means — and one inheriting that cancellation returns at once, leaving a tail behind and nothing in the log that says so. Given a budget, the error is an *UndrainedError and nothing else: every tail emptied, or these did not. A budget that is not one is refused instead of obeyed: context.WithTimeout reads zero as a deadline already past, where much of Go reads it as no limit, so obeying it would drain nothing and report every shard as holding a tail — the answer a caller passing zero meant least.

func (*Layer) Totals

func (l *Layer) Totals() cycle.Totals

Totals is what every shard this layer has held reports, summed — the number a witness reads to say the layer was not empty.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is the answer to "which registry does this node decode a tail with", and it is constructible only by TaskCategories and DefaultTaskCategories. Upstream's interface is what cycle.Deps takes and must be — mutation.Decode needs it — but a composition that accepted it directly would accept tasks.NewDefaultTaskCategoryRegistry too, which is a second answer: the same set today, and a different one the day archival is configured.

The zero value is refused by cycle.NewManager rather than treated as the default, since a node that decodes with no registry recovers nothing until the first failover.

func DefaultTaskCategories

func DefaultTaskCategories() Registry

DefaultTaskCategories is TaskCategories for a cluster that configures no archival. It answers the same set as tasks.NewDefaultTaskCategoryRegistry and is not that call: a registry a node decodes with is built the way a node builds one, so a caller reaching for the plain default is a second answer to which registry a node has.

func TaskCategories

func TaskCategories(dc *dynamicconfig.Collection, cfg *config.Config) Registry

TaskCategories builds the task category registry the layer decodes an inherited tail with, here rather than from fx because the layer is composed first. It depends on cfg: the archival category exists exactly when archival is enabled, and a default registry would refuse to replay entries carrying archival tasks.

func (Registry) Categories

func (reg Registry) Categories() tasks.TaskCategoryRegistry

Categories is the registry as the layer's own components take it.

type UndrainedError added in v0.3.0

type UndrainedError struct {
	// Shards is every shard whose tail outlived the shutdown.
	Shards []cycle.Residue
}

UndrainedError is what Layer.Shutdown answers when a tail outlived it: the shards still holding acked entries no drain applied, and how many each holds.

It reports neither a lost write nor a failed shutdown. Those entries are in the log and a successor's replay is what they are there for, so a node restarting into the same configuration needs nothing from this value. One caller does: whoever is taking the layer *out*. Removing the `wal` section strands exactly these entries and says nothing, because passthrough composes no log and so cannot see that they exist — which makes a shutdown that answered nil the only evidence that removing it is safe.

func (*UndrainedError) Error added in v0.3.0

func (e *UndrainedError) Error() string

type WAL

type WAL struct {
	// Sync drains inside every write and reports the drain's outcome to the
	// caller. Off by default, so the shipped configuration is the windowed one
	// and a hard restart leaves a tail for the next owner to replay.
	Sync bool `mapstructure:"sync"`

	// DrainOnRead answers every read from the cold store, draining the window
	// first — see [cycle.Config.DrainOnRead] for what it costs.
	DrainOnRead bool `mapstructure:"drain_on_read"`
}

WAL is the `wal` section verbatim. The yaml key names are a promise to whoever wrote the file, so they are this package's struct and not cycle.Config with tags on it.

The section is what the layer is; [settings] is how it behaves. A misspelt key here is a refusal to start; a misspelt dynamic-config key is a warning and the default standing silently.

func (WAL) StaticConfig

func (w WAL) StaticConfig() cycle.Config

StaticConfig is cycle.Defaults with each [knobs] row the section set overlaid; an empty section is exactly cycle.Defaults.

It is only the static half of what a node runs — NewPolicy is the whole — so every field [settings] carries is at its default here. Assert a section against this; read a live watermark off NewPolicy.

Directories

Path Synopsis
Package apply is what a drain's outcome means: the class the cycle branches on, the errors that carry it, and the readback that names which row diverged.
Package apply is what a drain's outcome means: the class the cycle branches on, the errors that carry it, and the readback that names which row diverged.
Package baserow is the cold store's two mutable-state reads, as the write path needs them: one run's row, and a workflow's current-execution row with the last_write_version beside it.
Package baserow is the cold store's two mutable-state reads, as the write path needs them: one run's row, and a workflow's current-execution row with the last_write_version beside it.
Package cold is the cold store contract: the seam a drain lands on, as wal is the seam an ack lands on.
Package cold is the cold store contract: the seam a drain lands on, as wal is the seam an ack lands on.
memcold
Package memcold is the one cold store this repository ships: Temporal's own SQL persistence, running over a SQLite database that lives in this process and dies with it.
Package memcold is the one cold store this repository ships: Temporal's own SQL persistence, running over a SQLite database that lives in this process and dies with it.
Package cycle is the layer's state machine: one goroutine per (shard, epoch) owns the accumulator, the drain, the apply transaction, the trim cadence and the four reads, so that "who is touching this shard" has one answer.
Package cycle is the layer's state machine: one goroutine per (shard, epoch) owns the accumulator, the drain, the apply transaction, the trim cadence and the four reads, so that "who is touching this shard" has one answer.
tailstate
Package tailstate is the tail's arithmetic, in one place: everything I10 bounds — unsettled acked entries, the bytes they hold, how far behind the cold store's watermark is — is counted here and nowhere else.
Package tailstate is the tail's arithmetic, in one place: everything I10 bounds — unsettled acked entries, the bytes they hold, how far behind the cold store's watermark is — is counted here and nowhere else.
trim
Package trim is the lazy deletion of WAL entries the cold store already holds: the cadence that decides when, the one trim in flight, and the two counters that tell a cadence that fired from a trim that reached the log.
Package trim is the lazy deletion of WAL entries the cold store already holds: the cadence that decides when, the one trim in flight, and the two counters that tell a cadence that fired from a trim that reached the log.
window
Package window is the size and age of what a cycle has folded since its last drain.
Package window is the size and age of what a cycle has folded since its last drain.
Package fold is the tail's compaction: it accumulates the mutations of a window and emits, per dirty workflow, one merged request plus the assertions that request stands on.
Package fold is the tail's compaction: it accumulates the mutations of a window and emits, per dirty workflow, one merged request plus the assertions that request stands on.
internal
verify/acceptance
Package acceptance is the volume half of the evidence: a generated stream long enough that what the layer did to it is a measurement rather than an anecdote, driven two ways.
Package acceptance is the volume half of the evidence: a generated stream long enough that what the layer did to it is a measurement rather than an anecdote, driven two ways.
verify/basetest
Package basetest is the pre-window rows in memory.
Package basetest is the pre-window rows in memory.
verify/checker
Package checker is what a driver writes down about the calls it made, and the vocabulary for reading that record back.
Package checker is what a driver writes down about the calls it made, and the vocabulary for reading that record back.
verify/coldtasks
Package coldtasks is a model of the store below, for the merged task read's tests.
Package coldtasks is a model of the store below, for the merged task read's tests.
verify/coldtest
Package coldtest is the cold store a drain lands in, in memory.
Package coldtest is the cold store a drain lands in, in memory.
verify/drive
Package drive is the half of a run that *writes*: one mutation into one ExecutionStore call, a shard taken so those calls have an epoch to carry, and Stream — a generated stream behind the codec, for the callers that want what came back out of a log.
Package drive is the half of a run that *writes*: one mutation into one ExecutionStore call, a shard taken so those calls have an epoch to carry, and Stream — a generated stream behind the codec, for the callers that want what came back out of a log.
verify/e2e
Package e2e is the whole of waltz under a running Temporal server: the four services in this process, over the cold store this repository ships, with a real workflow driven through the frontend by the SDK.
Package e2e is the whole of waltz under a running Temporal server: the four services in this process, over the cold store this repository ships, with a real workflow driven through the frontend by the SDK.
verify/foldrun
Package foldrun drives mutations through one accumulator the way a cycle does — window by window, with fold's refusal recovery — and counts what the folding did.
Package foldrun drives mutations through one accumulator the way a cycle does — window by window, with fold's refusal recovery — and counts what the folding did.
verify/guard
Package guard holds the tests whose job is to fail when a decision is reverted, rather than when the code is wrong.
Package guard holds the tests whose job is to fail when a decision is reverted, rather than when the code is wrong.
verify/mutbuild
Package mutbuild builds one mutation of a given shape, well-formed, with no cluster: the request Temporal's ExecutionManager hands the store, as a unit test needs it — one at a time, with the ids named rather than drawn.
Package mutbuild builds one mutation of a given shape, well-formed, with no cluster: the request Temporal's ExecutionManager hands the store, as a unit test needs it — one at a time, with the ids named rather than drawn.
verify/mutgen
Package mutgen generates valid, deterministic mutation streams.
Package mutgen generates valid, deterministic mutation streams.
verify/witness
Package witness is the WAL layer's witness as one judged module: the claims a run over the layer must be able to make about the layer's own counters, stated once and checked the same way by every run that composes one.
Package witness is the WAL layer's witness as one judged module: the claims a run over the layer must be able to make about the layer's own counters, stated once and checked the same way by every run that composes one.
Package mutation is the WAL's record format: it turns one ExecutionStore write request into the opaque bytes a [wal.Entry] carries, and back.
Package mutation is the WAL's record format: it turns one ExecutionStore write request into the opaque bytes a [wal.Entry] carries, and back.
wal
Package wal is the write-ahead log contract the layer in front of a Temporal history shard's cold store is built on.
Package wal is the write-ahead log contract the layer in front of a Temporal history shard's cold store is built on.
memwal
Package memwal is the wal contract ([ADR 0002]) in process memory, and the only log shipped here.
Package memwal is the wal contract ([ADR 0002]) in process memory, and the only log shipped here.
waltest
Package waltest is the conformance suite for the wal contract, and beside it Faulty: a backend that keeps the contract, wrapped so that a chosen call fails.
Package waltest is the conformance suite for the wal contract, and beside it Faulty: a backend that keeps the contract, wrapped so that a chosen call fails.
Package walmetrics emits the WAL layer's numbers through the server's own metrics.Handler, which arrives after the layer is built; Emitter.Use is that handover, late because the handler is taken by the first service to build persistence and the layer is composed before the server is.
Package walmetrics emits the WAL layer's numbers through the server's own metrics.Handler, which arrives after the layer is built; Emitter.Use is that handover, late because the handler is taken by the first service to build persistence and the layer is composed before the server is.
Package wrapper is the WAL layer's seam into a running temporal-server: a decorator over the base plugin's DataStoreFactory whose ExecutionStore and ShardStore the history service talks to in place of the plugin's own.
Package wrapper is the WAL layer's seam into a running temporal-server: a decorator over the base plugin's DataStoreFactory whose ExecutionStore and ShardStore the history service talks to in place of the plugin's own.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址