1"""Design checkouts: at most one holder, ever.
3Concurrent editing is not resolved here, it is prevented. The property that
4matters is the one a single-threaded test cannot show: two people pressing Take
5at the same instant must not both succeed. That is what
6`test_only_one_of_many_simultaneous_takes_wins` exists for -- it races real
7processes through the real `flock`, because a mocked lock proves nothing about a
10Everything else follows from that: only the holder may save, a checkout lapses
11if nobody saves for `lock_ttl`, and none of it touches the operations that are
12not concurrent editing (rename, share, copy).
15from datetime
import datetime, timezone
26sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28pytest.importorskip(
"fastapi", reason=
"API tests need fastapi")
29pytest.importorskip(
"httpx", reason=
"fastapi TestClient needs httpx")
31from fastapi.testclient
import TestClient
33from fastapi
import FastAPI
39app.include_router(documents.router)
41A = {
"X-Auth-Email":
"alice@berkeley.edu"}
42B = {
"X-Auth-Email":
"bob@berkeley.edu"}
43OWNER_A = {
"owner": A[
"X-Auth-Email"]}
44BASE =
"/api/engine/documents"
49@pytest.fixture(autouse=True)
51 monkeypatch.setenv(
"USERDATA_DIR", str(tmp_path))
52 monkeypatch.setattr(documents.store,
"micro_interval", 0)
53 documents.store.last_micro.clear()
58 return TestClient(app)
61def _create(client, headers=A, name="Feed system"):
62 r = client.post(BASE, headers=headers, json={
"name": name,
"config": {}})
63 assert r.status_code == 200, r.text
67def _free(client, doc_id, headers=A, params=None):
68 """Release a freshly created design, since creating one now checks it out.
70 Several tests below need a design nobody holds; this is what makes that
71 explicit rather than an assumption about what `_create` leaves behind.
73 r = client.delete(f
"{BASE}/{doc_id}/checkout", headers=headers,
74 params=params
if params
is not None else {})
75 assert r.status_code == 200, r.text
79 assert client.put(f
"{BASE}/{doc_id}/share", headers=A,
80 json={
"sharedWith": emails}).status_code == 200
83def _save(client, doc_id, headers, params=None, config=None):
84 return client.post(f
"{BASE}/{doc_id}/autosave", headers=headers,
85 params=params
if params
is not None else {},
86 json={
"config": config
or {
"v": 1}})
92def _race_take(barrier, results, root, doc_id, email, base, owner_param):
93 """One contender, in its own process.
95 Threads would not prove anything: `flock` is held per open file description,
96 so the contention that matters is between processes, exactly as it is
97 between the API's workers.
99 The barrier is the point. Without it the pool tends to run these one after
100 another -- each finishes before the next starts, nobody actually races, and
101 the test passes even with the locking removed. Every process parks here
102 until all of them are ready, so they go for the design together.
104 os.environ[
"USERDATA_DIR"] = root
106 from fastapi
import FastAPI
107 from fastapi.testclient
import TestClient
as TC
113 fresh.include_router(documents.router)
115 barrier.wait(timeout=30)
116 r = c.post(f
"{base}/{doc_id}/checkout",
117 headers={
"X-Auth-Email": email}, params=owner_param)
118 results.put(r.status_code)
119 except Exception
as exc:
120 results.put(f
"error: {exc!r}")
123@pytest.mark.parametrize("attempt", range(5))
125 """The whole feature in one assertion, repeated -- a race that passes once
126 has proved nothing."""
128 contenders = [f
"user{i}@berkeley.edu" for i
in range(8)]
129 _share(client, doc_id, contenders)
130 _free(client, doc_id)
132 ctx = multiprocessing.get_context(
"fork")
133 barrier = ctx.Barrier(len(contenders))
134 results = ctx.Queue()
136 ctx.Process(target=_race_take,
137 args=(barrier, results, str(tmp_path), doc_id, e, BASE, OWNER_A))
145 codes = [results.get(timeout=5)
for _
in contenders]
146 assert all(isinstance(c, int)
for c
in codes), f
"a contender crashed: {codes}"
147 assert codes.count(200) == 1, f
"expected exactly one winner, got {codes}"
148 assert codes.count(423) == len(contenders) - 1, codes
151 index = json.loads((tmp_path /
"alice@berkeley.edu" /
"engine" /
"index.json").read_text())
152 record = next(r
for r
in index
if r[
"id"] == doc_id)
153 assert record[
"lockedBy"]
in contenders
161 _share(client, doc_id, [B[
"X-Auth-Email"]])
163 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
164 assert _save(client, doc_id, A).status_code == 200
165 r =
_save(client, doc_id, B, params=OWNER_A)
166 assert r.status_code == 423
167 assert "checked out by" in r.json()[
"detail"]
171 """Opening a design does not take it, so a save without the checkout must
172 fail rather than quietly claiming the design.
174 Creating one *does* check it out, so this releases first to get at the state
175 a second person opening the design would be in.
178 _free(client, doc_id)
179 r =
_save(client, doc_id, A)
180 assert r.status_code == 423
181 assert "Take" in r.json()[
"detail"]
184@pytest.mark.parametrize(
"path,method,body", [
185 (
"/autosave",
"POST", {
"config": {}}),
186 (
"/flush",
"POST", {
"config": {}}),
187 (
"/release",
"POST", {
"label":
"0.1"}),
191 _share(client, doc_id, [B[
"X-Auth-Email"]])
192 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
193 r = client.request(method, f
"{BASE}/{doc_id}{path}", headers=B, params=OWNER_A, json=body)
194 assert r.status_code == 423, f
"{path} was writable by a non-holder"
197@pytest.mark.parametrize(
"name,method,path,body", [
198 (
"rename",
"PATCH",
"", {
"name":
"Renamed"}),
199 (
"share",
"PUT",
"/share", {
"sharedWith": []}),
202 """A checkout guards the design's *content*. Renaming or re-sharing is not
203 concurrent editing, and blocking it would let a stale checkout freeze a
204 design nobody can tidy."""
206 _share(client, doc_id, [B[
"X-Auth-Email"]])
207 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
208 r = client.request(method, f
"{BASE}/{doc_id}{path}", headers=B, params=OWNER_A, json=body)
209 assert r.status_code == 200, f
"{name} was blocked by a checkout"
214 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
215 r = client.post(f
"{BASE}/copy", headers=B, json={
"owner": A[
"X-Auth-Email"],
"id": doc_id})
216 assert r.status_code == 200
224 _share(client, doc_id, [B[
"X-Auth-Email"]])
225 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
226 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 423
228 monkeypatch.setattr(documents.store,
"lock_ttl", 0)
229 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 200
233 """The dangerous case: A's checkout lapses, B takes it, and A's tab is still
234 autosaving. A must be refused, not allowed to overwrite B."""
236 _share(client, doc_id, [B[
"X-Auth-Email"]])
237 client.post(f
"{BASE}/{doc_id}/checkout", headers=A)
238 monkeypatch.setattr(documents.store,
"lock_ttl", 0)
239 client.post(f
"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A)
240 monkeypatch.setattr(documents.store,
"lock_ttl", 900)
242 assert _save(client, doc_id, A, config={
"v":
"stale"}).status_code == 423
243 assert _save(client, doc_id, B, params=OWNER_A, config={
"v":
"fresh"}).status_code == 200
244 assert client.get(f
"{BASE}/{doc_id}/load", headers=A).
json()[
"config"] == {
"v":
"fresh"}
248 """`lock_ttl` measures time since the last *save*, so someone actively
249 editing never loses the design out from under themselves."""
251 client.post(f
"{BASE}/{doc_id}/checkout", headers=A)
252 first = client.get(f
"{BASE}/{doc_id}/checkout", headers=A).
json()[
"lockExpiresAt"]
253 _save(client, doc_id, A, config={
"v":
"later"})
254 assert client.get(f
"{BASE}/{doc_id}/checkout", headers=A).
json()[
"lockExpiresAt"] >= first
262 _share(client, doc_id, [B[
"X-Auth-Email"]])
263 client.post(f
"{BASE}/{doc_id}/checkout", headers=A)
266 assert client.delete(f
"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 200
267 assert client.get(f
"{BASE}/{doc_id}/checkout", headers=A).
json()[
"lockedByMe"]
is True
269 assert client.delete(f
"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
270 assert client.delete(f
"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
271 assert client.get(f
"{BASE}/{doc_id}/checkout", headers=A).
json()[
"lockedBy"]
is None
276 _share(client, doc_id, [B[
"X-Auth-Email"]])
277 client.post(f
"{BASE}/{doc_id}/checkout", headers=A)
279 mine = next(d
for d
in client.get(BASE, headers=A).
json()
if d[
"id"] == doc_id)
280 assert mine[
"lockedByMe"]
is True and mine[
"lockedBy"] == A[
"X-Auth-Email"]
282 theirs = next(d
for d
in client.get(BASE, headers=B).
json()
if d[
"id"] == doc_id)
283 assert theirs[
"lockedByMe"]
is False and theirs[
"lockedBy"] == A[
"X-Auth-Email"]
287 """Every existing design has no lock fields at all."""
288 index = tmp_path /
"alice@berkeley.edu" /
"engine" /
"index.json"
289 index.parent.mkdir(parents=
True, exist_ok=
True)
290 index.write_text(json.dumps([{
"id":
"old",
"name":
"Old",
291 "createdAt":
"2020-01-01",
"updatedAt":
"2020-01-01"}]))
292 assert client.get(f
"{BASE}/old/checkout", headers=A).
json()[
"lockedBy"]
is None
293 assert client.post(f
"{BASE}/old/checkout", headers=A).status_code == 200
296 """A single take, in its own process, reporting its status code."""
297 os.environ[
"USERDATA_DIR"] = root
299 from fastapi
import FastAPI
300 from fastapi.testclient
import TestClient
as TC
303 fresh.include_router(documents.router)
305 r = c.post(f
"{base}/{doc_id}/checkout",
306 headers={
"X-Auth-Email": email}, params=owner_param)
307 results.put(r.status_code)
308 except Exception
as exc:
309 results.put(f
"error: {exc!r}")
313 """Deterministic proof that the compare-and-set is genuinely serialised.
315 The race above is probabilistic: the critical section is sub-millisecond, so
316 contenders often fail to interleave and it can pass even with the locking
317 removed. This one cannot. It grabs the very same `flock` the take path uses,
318 then asserts a take *blocks* until it is released -- which is exactly the
319 property that makes two simultaneous holders impossible.
322 lock_path = tmp_path /
"alice@berkeley.edu" / APP_SEGMENT /
".index.lock"
323 lock_path.parent.mkdir(parents=
True, exist_ok=
True)
325 ctx = multiprocessing.get_context(
"fork")
326 results = ctx.Queue()
327 fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
328 proc = ctx.Process(target=_take_in_child,
329 args=(results, str(tmp_path), doc_id, BASE, OWNER_A,
334 _share(client, doc_id, [
"bob@berkeley.edu"])
335 _free(client, doc_id)
338 fcntl.flock(fd, fcntl.LOCK_EX)
341 assert proc.is_alive(), (
342 "a checkout completed while the index lock was held -- the "
343 "compare-and-set is not serialised, so two people can hold one design"
346 fcntl.flock(fd, fcntl.LOCK_UN)
349 proc.join(timeout=30)
350 assert not proc.is_alive(),
"the take never completed after the lock was released"
351 assert results.get(timeout=5) == 200
355 """You made it a moment ago; nobody else can possibly hold it.
357 Without this you create a design, start editing, and every save is refused
358 until you notice a button you had no reason to look at -- which reads as
359 "the thing I just made keeps losing my work".
362 state = client.get(f
"{BASE}/{doc_id}/checkout", headers=A).
json()
363 assert state[
"lockedByMe"]
is True
365 assert _save(client, doc_id, A).status_code == 200
369 """Same reasoning: you take a copy in order to work on it."""
371 copy = client.post(f
"{BASE}/copy", headers=B,
372 json={
"owner": A[
"X-Auth-Email"],
"id": a_id}).
json()
373 state = client.get(f
"{BASE}/{copy['id']}/checkout", headers=B).
json()
374 assert state[
"lockedByMe"]
is True
375 assert _save(client, copy[
"id"], B).status_code == 200
379 """A new design carries its own checkout; it must not disturb a design
380 someone else is holding."""
382 _share(client, a_id, [B[
"X-Auth-Email"]])
383 _create(client, B,
"Bob's own")
384 assert client.get(f
"{BASE}/{a_id}/checkout", headers=A).
json()[
"lockedByMe"]
is True
393DECORATED = {
"owner",
"ownerName",
"mine",
"sharedWith",
"lockedByMe"}
399 listed = client.get(BASE, headers=A).
json()[0]
400 assert DECORATED <= set(listed), f
"list: missing {DECORATED - set(listed)}"
402 created = client.post(BASE, headers=A, json={
"name":
"Shaped",
"config": {}}).
json()
403 assert DECORATED <= set(created), f
"create: missing {DECORATED - set(created)}"
405 renamed = client.patch(f
"{BASE}/{doc_id}", headers=A, json={
"name":
"Renamed"}).
json()
406 assert DECORATED <= set(renamed), f
"rename: missing {DECORATED - set(renamed)}"
408 shared = client.put(f
"{BASE}/{doc_id}/share", headers=A,
409 json={
"sharedWith": [B[
"X-Auth-Email"]]}).
json()
410 assert DECORATED <= set(shared), f
"share: missing {DECORATED - set(shared)}"
412 copied = client.post(f
"{BASE}/copy", headers=B,
413 json={
"owner": A[
"X-Auth-Email"],
"id": doc_id}).
json()
414 assert DECORATED <= set(copied), f
"copy: missing {DECORATED - set(copied)}"
418 """The specific failure: `mine` absent reads as false, and the bar then shows
419 your own design as belonging to an undefined owner."""
422 (
"create", client.post(BASE, headers=A, json={
"name":
"Shaped",
"config": {}})),
423 (
"rename", client.patch(f
"{BASE}/{doc_id}", headers=A, json={
"name":
"Still mine"})),
426 assert body[
"mine"]
is True, f
"{label} did not report the design as yours"
427 assert body[
"ownerName"], f
"{label} returned an empty ownerName"
431 """`lockExpiresAt` is when the hold lapses, not when it was last beaten.
433 It used to carry the raw heartbeat -- a time already in the past -- so a bar
434 counting down to it read as expired the moment it rendered, which is why
435 nothing ever showed the user how long they had. Fails against that.
438 client.post(f
"{BASE}/{doc_id}/checkout", headers=A)
439 state = client.get(f
"{BASE}/{doc_id}/checkout", headers=A).
json()
440 expires = datetime.fromisoformat(state[
"lockExpiresAt"])
441 left = (expires - datetime.now(timezone.utc)).total_seconds()
442 assert 0 < left <= documents.store.lock_ttl
443 assert state[
"lockTtlSeconds"] == documents.store.lock_ttl
447 """The fix for "it kicked me out while I was working".
449 Before this endpoint the only thing that refreshed a hold was a successful
450 autosave, so panning, measuring or thinking counted as idle. The beat must
451 push the expiry out and must NOT touch the stored payload.
454 client.post(f
"{BASE}/{doc_id}/checkout", headers=A)
455 _save(client, doc_id, A)
456 stored = client.get(f
"{BASE}/{doc_id}/load", headers=A).
json()
457 before = client.get(f
"{BASE}/{doc_id}/checkout", headers=A).
json()[
"lockExpiresAt"]
459 r = client.post(f
"{BASE}/{doc_id}/checkout/beat", headers=A)
460 assert r.status_code == 200
461 assert r.json()[
"lockedByMe"]
is True
462 assert r.json()[
"lockExpiresAt"] >= before
466 assert client.get(f
"{BASE}/{doc_id}/load", headers=A).
json() == stored
470 """423 is the client's cue to stop beating and show the lost dialog.
472 Shared with B on purpose: an unshared user is refused at 403 long before the
473 lock is consulted, so it would prove nothing about the beat.
476 _share(client, doc_id, [B[
"X-Auth-Email"]])
477 client.post(f
"{BASE}/{doc_id}/checkout", headers=A)
478 assert client.post(f
"{BASE}/{doc_id}/checkout/beat", headers=B, params=OWNER_A).status_code == 423
482 """The end-to-end claim, both directions.
484 Someone interacting without saving must keep the design; someone who has
485 genuinely walked away must lose it, because release is holder-only and the
486 on-close beacon is best-effort, so lapsing is the only thing that recovers a
487 checkout after a crash or a power cut.
490 _share(client, doc_id, [B[
"X-Auth-Email"]])
491 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=A).status_code == 200
493 monkeypatch.setattr(documents.store,
"lock_ttl", 2)
498 assert client.post(f
"{BASE}/{doc_id}/checkout/beat", headers=A).status_code == 200
499 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 423
503 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 200
507 """A lapse with nobody waiting is not a conflict, and must not cost the edit.
509 Every content write needs the checkout, so if nobody has taken the design
510 since it lapsed on you, nobody can have changed it: your next beat or save
511 takes the hold back instead of being refused (see
512 ``DesignStore.claim_for_write``). Refusing it was how edits were lost --
513 step away past ``lock_ttl``, come back, keep editing, and every save came
514 back 423 with nobody else anywhere near the design.
517 _share(client, doc_id, [B[
"X-Auth-Email"]])
518 monkeypatch.setattr(documents.store,
"lock_ttl", 0)
519 assert client.post(f
"{BASE}/{doc_id}/checkout/beat", headers=A).status_code == 200
520 assert _save(client, doc_id, A).status_code == 200
521 monkeypatch.setattr(documents.store,
"lock_ttl", 900)
522 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 423
526 """Lapsing still frees the design for whoever acts first. Once B has taken
527 it, A's beat and save are refused -- the two-holders case checkouts exist
530 _share(client, doc_id, [B[
"X-Auth-Email"]])
531 monkeypatch.setattr(documents.store,
"lock_ttl", 0)
532 assert client.post(f
"{BASE}/{doc_id}/checkout", headers=B, params=OWNER_A).status_code == 200
533 monkeypatch.setattr(documents.store,
"lock_ttl", 900)
534 assert client.post(f
"{BASE}/{doc_id}/checkout/beat", headers=A).status_code == 423
535 assert _save(client, doc_id, A).status_code == 423
a class to store JSON values
test_every_endpoint_that_returns_a_record_returns_a_decorated_one(client)
test_creating_a_design_checks_it_out_to_you(client)
_share(client, doc_id, emails)
test_only_one_of_many_simultaneous_takes_wins(client, tmp_path, attempt)
test_lock_expires_at_is_in_the_future(client)
test_a_checkout_lapses_and_the_next_person_can_take_it(client, monkeypatch)
_take_in_child(results, root, doc_id, base, owner_param, email)
test_beat_refuses_when_you_do_not_hold_it(client)
_isolate(tmp_path, monkeypatch)
test_beat_alone_survives_the_ttl_but_idling_does_not(client, monkeypatch)
test_take_cannot_proceed_while_the_index_lock_is_held(client, tmp_path)
test_saving_without_taking_it_is_refused(client)
_free(client, doc_id, headers=A, params=None)
test_saving_keeps_the_checkout_alive(client)
test_beat_refreshes_the_hold_without_writing(client)
test_creating_does_not_touch_anyone_elses_checkout(client)
_race_take(barrier, results, root, doc_id, email, base, owner_param)
test_the_list_reports_the_checkout_without_an_extra_call(client)
test_copying_is_not_blocked_by_a_checkout(client)
test_holder_may_save_and_others_may_not(client)
test_the_previous_holder_is_refused_rather_than_silently_overwriting(client, monkeypatch)
test_copying_a_design_checks_the_copy_out_to_you(client)
test_a_lapsed_hold_nobody_took_is_reclaimed_by_its_holder(client, monkeypatch)
test_every_content_write_needs_the_checkout(client, path, method, body)
test_release_is_idempotent_and_holder_only(client)
test_a_returned_record_says_the_design_is_yours(client)
_save(client, doc_id, headers, params=None, config=None)
test_a_lapsed_hold_someone_else_took_is_not_reclaimed(client, monkeypatch)
test_housekeeping_is_not_blocked_by_someone_elses_checkout(client, name, method, path, body)
_create(client, headers=A, name="Feed system")
test_designs_predating_checkouts_read_as_free(client, tmp_path)