diff --git a/assets/composition-failover/worker.mjs b/assets/composition-failover/worker.mjs index fa7b925..24f3b2f 100644 --- a/assets/composition-failover/worker.mjs +++ b/assets/composition-failover/worker.mjs @@ -1,5 +1,5 @@ const READY_HEADER = 'X-APEX-Ready-Token'; -const PROBE_TIMEOUT_MS = 2000; +const PROBE_TIMEOUT_MS = 5000; function hostname(value) { if (typeof value !== 'string' || value.length > 253 || !value.includes('.')) return null; diff --git a/assets/composition-failover/worker.test.mjs b/assets/composition-failover/worker.test.mjs index 14f7226..a0cd5dd 100644 --- a/assets/composition-failover/worker.test.mjs +++ b/assets/composition-failover/worker.test.mjs @@ -75,7 +75,7 @@ test('unready primary cancels unread body and selects ready secondary', async (t assert.equal(calls[2].options.cf.resolveOverride, env.APEX_ORIGIN_SECONDARY); }); -test('probe deadline aborts at two seconds even if fetch ignores cancellation', async (t) => { +test('probe deadline aborts at five seconds even if fetch ignores cancellation', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); let finishPrimary; let canceled = false; @@ -84,8 +84,9 @@ test('probe deadline aborts at two seconds even if fetch ignores cancellation', : count === 2 ? ready() : new Response('secondary')); const pending = worker.fetch(new Request(url), env); await Promise.resolve(); - t.mock.timers.tick(1999); + t.mock.timers.tick(4999); assert.equal(calls.length, 1); + assert.equal(calls[0].options.signal.aborted, false); t.mock.timers.tick(1); const response = await pending; assert.equal(await response.text(), 'secondary'); @@ -227,16 +228,18 @@ test('failed readiness fetch selects secondary without exposing exception detail assert.equal(calls.length, 3); }); -test('secondary probe has its own two-second deadline and no client dispatch follows', async (t) => { +test('secondary probe has its own five-second deadline and no client dispatch follows', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const calls = intercept(t, () => new Promise(() => {})); const pending = worker.fetch(new Request(url), env); - t.mock.timers.tick(2000); + t.mock.timers.tick(5000); await new Promise((resolve) => setImmediate(resolve)); assert.equal(calls.length, 2); assert.equal(calls[0].options.signal.aborted, true); assert.equal(calls[1].options.signal.aborted, false); - t.mock.timers.tick(2000); + t.mock.timers.tick(4999); + assert.equal(calls[1].options.signal.aborted, false); + t.mock.timers.tick(1); const response = await pending; assert.equal(response.status, 503); assert.equal(calls[1].options.signal.aborted, true); diff --git a/assets/composition-readiness/README.md b/assets/composition-readiness/README.md index 7de64d5..640fdd7 100644 --- a/assets/composition-readiness/README.md +++ b/assets/composition-readiness/README.md @@ -21,7 +21,7 @@ Run `python3 adapter.py --config /private/adapter.json` inside the application f Only GET at the exact canonical path, with one matching `X-APEX-Ready-Token` header, can execute the fixed command. Missing/wrong/duplicate credentials return403; other paths404; supported non-GET methods405. Ready returns204; probe failure, timeout or concurrency exhaustion returns503. These responses have empty bodies and `Cache-Control: no-store`. The adapter does not log requests, tokens or command output, cache success, or accept a client-supplied command. Each connection closes after its response. -Probe timeout is bounded to1.5seconds so an origin can answer within the Worker's two-second deadline. A timeout kills the probe's process group. Probe slots and request threads are bounded; slow socket operations time out after two seconds. Operators must account for proxy overhead and actual probe execution time. Probe programs must not detach into a new session or launch background jobs. +Probe timeout defaults to1.5seconds and can be configured up to3seconds. With a3-second probe, allow4seconds at the readiness proxy and5seconds at the Worker. A timeout kills the probe's process group. Probe slots and request threads are bounded; slow socket operations time out after two seconds. Operators must account for proxy overhead and actual probe execution time. Probe programs must not detach into a new session or launch background jobs. Optional `single_flight: true` lets overlapping authenticated requests share one running fixed-command probe. It accepts only a JSON boolean and defaults to false. Waiters use that probe's remaining monotonic deadline; they never queue another probe for the same request. The existing connection limit (`slots * 2 + 4`) also bounds waiting requests. Timeout or shutdown returns unavailable and terminates the probe group. Completed results are not cached: each subsequent request starts a fresh probe, with no success TTL or failure cache. Coalescing reduces concurrent work but does not make a slow individual probe meet its deadline. diff --git a/assets/composition-readiness/adapter.py b/assets/composition-readiness/adapter.py index 5c22034..1909b8f 100644 --- a/assets/composition-readiness/adapter.py +++ b/assets/composition-readiness/adapter.py @@ -24,7 +24,7 @@ class ReadinessServer(http.server.ThreadingHTTPServer): raise ValueError('A canonical absolute readiness path is required') if not isinstance(command, list) or not command or not all(isinstance(arg, str) and '\0' not in arg for arg in command) or not os.path.isabs(command[0]): raise ValueError('A fixed absolute executable and argv are required') - if isinstance(timeout, bool) or not 0 < timeout <= 1.5 or isinstance(slots, bool) or not isinstance(slots, int) or not 1 <= slots <= 16: + if isinstance(timeout, bool) or not 0 < timeout <= 3 or isinstance(slots, bool) or not isinstance(slots, int) or not 1 <= slots <= 16: raise ValueError('Invalid probe bounds') if not isinstance(single_flight, bool): raise ValueError('single_flight must be a boolean') diff --git a/assets/composition-readiness/test_adapter.py b/assets/composition-readiness/test_adapter.py index b8ad498..aa46e34 100644 --- a/assets/composition-readiness/test_adapter.py +++ b/assets/composition-readiness/test_adapter.py @@ -17,6 +17,13 @@ class AdapterTests(unittest.TestCase): c.endheaders();r=c.getresponse();out=(r.status,r.getheader('Cache-Control'),r.read());c.close();return out def test_authenticated_success_runs_fixed_probe(self): self.assertEqual(self.request(self.server()),(204,'no-store',b''));self.assertTrue(self.marker.exists()) + def test_configured_three_seconds_allows_a_probe_longer_than_default(self): + code=f"import time;from pathlib import Path;time.sleep(1.6);Path({str(self.marker)!r}).write_text('completed')" + s=self.server(code,timeout=3,single_flight=True) + self.assertEqual(self.request(s),(204,'no-store',b''));self.assertEqual(self.marker.read_text(),'completed') + def test_timeout_over_three_seconds_is_rejected_before_probe(self): + with self.assertRaises(ValueError):self.server(timeout=3.01) + self.assertFalse(self.marker.exists()) def test_absent_wrong_and_duplicate_tokens_never_probe(self): s=self.server() for token,duplicate in [(None,False),('bad',False),('private-test-token',True)]:self.assertEqual(self.request(s,token=token,extra_token=duplicate)[0],403)