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}