Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

pausable.gno

7.34 Kb · 232 lines
  1// Package pausable is the switch a realm checks before it acts: a parsed pause
  2// state, the rule for combining two of them, and the asserts that stop a call.
  3//
  4// # Three levels, because "paused" is usually too blunt
  5//
  6// Taking a realm fully offline hides the thing people came to read. Most
  7// incidents only need the writes stopped: a board keeps rendering, an exchange
  8// keeps quoting, and nothing new lands while the fix is prepared. So the
  9// levels are Running, ReadOnly and Paused, and a realm normally guards its
 10// mutating functions with AssertWritable and leaves Render alone.
 11//
 12//	func Post(cur realm, body string) {
 13//		config.AssertWritable()  // aborts while the realm is ReadOnly or Paused
 14//		…
 15//	}
 16//
 17//	func Render(path string) string {
 18//		return config.TopBlock() + body  // the banner explains itself
 19//	}
 20//
 21// # It fails closed
 22//
 23// MustParse turns a value it does not recognise into Paused, not Running. A
 24// pause switch that a typo silently disables is not a pause switch, and the
 25// failure is loud and one transaction from fixed, where the opposite failure
 26// is silent and discovered during the incident it was meant to cover.
 27//
 28// The cost of that choice is a typo taking a realm offline, so the writer is
 29// expected to validate: Parse reports ok=false and the storing realm refuses
 30// the write. Validate on the way in, fail closed on the way out.
 31//
 32// # The stricter of two states wins
 33//
 34// A global pause and a per-realm pause combine with Strictest, so a stale
 35// per-realm entry can never re-open a realm during a global halt. The cost is
 36// that exempting one realm from a global pause is not expressible; clear the
 37// global and set the others instead.
 38package pausable
 39
 40import "strings"
 41
 42// Level is how much of a realm is still allowed to work.
 43type Level int
 44
 45const (
 46	// Running is the normal state, and what an unset value means.
 47	Running Level = iota
 48	// ReadOnly still renders and still answers queries; it refuses writes.
 49	ReadOnly
 50	// Paused refuses everything, reads included.
 51	Paused
 52)
 53
 54// The canonical spelling of each level, which is what a value round-trips to.
 55const (
 56	runningWord  = "running"
 57	readOnlyWord = "readonly"
 58	pausedWord   = "paused"
 59)
 60
 61// reasonSep separates the level from the human explanation in a stored value:
 62// "paused: migrating storage, back in an hour".
 63const reasonSep = ":"
 64
 65func (l Level) String() string {
 66	switch l {
 67	case Running:
 68		return runningWord
 69	case ReadOnly:
 70		return readOnlyWord
 71	case Paused:
 72		return pausedWord
 73	default:
 74		// Not reachable through Parse, and not worth panicking over in a
 75		// Render path: an unknown level reads as the strictest one.
 76		return pausedWord
 77	}
 78}
 79
 80// AllowsRead reports whether rendering and querying are still allowed.
 81func (l Level) AllowsRead() bool { return l != Paused }
 82
 83// AllowsWrite reports whether state-changing calls are still allowed.
 84func (l Level) AllowsWrite() bool { return l == Running }
 85
 86// State is a parsed pause setting: a level and an optional reason to show.
 87//
 88// The zero State is Running with no reason, which is what an unset setting
 89// means, so a realm that never configures anything is never paused.
 90type State struct {
 91	Level  Level
 92	Reason string
 93}
 94
 95// Parse reads a stored value: "", "running", "readonly", "paused", each
 96// optionally followed by ": <reason>".
 97//
 98// ok is false for anything else. A caller that is STORING the value should
 99// refuse on !ok; a caller that is READING one should use MustParse, which
