forgetest: the first /state parses each suite module once

The first GET /state after forgetest starts hashes every test's
implementation, and it took from 81 s to more than 12 minutes on the
bench reference. Two causes:

- sibling_imports() read and parsed a test's module and every sibling it
  imports, transitively, once per test: 280 ast.parse calls for 26 suite
  modules. module_parts() kept its own parse, sibling_imports() kept
  none.
- The page gives up on a poll after 20 s and polls again; the server
  thread goes on computing. Each new poll started the same cold work
  beside the first, all of it on the one CPU, so the longer the first
  took the more copies ran. That is the spread between 81 s and 12
  minutes.

Each module's text and tree are now read and parsed once and kept, as
are its direct sibling imports, and the implementation hash is filled
by one thread at a time: a poll that arrives while it is computed waits
for it instead of repeating it. catalog.forget(path) drops what is kept
about a file, for the unit tests that edit their modules.

The hashes do not move: every test's implementation hash and domain
fingerprint on the image manifest of 20260923220034 is byte-identical
before and after, so no result is invalidated. On the host the cold
computation went from 7.00 s to 0.29 s (280 parses to 25). On the bench
reference (the file bind-mounted on image 20260923220034, the page
open) the first /state answered in 8 to 12 s after a restart, where the
same restart earlier in the evening had not answered after 7 minutes.

tests/test_responsiveness.py pins both: each suite module parsed at most
once while every test's implementation hash is computed, and three
threads reading one test's hash compute it once. With the old behavior
put back by a patch both fail (280 parses for 26 modules; the hash
computed 3 times). forgetest's unit tests 454 OK. forgetest is the
dev-only harness, outside the catalog's coverage; its catalog
consequence is none, since no fingerprint moves.
This commit is contained in:
ScottW514
2026-09-23 19:22:10 -04:00
parent 56af6eaab2
commit dc9bede034
3 changed files with 126 additions and 28 deletions
+62 -1
View File
@@ -5,12 +5,16 @@
"""What keeps the page answering the operator instead of the timer.
Two things went wrong on the bench and are pinned here:
Three things went wrong on the bench and are pinned here:
- the state cost. Every poll re-read and re-parsed the whole result log,
and recomputed every test's domain fingerprint. A result record carries
its run log, so the file reaches megabytes over a campaign and the poll
grew with it. Both are now parsed and computed once.
- the first state's cost. Hashing the implementations parsed a test's
module and every module it imports once per test, and each poll the
page timed out on started the same work again beside the first. Each
module is now parsed once, and a poll waits for the work in progress.
- the wasted payload. An idle page polls an unchanged state; it now gets
a 304 instead of the whole thing.
@@ -171,6 +175,63 @@ class FingerprintCacheTests(unittest.TestCase):
self.assertIsNone(self.man.files("no-such-component"))
class ImplementationCostTests(unittest.TestCase):
"""The first state after a start hashes every test's implementation.
It parses each suite module once, and a poll that arrives while it
runs waits for it: on the board a parse per test took minutes, and
the page's timed-out polls each started the work again beside it."""
def test_each_suite_module_is_parsed_once(self):
import ast
import inspect
reg = catalog.load_suite()
paths = sorted({inspect.getsourcefile(t.fn) for t in catalog.all_tests(reg)})
for p in os.listdir(catalog.suite_dir()):
catalog.forget(os.path.join(catalog.suite_dir(), p))
for p in paths:
catalog.forget(p)
real_parse, parsed = ast.parse, []
def counting_parse(source, *a, **kw):
parsed.append(source[:40])
return real_parse(source, *a, **kw)
ast.parse = counting_parse
try:
for t in catalog.all_tests(reg):
catalog.implementation_sha(inspect.getsourcefile(t.fn), t.id)
finally:
ast.parse = real_parse
modules = [p for p in os.listdir(catalog.suite_dir()) if p.endswith(".py")]
self.assertLessEqual(len(parsed), len(modules),
"%d parses for %d suite modules" % (len(parsed), len(modules)))
def test_a_concurrent_fill_waits_instead_of_repeating(self):
import threading
import time
t = helpers.make_test("fake.impl", [("forgectrl", "src/ui.c")], fn=t_noop)
real, calls = catalog.implementation_sha, []
def slow(path, test_id):
calls.append(test_id)
time.sleep(0.3)
return real(path, test_id)
catalog.implementation_sha = slow
try:
got = []
threads = [threading.Thread(target=lambda: got.append(t.source_sha)) for _ in range(3)]
for th in threads:
th.start()
for th in threads:
th.join(10)
finally:
catalog.implementation_sha = real
self.assertEqual(calls, ["fake.impl"], "the implementation hash was computed %d times" % len(calls))
self.assertEqual(len(set(got)), 1)
self.assertEqual(len(got), 3)
class PageTests(unittest.TestCase):
def test_page_never_rebuilds_what_the_operator_may_be_pressing(self):
"""A poll must update rows, prompt buttons and tool entries in