hydo-assert 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hydo_assert-0.3.0/PKG-INFO +295 -0
- hydo_assert-0.3.0/README.md +273 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/pyproject.toml +1 -1
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert/__init__.py +1 -1
- hydo_assert-0.3.0/src/hydo_assert.egg-info/PKG-INFO +295 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert.egg-info/SOURCES.txt +2 -1
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/tests/test_cases.py +2 -2
- hydo_assert-0.3.0/tests/test_readme.py +82 -0
- hydo_assert-0.2.0/PKG-INFO +0 -238
- hydo_assert-0.2.0/README.md +0 -216
- hydo_assert-0.2.0/src/hydo_assert.egg-info/PKG-INFO +0 -238
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/LICENSE +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/setup.cfg +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert/_buggify.py +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert/_core.py +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert/_eventually.py +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert/_json.py +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert/_sink.py +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert/history.py +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert/py.typed +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert.egg-info/dependency_links.txt +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/src/hydo_assert.egg-info/top_level.txt +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/tests/test_history.py +0 -0
- {hydo_assert-0.2.0 → hydo_assert-0.3.0}/tests/test_hydo_assert.py +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hydo-assert
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Assertion SDK for programs tested under hydo, a deterministic hypervisor (always/sometimes properties, BUGGIFY fault points, bounded liveness, operation histories)
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/kakoc/hydo
|
|
7
|
+
Project-URL: Documentation, https://github.com/kakoc/hydo/tree/main/sdk/python#readme
|
|
8
|
+
Project-URL: Repository, https://github.com/kakoc/hydo
|
|
9
|
+
Project-URL: Issues, https://github.com/kakoc/hydo/issues
|
|
10
|
+
Keywords: testing,assertions,deterministic,fault-injection,simulation,hydo
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# hydo-assert for Python
|
|
24
|
+
|
|
25
|
+
[hydo](https://github.com/kakoc/hydo) runs a whole system in a deterministic virtual machine,
|
|
26
|
+
injects faults (process kills, network faults, power cuts, disk errors) and replays every
|
|
27
|
+
failure bit for bit. This package lets a Python program that runs under hydo say, from the
|
|
28
|
+
inside, what must be true and what should happen. hydo collects what the program reports, and
|
|
29
|
+
an exploration (many runs with different faults) reports each property as passed, failed (with
|
|
30
|
+
a schedule that replays the failure) or never exercised.
|
|
31
|
+
|
|
32
|
+
The package uses only the standard library and needs Python 3.8 or later. Outside hydo it does
|
|
33
|
+
almost nothing: the records go to standard error (or nowhere), and fault points stay off.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install hydo-assert
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The distribution is `hydo-assert`; the module is `hydo_assert`. In a guest image without pip,
|
|
42
|
+
copy the folder `hydo_assert` onto the program's `PYTHONPATH`.
|
|
43
|
+
|
|
44
|
+
## A complete program
|
|
45
|
+
|
|
46
|
+
A small key/value store that declares three properties and checks them:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import hydo_assert as ha
|
|
50
|
+
|
|
51
|
+
READ_BACK = ha.always("kv.read_back", "every acknowledged write reads back with its value")
|
|
52
|
+
OVERWRITE = ha.sometimes("kv.overwrite", "a write replaced an existing value")
|
|
53
|
+
LOST_REPLY = ha.buggify("kv.lost_reply", "the write lands but its reply is lost")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
ha.catalog() # list every property, also the ones that never run
|
|
58
|
+
store = {}
|
|
59
|
+
ha.setup_complete() # the system is ready: hydo may start injecting faults
|
|
60
|
+
|
|
61
|
+
for key, value in [("a", 1), ("b", 2), ("a", 3)]:
|
|
62
|
+
old = store.get(key)
|
|
63
|
+
store[key] = value
|
|
64
|
+
OVERWRITE.check(old is not None, {"key": key})
|
|
65
|
+
if LOST_REPLY.fire({"key": key}):
|
|
66
|
+
continue # the client never hears back (only when hydo turns this point on)
|
|
67
|
+
acked = store.get(key) == value
|
|
68
|
+
READ_BACK.check(acked, {"key": key, "value": value})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run it with `HYDO_ASSERT_OUT=stderr` to see what it reports. It writes these lines (under hydo
|
|
76
|
+
they go to the guest's console, where hydo reads them):
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
HYDO-ASSERT-DECL {"t":"always","id":"kv.read_back","msg":"every acknowledged write reads back with its value"}
|
|
80
|
+
HYDO-ASSERT-DECL {"t":"sometimes","id":"kv.overwrite","msg":"a write replaced an existing value"}
|
|
81
|
+
HYDO-ASSERT-DECL {"t":"sometimes","id":"buggify.kv.lost_reply","msg":"fault point fired: the write lands but its reply is lost"}
|
|
82
|
+
HYDO-SETUP-COMPLETE
|
|
83
|
+
HYDO-ASSERT {"t":"always","id":"kv.read_back","ok":true,"msg":"every acknowledged write reads back with its value","details":{"key":"a","value":1}}
|
|
84
|
+
HYDO-ASSERT {"t":"sometimes","id":"kv.overwrite","ok":true,"msg":"a write replaced an existing value","details":{"key":"a"}}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A process writes each outcome of a property once: the first pass and the first failure, with
|
|
88
|
+
the details of that evaluation. That is why the later passes of `kv.read_back` write nothing.
|
|
89
|
+
|
|
90
|
+
## How to
|
|
91
|
+
|
|
92
|
+
### Declare properties and check them
|
|
93
|
+
|
|
94
|
+
A property has an id (stable across versions of your program) and a message for people. Its
|
|
95
|
+
kind says what hydo expects:
|
|
96
|
+
|
|
97
|
+
| kind | the property holds when | hydo reports a failure when |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `always` | the condition is true every time, and it is checked at least once | it is false once, or never checked |
|
|
100
|
+
| `always_or_unreachable` | the condition is true every time it is checked | it is false once |
|
|
101
|
+
| `sometimes` | the condition is true at least once in an exploration | it is never true |
|
|
102
|
+
| `reachable` | the code is reached at least once in an exploration | it is never reached |
|
|
103
|
+
| `unreachable` | the code is never reached | it is reached |
|
|
104
|
+
|
|
105
|
+
Declare properties at module level, so that the catalog lists them once the module is
|
|
106
|
+
imported:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import hydo_assert as ha
|
|
110
|
+
|
|
111
|
+
STORED = ha.always("kv.stored", "every acknowledged write is stored")
|
|
112
|
+
PARSES = ha.always_or_unreachable("kv.log_parses", "every complete log line parses")
|
|
113
|
+
RETRIED = ha.sometimes("kv.retried", "a client retried a request")
|
|
114
|
+
RECOVERED = ha.reachable("kv.recovered", "the server replayed a non-empty log")
|
|
115
|
+
UNKNOWN = ha.unreachable("kv.unknown_command", "the server got a command it does not know")
|
|
116
|
+
|
|
117
|
+
STORED.check(True, {"key": "a"}) # returns the condition
|
|
118
|
+
PARSES.check(True) # the details are optional
|
|
119
|
+
RETRIED.check(False) # a false `sometimes` writes nothing
|
|
120
|
+
RECOVERED.hit({"entries": 3})
|
|
121
|
+
command = "GET"
|
|
122
|
+
if command not in ("GET", "SET"):
|
|
123
|
+
UNKNOWN.hit({"command": command})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Compare two values
|
|
127
|
+
|
|
128
|
+
`cmp` checks `left op right` and puts both values and the operator into the details, so a
|
|
129
|
+
failure shows them:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
import hydo_assert as ha
|
|
133
|
+
|
|
134
|
+
LOG_SIZE = ha.always("kv.log_size", "the log stays under its cap")
|
|
135
|
+
size = 12
|
|
136
|
+
LOG_SIZE.cmp(size, "<=", 10000) # details: {"left":12,"op":"<=","right":10000}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The operator is one of `<`, `<=`, `==`, `!=`, `>=` and `>`.
|
|
140
|
+
|
|
141
|
+
### Check that something happens in time (eventually)
|
|
142
|
+
|
|
143
|
+
An `eventually` property says that after this point, the condition becomes true within a
|
|
144
|
+
deadline. For example: after a restart, the server answers within 5 seconds. Time inside a hydo
|
|
145
|
+
guest is deterministic, so the verdict is the same on every replay.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import time
|
|
149
|
+
|
|
150
|
+
import hydo_assert as ha
|
|
151
|
+
|
|
152
|
+
BACK = ha.eventually("kv.back", "the server answers within 5 s of its start", 5.0) # seconds
|
|
153
|
+
started = time.monotonic()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def answers():
|
|
157
|
+
return time.monotonic() - started >= 0.02 # stands for a real request
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
assert BACK.await_cond(answers, poll=0.005) # polls every 5 ms
|
|
161
|
+
|
|
162
|
+
# Or mark the start and the end yourself:
|
|
163
|
+
watch = BACK.begin()
|
|
164
|
+
# ... the server comes up ...
|
|
165
|
+
watch.done() # or watch.expire() when you know it will not
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`await_cond` blocks the thread. In asyncio code use `await_async`, whose condition may be a
|
|
169
|
+
plain function or a coroutine function:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
import asyncio
|
|
173
|
+
|
|
174
|
+
import hydo_assert as ha
|
|
175
|
+
|
|
176
|
+
BACK = ha.eventually("kv.back_async", "the server answers within 5 s", 5.0)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def answers():
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def main():
|
|
184
|
+
assert await BACK.await_async(answers, 0.1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
asyncio.run(main())
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Add a fault point (BUGGIFY)
|
|
191
|
+
|
|
192
|
+
A fault point is a place where your program misbehaves on purpose: it drops a reply, fails a
|
|
193
|
+
write, waits too long. Normally the point does nothing (`fire` returns false). hydo can turn
|
|
194
|
+
points on for a run, so that faults happen where only your program can make them. FoundationDB
|
|
195
|
+
calls this technique BUGGIFY.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
import time
|
|
199
|
+
|
|
200
|
+
import hydo_assert as ha
|
|
201
|
+
|
|
202
|
+
LOST_REPLY = ha.buggify("kv.lost_reply_point", "the write lands but its reply is lost")
|
|
203
|
+
SLOW_DISK = ha.buggify("kv.slow_disk", "a write takes 10 ms longer")
|
|
204
|
+
|
|
205
|
+
reply = "OK"
|
|
206
|
+
if LOST_REPLY.fire():
|
|
207
|
+
reply = None # misbehave: the client never gets the reply
|
|
208
|
+
if SLOW_DISK.fire({"key": "a"}):
|
|
209
|
+
time.sleep(0.01)
|
|
210
|
+
print(reply)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
To let hydo turn the points on, add `buggify: true` to the project's `hydo.yaml` and explore
|
|
214
|
+
with `hydo explore --buggify`. Each run then gets a seed that turns some points on, and a point
|
|
215
|
+
that is on fires on some of its calls. To try it outside hydo, set
|
|
216
|
+
`HYDO_BUGGIFY=<seed in hex>,<points on, per mille>,<calls that fire, per mille>`; for example
|
|
217
|
+
`HYDO_BUGGIFY=2a,1000,1000` makes every point fire every time.
|
|
218
|
+
|
|
219
|
+
### Record an operation history
|
|
220
|
+
|
|
221
|
+
hydo can check a whole history of operations after a run. It checks that every read is
|
|
222
|
+
consistent with the writes before it (linearizability per key), and that no list append goes
|
|
223
|
+
missing. Your workload's
|
|
224
|
+
client records each operation twice: when it starts (`invoke`) and when the outcome is known.
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from hydo_assert import history as hh
|
|
228
|
+
|
|
229
|
+
kv = hh.History("kv")
|
|
230
|
+
write = kv.invoke(3, "write", "k1", hh.enc("v1")) # client 3, before the request
|
|
231
|
+
# ... send the request, then record the outcome:
|
|
232
|
+
write.ok() # it happened; fail(): it did not; info(): the outcome is unknown
|
|
233
|
+
read = kv.invoke(3, "read", "k1")
|
|
234
|
+
read.ok("k1", hh.value_or_nil("v1"))
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Then name the history in the project's `hydo.yaml`:
|
|
238
|
+
|
|
239
|
+
```yaml
|
|
240
|
+
checks:
|
|
241
|
+
- id: kv.linearizable
|
|
242
|
+
type: linearizable
|
|
243
|
+
model: register
|
|
244
|
+
history: kv
|
|
245
|
+
msg: every read sees the latest acknowledged write
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`hydo guide checks` describes the checks and their options.
|
|
249
|
+
|
|
250
|
+
### Choose where the records go
|
|
251
|
+
|
|
252
|
+
Under hydo the records go to the guest's console (`/dev/ttyS0` for properties, `/dev/console`
|
|
253
|
+
for histories), which hydo reads even when the program's standard output goes to a log file.
|
|
254
|
+
Outside hydo, where the console cannot be opened, they go to standard error. Two variables
|
|
255
|
+
change this, and each is read once, at the first record:
|
|
256
|
+
|
|
257
|
+
- `HYDO_ASSERT_OUT`: a path of an existing file, `stderr`, `off`, or `logging` (each record
|
|
258
|
+
logged at INFO on the logger `hydo_assert`) for the properties;
|
|
259
|
+
- `HYDO_HIST_OUT`: a path (created if missing), `stderr`, or `off` for the histories.
|
|
260
|
+
|
|
261
|
+
### Run a Python program in a hydo guest
|
|
262
|
+
|
|
263
|
+
Set `PYTHONHASHSEED=0` so that the program does not depend on a random hash seed, and
|
|
264
|
+
`PYTHONDONTWRITEBYTECODE=1` because the guest's root file system is read-only. A complete
|
|
265
|
+
project with a `hydo.yaml` is the python-kv example:
|
|
266
|
+
<https://github.com/kakoc/hydo/tree/main/hydo/onboard/examples/python-kv>.
|
|
267
|
+
|
|
268
|
+
## Reference
|
|
269
|
+
|
|
270
|
+
- The API: the docstrings of `hydo_assert` and `hydo_assert.history` (`help(hydo_assert)`).
|
|
271
|
+
- The records, and how hydo judges each property type:
|
|
272
|
+
<https://github.com/kakoc/hydo/blob/main/hydo/ASSERTIONS.md>.
|
|
273
|
+
- The history format and the checkers:
|
|
274
|
+
<https://github.com/kakoc/hydo/blob/main/hydo/HISTORIES.md>.
|
|
275
|
+
- The same SDK for Rust, JavaScript and Go writes the same records, byte for byte:
|
|
276
|
+
<https://github.com/kakoc/hydo/tree/main/sdk>.
|
|
277
|
+
|
|
278
|
+
Rules of this package:
|
|
279
|
+
|
|
280
|
+
- The catalog (`catalog()`) lists the properties in the order they were declared, which for
|
|
281
|
+
module-level declarations is the import order. Call it once, early, in the process whose
|
|
282
|
+
console hydo reads.
|
|
283
|
+
- Details are encoded as Go's `encoding/json` encodes the same values: keys in UTF-8 byte
|
|
284
|
+
order, a tuple or a set as an array, `bytes` as base64, any other object as `str(obj)`.
|
|
285
|
+
Details that are not a mapping are wrapped as `{"value": ...}`. A record whose details hold a
|
|
286
|
+
NaN or an infinity is not written. The caller's dict is never changed.
|
|
287
|
+
- Every call is safe from threads and from asyncio code: one lock, never held across a blocking
|
|
288
|
+
call or an `await`. After `os.fork()` the child keeps the parent's record of what was written.
|
|
289
|
+
- A history's process is an int or a token without spaces; its arguments are tokens (encode
|
|
290
|
+
free-form values with `enc`). Versions are int64 values. A process has at most one operation
|
|
291
|
+
in flight.
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
Apache-2.0.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# hydo-assert for Python
|
|
2
|
+
|
|
3
|
+
[hydo](https://github.com/kakoc/hydo) runs a whole system in a deterministic virtual machine,
|
|
4
|
+
injects faults (process kills, network faults, power cuts, disk errors) and replays every
|
|
5
|
+
failure bit for bit. This package lets a Python program that runs under hydo say, from the
|
|
6
|
+
inside, what must be true and what should happen. hydo collects what the program reports, and
|
|
7
|
+
an exploration (many runs with different faults) reports each property as passed, failed (with
|
|
8
|
+
a schedule that replays the failure) or never exercised.
|
|
9
|
+
|
|
10
|
+
The package uses only the standard library and needs Python 3.8 or later. Outside hydo it does
|
|
11
|
+
almost nothing: the records go to standard error (or nowhere), and fault points stay off.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install hydo-assert
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The distribution is `hydo-assert`; the module is `hydo_assert`. In a guest image without pip,
|
|
20
|
+
copy the folder `hydo_assert` onto the program's `PYTHONPATH`.
|
|
21
|
+
|
|
22
|
+
## A complete program
|
|
23
|
+
|
|
24
|
+
A small key/value store that declares three properties and checks them:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import hydo_assert as ha
|
|
28
|
+
|
|
29
|
+
READ_BACK = ha.always("kv.read_back", "every acknowledged write reads back with its value")
|
|
30
|
+
OVERWRITE = ha.sometimes("kv.overwrite", "a write replaced an existing value")
|
|
31
|
+
LOST_REPLY = ha.buggify("kv.lost_reply", "the write lands but its reply is lost")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
ha.catalog() # list every property, also the ones that never run
|
|
36
|
+
store = {}
|
|
37
|
+
ha.setup_complete() # the system is ready: hydo may start injecting faults
|
|
38
|
+
|
|
39
|
+
for key, value in [("a", 1), ("b", 2), ("a", 3)]:
|
|
40
|
+
old = store.get(key)
|
|
41
|
+
store[key] = value
|
|
42
|
+
OVERWRITE.check(old is not None, {"key": key})
|
|
43
|
+
if LOST_REPLY.fire({"key": key}):
|
|
44
|
+
continue # the client never hears back (only when hydo turns this point on)
|
|
45
|
+
acked = store.get(key) == value
|
|
46
|
+
READ_BACK.check(acked, {"key": key, "value": value})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Run it with `HYDO_ASSERT_OUT=stderr` to see what it reports. It writes these lines (under hydo
|
|
54
|
+
they go to the guest's console, where hydo reads them):
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
HYDO-ASSERT-DECL {"t":"always","id":"kv.read_back","msg":"every acknowledged write reads back with its value"}
|
|
58
|
+
HYDO-ASSERT-DECL {"t":"sometimes","id":"kv.overwrite","msg":"a write replaced an existing value"}
|
|
59
|
+
HYDO-ASSERT-DECL {"t":"sometimes","id":"buggify.kv.lost_reply","msg":"fault point fired: the write lands but its reply is lost"}
|
|
60
|
+
HYDO-SETUP-COMPLETE
|
|
61
|
+
HYDO-ASSERT {"t":"always","id":"kv.read_back","ok":true,"msg":"every acknowledged write reads back with its value","details":{"key":"a","value":1}}
|
|
62
|
+
HYDO-ASSERT {"t":"sometimes","id":"kv.overwrite","ok":true,"msg":"a write replaced an existing value","details":{"key":"a"}}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A process writes each outcome of a property once: the first pass and the first failure, with
|
|
66
|
+
the details of that evaluation. That is why the later passes of `kv.read_back` write nothing.
|
|
67
|
+
|
|
68
|
+
## How to
|
|
69
|
+
|
|
70
|
+
### Declare properties and check them
|
|
71
|
+
|
|
72
|
+
A property has an id (stable across versions of your program) and a message for people. Its
|
|
73
|
+
kind says what hydo expects:
|
|
74
|
+
|
|
75
|
+
| kind | the property holds when | hydo reports a failure when |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `always` | the condition is true every time, and it is checked at least once | it is false once, or never checked |
|
|
78
|
+
| `always_or_unreachable` | the condition is true every time it is checked | it is false once |
|
|
79
|
+
| `sometimes` | the condition is true at least once in an exploration | it is never true |
|
|
80
|
+
| `reachable` | the code is reached at least once in an exploration | it is never reached |
|
|
81
|
+
| `unreachable` | the code is never reached | it is reached |
|
|
82
|
+
|
|
83
|
+
Declare properties at module level, so that the catalog lists them once the module is
|
|
84
|
+
imported:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import hydo_assert as ha
|
|
88
|
+
|
|
89
|
+
STORED = ha.always("kv.stored", "every acknowledged write is stored")
|
|
90
|
+
PARSES = ha.always_or_unreachable("kv.log_parses", "every complete log line parses")
|
|
91
|
+
RETRIED = ha.sometimes("kv.retried", "a client retried a request")
|
|
92
|
+
RECOVERED = ha.reachable("kv.recovered", "the server replayed a non-empty log")
|
|
93
|
+
UNKNOWN = ha.unreachable("kv.unknown_command", "the server got a command it does not know")
|
|
94
|
+
|
|
95
|
+
STORED.check(True, {"key": "a"}) # returns the condition
|
|
96
|
+
PARSES.check(True) # the details are optional
|
|
97
|
+
RETRIED.check(False) # a false `sometimes` writes nothing
|
|
98
|
+
RECOVERED.hit({"entries": 3})
|
|
99
|
+
command = "GET"
|
|
100
|
+
if command not in ("GET", "SET"):
|
|
101
|
+
UNKNOWN.hit({"command": command})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Compare two values
|
|
105
|
+
|
|
106
|
+
`cmp` checks `left op right` and puts both values and the operator into the details, so a
|
|
107
|
+
failure shows them:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import hydo_assert as ha
|
|
111
|
+
|
|
112
|
+
LOG_SIZE = ha.always("kv.log_size", "the log stays under its cap")
|
|
113
|
+
size = 12
|
|
114
|
+
LOG_SIZE.cmp(size, "<=", 10000) # details: {"left":12,"op":"<=","right":10000}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The operator is one of `<`, `<=`, `==`, `!=`, `>=` and `>`.
|
|
118
|
+
|
|
119
|
+
### Check that something happens in time (eventually)
|
|
120
|
+
|
|
121
|
+
An `eventually` property says that after this point, the condition becomes true within a
|
|
122
|
+
deadline. For example: after a restart, the server answers within 5 seconds. Time inside a hydo
|
|
123
|
+
guest is deterministic, so the verdict is the same on every replay.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import time
|
|
127
|
+
|
|
128
|
+
import hydo_assert as ha
|
|
129
|
+
|
|
130
|
+
BACK = ha.eventually("kv.back", "the server answers within 5 s of its start", 5.0) # seconds
|
|
131
|
+
started = time.monotonic()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def answers():
|
|
135
|
+
return time.monotonic() - started >= 0.02 # stands for a real request
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
assert BACK.await_cond(answers, poll=0.005) # polls every 5 ms
|
|
139
|
+
|
|
140
|
+
# Or mark the start and the end yourself:
|
|
141
|
+
watch = BACK.begin()
|
|
142
|
+
# ... the server comes up ...
|
|
143
|
+
watch.done() # or watch.expire() when you know it will not
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`await_cond` blocks the thread. In asyncio code use `await_async`, whose condition may be a
|
|
147
|
+
plain function or a coroutine function:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import asyncio
|
|
151
|
+
|
|
152
|
+
import hydo_assert as ha
|
|
153
|
+
|
|
154
|
+
BACK = ha.eventually("kv.back_async", "the server answers within 5 s", 5.0)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def answers():
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def main():
|
|
162
|
+
assert await BACK.await_async(answers, 0.1)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
asyncio.run(main())
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Add a fault point (BUGGIFY)
|
|
169
|
+
|
|
170
|
+
A fault point is a place where your program misbehaves on purpose: it drops a reply, fails a
|
|
171
|
+
write, waits too long. Normally the point does nothing (`fire` returns false). hydo can turn
|
|
172
|
+
points on for a run, so that faults happen where only your program can make them. FoundationDB
|
|
173
|
+
calls this technique BUGGIFY.
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
import time
|
|
177
|
+
|
|
178
|
+
import hydo_assert as ha
|
|
179
|
+
|
|
180
|
+
LOST_REPLY = ha.buggify("kv.lost_reply_point", "the write lands but its reply is lost")
|
|
181
|
+
SLOW_DISK = ha.buggify("kv.slow_disk", "a write takes 10 ms longer")
|
|
182
|
+
|
|
183
|
+
reply = "OK"
|
|
184
|
+
if LOST_REPLY.fire():
|
|
185
|
+
reply = None # misbehave: the client never gets the reply
|
|
186
|
+
if SLOW_DISK.fire({"key": "a"}):
|
|
187
|
+
time.sleep(0.01)
|
|
188
|
+
print(reply)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
To let hydo turn the points on, add `buggify: true` to the project's `hydo.yaml` and explore
|
|
192
|
+
with `hydo explore --buggify`. Each run then gets a seed that turns some points on, and a point
|
|
193
|
+
that is on fires on some of its calls. To try it outside hydo, set
|
|
194
|
+
`HYDO_BUGGIFY=<seed in hex>,<points on, per mille>,<calls that fire, per mille>`; for example
|
|
195
|
+
`HYDO_BUGGIFY=2a,1000,1000` makes every point fire every time.
|
|
196
|
+
|
|
197
|
+
### Record an operation history
|
|
198
|
+
|
|
199
|
+
hydo can check a whole history of operations after a run. It checks that every read is
|
|
200
|
+
consistent with the writes before it (linearizability per key), and that no list append goes
|
|
201
|
+
missing. Your workload's
|
|
202
|
+
client records each operation twice: when it starts (`invoke`) and when the outcome is known.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from hydo_assert import history as hh
|
|
206
|
+
|
|
207
|
+
kv = hh.History("kv")
|
|
208
|
+
write = kv.invoke(3, "write", "k1", hh.enc("v1")) # client 3, before the request
|
|
209
|
+
# ... send the request, then record the outcome:
|
|
210
|
+
write.ok() # it happened; fail(): it did not; info(): the outcome is unknown
|
|
211
|
+
read = kv.invoke(3, "read", "k1")
|
|
212
|
+
read.ok("k1", hh.value_or_nil("v1"))
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Then name the history in the project's `hydo.yaml`:
|
|
216
|
+
|
|
217
|
+
```yaml
|
|
218
|
+
checks:
|
|
219
|
+
- id: kv.linearizable
|
|
220
|
+
type: linearizable
|
|
221
|
+
model: register
|
|
222
|
+
history: kv
|
|
223
|
+
msg: every read sees the latest acknowledged write
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`hydo guide checks` describes the checks and their options.
|
|
227
|
+
|
|
228
|
+
### Choose where the records go
|
|
229
|
+
|
|
230
|
+
Under hydo the records go to the guest's console (`/dev/ttyS0` for properties, `/dev/console`
|
|
231
|
+
for histories), which hydo reads even when the program's standard output goes to a log file.
|
|
232
|
+
Outside hydo, where the console cannot be opened, they go to standard error. Two variables
|
|
233
|
+
change this, and each is read once, at the first record:
|
|
234
|
+
|
|
235
|
+
- `HYDO_ASSERT_OUT`: a path of an existing file, `stderr`, `off`, or `logging` (each record
|
|
236
|
+
logged at INFO on the logger `hydo_assert`) for the properties;
|
|
237
|
+
- `HYDO_HIST_OUT`: a path (created if missing), `stderr`, or `off` for the histories.
|
|
238
|
+
|
|
239
|
+
### Run a Python program in a hydo guest
|
|
240
|
+
|
|
241
|
+
Set `PYTHONHASHSEED=0` so that the program does not depend on a random hash seed, and
|
|
242
|
+
`PYTHONDONTWRITEBYTECODE=1` because the guest's root file system is read-only. A complete
|
|
243
|
+
project with a `hydo.yaml` is the python-kv example:
|
|
244
|
+
<https://github.com/kakoc/hydo/tree/main/hydo/onboard/examples/python-kv>.
|
|
245
|
+
|
|
246
|
+
## Reference
|
|
247
|
+
|
|
248
|
+
- The API: the docstrings of `hydo_assert` and `hydo_assert.history` (`help(hydo_assert)`).
|
|
249
|
+
- The records, and how hydo judges each property type:
|
|
250
|
+
<https://github.com/kakoc/hydo/blob/main/hydo/ASSERTIONS.md>.
|
|
251
|
+
- The history format and the checkers:
|
|
252
|
+
<https://github.com/kakoc/hydo/blob/main/hydo/HISTORIES.md>.
|
|
253
|
+
- The same SDK for Rust, JavaScript and Go writes the same records, byte for byte:
|
|
254
|
+
<https://github.com/kakoc/hydo/tree/main/sdk>.
|
|
255
|
+
|
|
256
|
+
Rules of this package:
|
|
257
|
+
|
|
258
|
+
- The catalog (`catalog()`) lists the properties in the order they were declared, which for
|
|
259
|
+
module-level declarations is the import order. Call it once, early, in the process whose
|
|
260
|
+
console hydo reads.
|
|
261
|
+
- Details are encoded as Go's `encoding/json` encodes the same values: keys in UTF-8 byte
|
|
262
|
+
order, a tuple or a set as an array, `bytes` as base64, any other object as `str(obj)`.
|
|
263
|
+
Details that are not a mapping are wrapped as `{"value": ...}`. A record whose details hold a
|
|
264
|
+
NaN or an infinity is not written. The caller's dict is never changed.
|
|
265
|
+
- Every call is safe from threads and from asyncio code: one lock, never held across a blocking
|
|
266
|
+
call or an `await`. After `os.fork()` the child keeps the parent's record of what was written.
|
|
267
|
+
- A history's process is an int or a token without spaces; its arguments are tokens (encode
|
|
268
|
+
free-form values with `enc`). Versions are int64 values. A process has at most one operation
|
|
269
|
+
in flight.
|
|
270
|
+
|
|
271
|
+
## License
|
|
272
|
+
|
|
273
|
+
Apache-2.0.
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "hydo-assert"
|
|
8
|
-
version = "0.
|
|
8
|
+
version = "0.3.0"
|
|
9
9
|
description = "Assertion SDK for programs tested under hydo, a deterministic hypervisor (always/sometimes properties, BUGGIFY fault points, bounded liveness, operation histories)"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.8"
|