Project: Self-Describing State Machines
A feature flag is a small state machine: it is stable, its guardrail metrics are checked before a rollout, it rolls out a new percentage for one step, and it refuses every rollout for a while after too many failed checks. The Vane draft describes that machine in prose. This project writes it as a machine that a program can run, and then makes the machine describe itself: what each event carries, what type each piece of data has, what each state means in words, and which requirement of the draft each part implements. With those annotations, the tools of the earlier chapters can check the machine against its schemas and against the draft, write its documentation, and draw it.
The machine is written in SCXML, the W3C's State Chart XML. Its files
are in the scxml directory:
flag.scxml, the machine;scxml.xsd, a schema for the part of SCXML the machine uses, andvane-data.xsd, a schema for its annotations, both read in Validating with XSD;vane.xml, a link to the draft in thespecdirectory.
SCXML in one page
An SCXML document is a scxml element in the namespace
http://www.w3.org/2005/07/scxml. Its children are:
stateelements, each with anid. A machine is in one of its states at a time (states can also nest, and run in parallel, which this machine doesn't need); theinitialattribute ofscxmlnames the first.- In each state,
transitionelements. A transition waits for an event, named inevent; when the event comes and the condition incondholds, the machine leaves the state for the one named intarget. A transition without a target handles the event and stays. - Executable content, run on a transition and when a state is
entered (
onentry) or left (onexit):assignchanges data,logwrites a message,raisemakes an event inside the machine, andsendsends one, to the machine itself after a delay or to the world outside. - A
datamodelofdataitems, the machine's variables. Thedatamodelattribute says which language the expressions incond,exprandlocationare written in; here ECMAScript.
An SCXML interpreter runs such a document. sxml doesn't run machines; it reads, checks and transforms them, which is what this project needs.
The flag
flag.scxml:
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml"
xmlns:vane="urn:example:vane"
version="1.0" name="feature_flag" initial="stable" datamodel="ecmascript">
<vane:events>
<vane:event name="rollout.request" vane:payload="vane:rollout">
<p xmlns="http://www.w3.org/1999/xhtml">An owner asks for a new percentage.</p>
</vane:event>
<vane:event name="check.passed">
<p xmlns="http://www.w3.org/1999/xhtml">The guardrail metrics are within their limits.</p>
</vane:event>
<vane:event name="check.failed">
<p xmlns="http://www.w3.org/1999/xhtml">A guardrail metric is over its limit.</p>
</vane:event>
<vane:event name="kill.request">
<p xmlns="http://www.w3.org/1999/xhtml">An owner pulls the kill switch.</p>
</vane:event>
<vane:event name="status.request">
<p xmlns="http://www.w3.org/1999/xhtml">An SDK asks for the state.</p>
</vane:event>
<vane:event name="status.response" vane:payload="vane:status">
<p xmlns="http://www.w3.org/1999/xhtml">The service answers with the state.</p>
</vane:event>
<vane:event name="step.done">
<p xmlns="http://www.w3.org/1999/xhtml">Sent by the machine to itself when a rollout step has run.</p>
</vane:event>
<vane:event name="pause.expired">
<p xmlns="http://www.w3.org/1999/xhtml">Sent by the machine to itself when a pause ends.</p>
</vane:event>
<vane:event name="flag.archived">
<p xmlns="http://www.w3.org/1999/xhtml">An owner archived the flag.</p>
</vane:event>
</vane:events>
<datamodel>
<data id="failures" expr="0" vane:type="vane:failure-count"/>
<data id="maxFailures" expr="3" vane:type="vane:failure-limit"/>
<data id="stepSeconds" expr="600" vane:type="vane:seconds"/>
<data id="pauseSeconds" expr="300" vane:type="vane:seconds"/>
</datamodel>
<state id="stable" vane:implements="req-2">
<div xmlns="http://www.w3.org/1999/xhtml">
<p>The flag keeps its percentage. It waits for a rollout.</p>
</div>
<transition event="rollout.request" target="checking"/>
<transition event="status.request" vane:implements="req-6">
<send event="status.response"/>
</transition>
</state>
<state id="checking" vane:implements="req-4">
<div xmlns="http://www.w3.org/1999/xhtml">
<p>The service checks the guardrail metrics before it applies the new percentage.</p>
</div>
<transition event="check.passed" target="rolling-out">
<assign location="failures" expr="0"/>
</transition>
<transition event="check.failed" cond="failures + 1 < maxFailures" target="stable">
<assign location="failures" expr="failures + 1"/>
</transition>
<transition event="check.failed" target="paused" vane:implements="req-7"/>
</state>
<state id="rolling-out" vane:implements="req-3">
<div xmlns="http://www.w3.org/1999/xhtml">
<p>The flag serves the new percentage. The step ends after
<var>stepSeconds</var> seconds, or when an owner kills it.</p>
</div>
<onentry>
<send event="step.done" delayexpr="stepSeconds + 's'"/>
</onentry>
<transition event="kill.request step.done" target="stable"/>
<transition event="flag.evaluated">
<log label="flag" expr="'evaluated'"/>
</transition>
</state>
<state id="paused" vane:implements="req-5 req-8">
<div xmlns="http://www.w3.org/1999/xhtml">
<p>The service refuses every rollout for <var>pauseSeconds</var> seconds.</p>
</div>
<onentry>
<send event="pause.expired" delayexpr="pauseSeconds + 's'"/>
</onentry>
<transition event="pause.expired" target="stable">
<assign location="failures" expr="0"/>
</transition>
</state>
<state id="archived">
<div xmlns="http://www.w3.org/1999/xhtml">
<p>The flag is archived. No SDK asks for it any more.</p>
</div>
<transition event="kill.request" target="stable"/>
</state>
</scxml>
Read the states from the top. stable waits for a rollout request,
and answers a status request without leaving. checking decides: a
passed check starts the rollout; a failed check returns to stable
while there are failures left, and enters paused when there are
none. Both transitions for check.failed wait for the same event, and
the first whose condition holds is taken. rolling-out sends itself a
step.done when it is entered, and returns to stable on that or on
a kill request. paused sends itself pause.expired and then goes
back to stable. archived is for a flag that is no longer used.
A query lists the transitions as a table:
$ sxml xquery --method=adaptive --context-item=flag.scxml --expression='
declare default element namespace "http://www.w3.org/2005/07/scxml";
for $transition in //transition
return `{$transition/../@id} --{$transition/@event}--> {($transition/@target, "(stays)")[1]}`'
"stable --rollout.request--> checking"
"stable --status.request--> (stays)"
"checking --check.passed--> rolling-out"
"checking --check.failed--> stable"
"checking --check.failed--> paused"
"rolling-out --kill.request step.done--> stable"
"rolling-out --flag.evaluated--> (stays)"
"paused --pause.expired--> stable"
"archived --kill.request--> stable"
The annotations
Everything in the namespace urn:example:vane is an annotation. An
SCXML interpreter ignores elements and attributes of other namespaces,
so they don't change how the machine runs; they describe it:
vane:eventsdeclares every event the machine knows, with an XHTML paragraph that says what it means, and, for events that carry data,vane:payload, the name of an element ofvane-data.xsdthat describes the data. A rollout request carriesvane:rollout, a flag key and a percentage; a status response carriesvane:status.vane:typeon eachdataitem names a simple type ofvane-data.xsd:maxFailuresis avane:failure-limit, a positive integer up to 10.vane:implementson states and transitions lists the anchors of the draft's requirements that they carry out. The paused state implementsreq-5, a paused flag isn't rolled out, andreq-8, the pause lasts five minutes.- An XHTML
divin each state says in words what the state is.
The events with their payloads and descriptions:
$ sxml xquery --method=adaptive --context-item=flag.scxml --expression='
declare namespace vane = "urn:example:vane";
//vane:event ! `{@name}{@vane:payload ! (" carries " || .)}: {normalize-space()}`'
"rollout.request carries vane:rollout: An owner asks for a new percentage."
"check.passed: The guardrail metrics are within their limits."
"check.failed: A guardrail metric is over its limit."
"kill.request: An owner pulls the kill switch."
"status.request: An SDK asks for the state."
"status.response carries vane:status: The service answers with the state."
"step.done: Sent by the machine to itself when a rollout step has run."
"pause.expired: Sent by the machine to itself when a pause ends."
"flag.archived: An owner archived the flag."
And the requirements the machine names, with their key words, looked up in the draft:
$ sxml xquery --method=adaptive --context-item=flag.scxml --expression='
declare namespace vane = "urn:example:vane";
for $anchor in distinct-values(//@vane:implements ! tokenize(.))
let $requirement := doc("vane.xml")//t[@anchor = $anchor]
return `{$anchor}: {$requirement/bcp14}`'
"req-2: MUST"
"req-6: MUST"
"req-4: MUST"
"req-7: MUST"
"req-3: SHOULD"
"req-5: MUST NOT"
"req-8: SHOULD"
The numbers of the machine are the draft's numbers: three failed
checks (req-7), ten minutes, 600 seconds, for a rollout step
(req-3), and five minutes, 300 seconds, of pause (req-8).
Validating it
scxml.xsd imports vane-data.xsd, so one schema checks both the
machine and its annotations:
$ sxml validate --schema=scxml.xsd flag.scxml; echo "exit status $?"
exit status 0
The payload elements have declarations of their own, so the data a
machine sends or receives can be validated with vane-data.xsd alone:
$ echo '<rollout xmlns="urn:example:vane"><flag>new-checkout</flag><percentage>1000</percentage></rollout>' \
| sxml validate --schema=vane-data.xsd -
-:1:60: cvc-pattern-valid: element 'percentage' has the value '1000', which is not valid for type 'percentage': the value matches no pattern facet (XSD 1.1 Part 2, 4.3.4.4); the pattern facet is '[0-9]{1,3}'
The percentage has four digits, and a percentage of the protocol is
one to three digits, as the draft's ABNF says. The pattern says no
more than the ABNF does, so it lets 999 through, although the draft's
terminology says a percentage is at most 100; a maxInclusive facet
on an integer type would say that, and the ABNF could not.
The machine is valid, and yet it has four flaws that no schema finds: a state that nothing leads to, an event that is handled but never declared, an event that is declared but never used, and a requirement of the draft that it doesn't implement. The rest of the project:
- Checking What the Schema Cannot finds them with a schema-aware query, and fixes them with an update;
- Documenting the Machine in XHTML writes a page about the machine from its annotations and its schemas;
- Drawing the Machine draws it as a diagram.