100// fails closed instead.
101func Parse(raw string) (State, bool) {
102	raw = strings.TrimSpace(raw)
103	if raw == "" {
104		return State{}, true
105	}
106
107	word, reason := raw, ""
108	if i := strings.Index(raw, reasonSep); i >= 0 {
109		word = strings.TrimSpace(raw[:i])
110		reason = strings.TrimSpace(raw[i+1:])
111	}
112
113	switch strings.ToLower(word) {
114	case runningWord:
115		return State{Level: Running, Reason: reason}, true
116	case readOnlyWord:
117		return State{Level: ReadOnly, Reason: reason}, true
118	case pausedWord:
119		return State{Level: Paused, Reason: reason}, true
120	default:
121		return State{}, false
122	}
123}
124
125// MustParse is Parse for a reader, failing closed: a value it cannot read
126// becomes Paused, carrying the raw text as the reason so whoever hits it can
127// see what is wrong.
128func MustParse(raw string) State {
129	if s, ok := Parse(raw); ok {
130		return s
131	}
132	return State{Level: Paused, Reason: "unreadable pause setting: " + strings.TrimSpace(raw)}
133}
134
135// String renders a State back into a storable value. It round-trips through
136// Parse, and the zero State renders empty so an unset setting stays unset.
137func (s State) String() string {
138	if s.Level == Running && s.Reason == "" {
139		return ""
140	}
141	if s.Reason == "" {
142		return s.Level.String()
143	}
144	return s.Level.String() + reasonSep + " " + s.Reason
145}
146
147// Strictest returns whichever of the two stops more, keeping the reason that
148// belongs to the level it returns. On a tie the first argument wins, so a
149// caller passing (global, scoped) keeps the global explanation when both say
150// the same thing, and the scoped one when it is the stricter.
151func Strictest(a, b State) State {
152	if b.Level > a.Level {
153		return b
154	}
155	return a
156}
157
158// AllowsRead reports whether rendering and querying are still allowed.
159func (s State) AllowsRead() bool { return s.Level.AllowsRead() }
160
161// AllowsWrite reports whether state-changing calls are still allowed.
162func (s State) AllowsWrite() bool { return s.Level.AllowsWrite() }
163
164// IsPaused reports whether anything at all is being held back. It is true for
165// ReadOnly as well as Paused, because the question a caller usually means by
166// "is it paused" is "is it behaving normally".
167func (s State) IsPaused() bool { return s.Level != Running }
168
169// AssertWritable aborts unless writes are allowed. This is the one a realm
170// puts at the top of every state-changing function.
171func (s State) AssertWritable() {
172	if !s.AllowsWrite() {
173		panic(s.message("writes are paused"))
174	}
175}
176
177// AssertReadable aborts unless reads are allowed. Most realms do not want this
178// in Render: a page that aborts tells a reader nothing, while Notice tells
179// them what happened and when to come back.
180func (s State) AssertReadable() {
181	if !s.AllowsRead() {
182		panic(s.message("paused"))
183	}
184}
185
186func (s State) message(what string) string {
187	if s.Reason == "" {
188		return what
189	}
190	return what + reasonSep + " " + s.Reason
191}
192
193// Notice is the banner a Render puts above its content, or "" while running.
194//
195// It is a markdown blockquote, so it reads as set apart from the page without
196// needing any style the renderer might not have.
197func (s State) Notice() string {
198	if s.Level == Running {
199		return ""
200	}
201	head := "**Paused.** This realm is not accepting anything right now."
202	if s.Level == ReadOnly {
203		head = "**Read-only.** This realm is still readable, but not accepting changes."
204	}
205	if s.Reason == "" {
206		return "> " + head
207	}
208	// The reason is text a manager typed, and it lands inside a blockquote
209	// where a newline would end the quote and let the rest render as page
210	// content. Folding it to a single line keeps the banner one block.
211	return "> " + head + " " + oneLine(s.Reason)
212}
213
214// oneLine collapses every run of whitespace, newlines included, into single
215// spaces.
216func oneLine(s string) string {
217	var b strings.Builder
218	space := false
219	for i := 0; i < len(s); i++ {
220		c := s[i]
221		if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
222			space = true
223			continue
224		}
225		if space && b.Len() > 0 {
226			b.WriteByte(' ')
227		}
228		space = false
229		b.WriteByte(c)
230	}
231	return b.String()
232}