Skip to content

fix(serverless): strict scheduling raises on dispatch failure, not just noop - #30

Merged
eldonm merged 2 commits into
mainfrom
fix/strict-scheduler-failures
Jul 31, 2026
Merged

fix(serverless): strict scheduling raises on dispatch failure, not just noop#30
eldonm merged 2 commits into
mainfrom
fix/strict-scheduler-failures

Conversation

@eldonm

@eldonm eldonm commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

create_task(..., strict=True) promised scheduling failures surface to the caller, but strict guarded exactly one case: the LoggingNoop scheduler resolving in serverless mode. The AWS schedulers never raised — AwsLambdaDeferredTaskScheduler swallowed invoke exceptions and returned a synthetic reference, returned early (warn only) with AWS_LAMBDA_FUNCTION_NAME unset, and AwsSqsTaskScheduler silently no-op'd when unconfigured.

A strict caller is stating it has failure handling of its own — jvagent's WhatsApp webhook answers 5xx and releases its wamid dedup claim so Meta retries. A synthetic reference for an undispatched task turns that handling into silent message loss: the webhook returned 200, the wamid stayed claimed, and the retry was dedup-blocked.

Changes

  • strict: bool = False added to TaskScheduler.schedule and threaded through dispatch_deferred_task.
  • Under strict: aws_lambda raises on missing function name and re-raises invoke failures (EventBridge failure still falls back to direct invoke — a working fallback is not a dispatch failure); aws_sqs raises when unconfigured (send_message already propagated); the noop stub raises.
  • Non-strict behaviour is byte-for-byte unchanged — every existing fire-and-forget caller keeps its semantics.

Testing

2014 passed / 129 skipped; pre-commit clean on touched files. New tests cover both directions per path: strict raises where non-strict returns a synthetic ref; the success path still returns a reference.

Consumer side: TrueSelph/jvagent#132 carries a contract test that pins these semantics against the installed jvspatial and skips with a loud deploy warning when paired with a version predating this fix.

🤖 Generated with Claude Code

…st noop

create_task(..., strict=True) promised that scheduling failures surface to the
caller, but strict only guarded one case: the LoggingNoop scheduler resolving
in serverless mode. The AWS schedulers themselves never raised —
AwsLambdaDeferredTaskScheduler swallowed invoke exceptions and returned a
synthetic reference, returned early (warn only) when AWS_LAMBDA_FUNCTION_NAME
was unset, and AwsSqsTaskScheduler silently no-op'd when unconfigured.

A caller passing strict=True is stating that it has failure handling of its
own — a 5xx to the webhook origin, a dedup claim to release — and that a
silently-dropped task is data loss. Handing that caller a reference for a task
that was never dispatched converts its retry path into exactly that loss:
jvagent's WhatsApp deferred-interact webhook returned 200 on real dispatch
failures, the wamid stayed claimed, and Meta's retry was dedup-blocked.

`strict: bool = False` is added to TaskScheduler.schedule and threaded through
dispatch_deferred_task. Under strict: aws_lambda raises on a missing function
name and re-raises invoke failures (an EventBridge failure still falls back to
direct invoke — a working fallback is not a dispatch failure); aws_sqs raises
when unconfigured (send_message failures already propagated); the noop stub
raises. Non-strict behaviour is byte-for-byte unchanged, so every existing
fire-and-forget caller keeps its semantics.

Tests cover both directions per path: strict raises where non-strict returns a
synthetic ref, and the success path still returns a reference.

2014 passed, 129 skipped; pre-commit clean on touched files.
@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.051658 0.036425 -29.5% IMPROVED (-29.5%)
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.050786 0.037536 -26.1% IMPROVED (-26.1%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.540245 0.554384 +2.6% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 1.190978 1.161720 -2.5% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.229175 1.039863 -15.4% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 0.963805 0.807954 -16.2% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001772 0.001768 -0.3% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.357989 0.235644 -34.2% IMPROVED (-34.2%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.416590 0.281818 -32.4% IMPROVED (-32.4%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.400185 0.251202 -37.2% IMPROVED (-37.2%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.401298 0.273171 -31.9% IMPROVED (-31.9%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.371018 0.219958 -40.7% IMPROVED (-40.7%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.434869 0.276784 -36.4% IMPROVED (-36.4%)

@eldonm eldonm self-assigned this Jul 31, 2026
@eldonm

eldonm commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Widening strict from "the resolved scheduler is a noop" to "any dispatch failure" is the right call, and threading it through the TaskScheduler ABC is the right shape. Two things block it as written, and the coverage is thinner than the contract change warrants.

Blockers

1. strict=strict is forwarded unconditionally — breaks third-party schedulers

jvspatial/serverless/factory.py:168 always passes strict= to sched.schedule(...), including when strict is False. TaskScheduler is Public (stable) per docs/md/stability.md:38, and get_task_scheduler duck-types config.task_scheduler (factory.py:99-101). Any existing implementation with the pre-PR 5-arg signature now raises TypeError: schedule() got an unexpected keyword argument 'strict' on every dispatch — non-strict included, and that covers everything routed through create_task() in serverless mode (jvspatial/async_utils/__init__.py:587-594).

Either forward strict only when truthy, or probe the signature / accept **kwargs. If the signature change is intended to be breaking, docs/md/stability.md:12-15 wants a deprecation cycle and a **BREAKING** changelog entry.

2. No CHANGELOG entry

CLAUDE.md:148 requires one under ### Fixed for a bugfix. ## [Unreleased] is empty on this branch.

Should-fix

3. NoopOrSyncScheduler accepts strict and ignores itjvspatial/serverless/tasks/sync.py:22-29

This is what get_task_scheduler returns for all non-serverless callers (factory.py:103-104, executor=None). dispatch_deferred_task(..., strict=True) outside serverless mode returns sync-<uuid> and runs nothing — the exact silent-loss shape this PR exists to remove. The factory's strict pre-check can't catch it either: factory.py:152-156 gates on is_serverless_mode(config) and isinstance(sched, LoggingNoopTaskScheduler).

The comment at sync.py:27 ("Strict-safe default: execute immediately in-process") sits inside the if self._executor is not None: branch and doesn't describe the None case it appears to be reassuring the reader about.

4. EventBridge failure silently downgrades a scheduled task to an immediate invokejvspatial/serverless/tasks/aws_lambda.py:187-192

_create_eventbridge_schedule swallows everything and returns False (:122-128); the new comment excuses this as "NOT a dispatch failure". But the fallback invokes the Lambda now with process_at in the body. For any run_at/delay_seconds past Lambda's 15-minute ceiling that task cannot complete, and a strict caller still gets a success reference. Either raise under strict or document it as a known limitation.

5. The invoke() response is never inspectedjvspatial/serverless/tasks/aws_lambda.py:196-201

InvocationType="Event" returns StatusCode: 202 on acceptance. Only a raised exception counts as failure here, so a non-202 StatusCode or a FunctionError in the response reports success even under strict=True.

6. Ad-hoc RuntimeError instead of the central hierarchyaws_lambda.py:173, aws_sqs.py:41, stub.py:36

jvspatial/exceptions.py has JVSpatialErrorConfigurationErrorMissingConfigurationError / InvalidConfigurationError, which fit both cases (unset AWS_LAMBDA_FUNCTION_NAME; unconfigured SQS client). Strict callers are meant to branch on this — retry vs. fail fast — and bare RuntimeError gives them nothing to catch on. The pre-existing raise at factory.py:157 is also a bare RuntimeError, so consider migrating all four together.

7. Downstream-consumer specifics in library code and testsaws_lambda.py:158-165, tests/serverless/test_aws_lambda_scheduler.py:97-104

CLAUDE.md:183: "Do not add features mentioning specific downstream consumers in the library or its docs." The docstring and test comments narrate a particular WhatsApp/Meta incident (wamid stayed claimed, Meta's retry dedup-blocked, webhook returned 200). Rewrite generically — "a caller with its own retry/claim-release handling". There's a milder pre-existing instance at aws_lambda.py:94-96.

8. Contract docs not updateddocs/md/serverless-mode.md:77, SPEC §11.3

The doc still describes the old narrow meaning: "raises RuntimeError if serverless mode is on but the resolved scheduler is a logging no-op". SPEC §11.3 doesn't mention strict at all. CLAUDE.md:140/:179 want these to move with the code.

9. Coverage is Lambda-only

No test_aws_sqs*.py / test_stub*.py / test_sync*.py exists. Missing: SQS unconfigured-client raise (aws_sqs.py:39-45, new code, zero coverage); SQS send_message raising; LoggingNoopTaskScheduler.schedule(strict=True) (stub.py:35-39, new code, zero coverage); NoopOrSyncScheduler under strict (see 3); that dispatch_deferred_task actually forwards strict (the factory.py:168 change has no test); an old-signature custom scheduler — which would have caught blocker 1; ClientError and non-202 responses (see 5); EventBridge failure under strict (see 4).

10. Non-strict failure semantics differ by transportaws_sqs.py:33-37, 59-63

Accurate as documented: SQS send_message failures always propagate, while lambda_invoke swallows them (aws_lambda.py:202-210). But _aws_task_scheduler() picks the transport from env, so identical application code has opposite failure semantics depending on deployment config. Making strict consistent leaves the default path inconsistent.

Nits

  • tests/serverless/test_aws_lambda_scheduler.py:123,131 — the failure fixture is a builtin ConnectionError; real failures surface as botocore.exceptions.ClientError / EndpointConnectionError. Also jvspatial/exceptions.py:189 defines its own ConnectionError, so the unqualified name is a latent shadowing hazard here.
  • stub.py:35-39 duplicates the factory.py:152-160 guard with a different message, and only fires when a LoggingNoopTaskScheduler is injected outside serverless mode. Neither variant is tested.
  • test_non_strict_keeps_fire_and_forget_when_function_name_unset is a strictly better version of the pre-existing test_schedule_invoke_without_function_name_logs (:21) — it adds the monkeypatch.delenv the older one lacks. Consider deleting the older one.
  • factory.py:157 vs :161 (pre-existing): the strict raise precedes _note_noop_in_serverless, so _NOOP_DEFERRED_LOGGED never gets set and strict callers never see the one-time diagnostic at :122-126. This PR is a natural place to reorder.

…transport

Follow-up to the strict-scheduling change, closing the gaps found in review.

Blocker: `dispatch_deferred_task` forwarded `strict=` to `sched.schedule()`
unconditionally. `TaskScheduler` is a public/stable extension point and
`config.task_scheduler` is duck-typed, so every third-party scheduler written
against the pre-`strict` signature would have raised `TypeError` on *every*
dispatch, non-strict included. The signature is now introspected (cached per
class) and `strict` omitted for schedulers that predate it; a `strict=True`
dispatch through one raises `TaskSchedulerNotConfiguredError` explaining it
cannot honor the guarantee.

Remaining silent-failure paths, all now raising under strict:

* `NoopOrSyncScheduler` with no executor — what `get_task_scheduler` returns
  for every non-serverless caller — accepted `strict=True` and dropped the
  task.
* Lambda `invoke` responses were never inspected. An async invoke answers 202
  on acceptance, so a non-2xx `StatusCode` or a `FunctionError` was reported
  as success because boto3 had not raised.
* An EventBridge scheduling failure fell back to invoking immediately with
  `process_at` in the body. Past Lambda's 900s timeout the handler cannot
  survive until `run_at`, so the task was doomed and the caller still got a
  success reference.
* SQS `send_message` failures propagated while the Lambda transport swallowed
  them, so the same application code had opposite failure semantics depending
  on `JVSPATIAL_AWS_DEFERRED_TRANSPORT`. `strict` is now the single switch on
  both.

Replace the ad-hoc `RuntimeError`s with `DeferredTaskError` →
`TaskDispatchError` → `TaskSchedulerNotConfiguredError`, so a strict caller
can distinguish "retry may succeed" from "this deployment will never
dispatch". `DeferredTaskError` also derives from `RuntimeError`, keeping
existing handlers working.

Emit the one-time no-op diagnostic before the strict raise — a deployment
whose callers are all strict never saw the startup error explaining why
nothing dispatched.

Add coverage for SQS, the logging no-op, the sync fallback, `strict`
forwarding through the factory, legacy and `**kwargs` schedulers, and
non-raising invoke rejections; the suite was Lambda-only. Rewrite the
downstream-consumer narration in docstrings and test data generically per
CLAUDE.md, and update `docs/md/serverless-mode.md` and SPEC §11.3, which
still described the old narrow meaning of `strict`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@eldonm

eldonm commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Pushed dd1c21a addressing everything above.

Blockers

  1. strict= is no longer forwarded unconditionally. dispatch_deferred_task introspects schedule() (cached per class) and omits the argument for schedulers that predate it, so third-party implementations keep serving non-strict dispatches unchanged. A strict=True dispatch through one raises TaskSchedulerNotConfiguredError explaining it cannot honor the guarantee, rather than TypeError. Covered by test_dispatch_omits_strict_for_a_legacy_scheduler / test_dispatch_refuses_strict_on_a_legacy_scheduler.
  2. CHANGELOG entries added under ### Added / ### Fixed / ### Changed.

Should-fixes
3. NoopOrSyncScheduler with no executor now raises under strict. With an executor it satisfies strict as-is — the work ran in-process before schedule() returned — and the misleading comment moved accordingly.
4. EventBridge failure now raises under strict when the delay exceeds _LAMBDA_MAX_TIMEOUT_SECONDS (900), where an immediate invoke provably cannot honor run_at. Shorter delays still fall through to the invoke, which strict already guarded.
5. invoke responses are inspected via _invoke_rejection: non-2xx StatusCode or a FunctionError is a dispatch failure even though boto3 did not raise.
6. New DeferredTaskErrorTaskDispatchErrorTaskSchedulerNotConfiguredError in jvspatial/exceptions.py, replacing all four bare RuntimeErrors (including the pre-existing one at factory.py:157). DeferredTaskError also derives from RuntimeError, so this is not a breaking change for existing handlers.
7. Downstream-consumer narration rewritten generically in the docstrings, the test comments, and the test payload field names.
8. docs/md/serverless-mode.md rewritten with an exception table; SPEC §11.3 gained a "Dispatch failure contract" section.
9. New tests/serverless/test_task_scheduler_strict.py covers SQS (unconfigured + send_message failure), the logging no-op, the sync fallback, strict forwarding through the factory, legacy and **kwargs schedulers. The Lambda file gained non-raising invoke rejections and the EventBridge timeout cases. 12 → 56 serverless tests.
10. Resolved rather than documented: SQS send_message failures are now swallowed when non-strict, matching the Lambda transport. strict is the single switch on every transport. This is a behavior change for anyone relying on SQS raising by default — called out in the CHANGELOG.

NitsClientError replaces the builtin ConnectionError fixture (with a fallback shim when botocore is absent); the stub.py raise is kept as the backstop for direct callers and for a no-op injected outside serverless mode, with a docstring saying so and tests for both paths; the superseded test_schedule_invoke_without_function_name_logs is deleted; _note_noop_in_serverless now runs before the strict raise so strict-only deployments still get the startup diagnostic.

Full suite: 2038 passed, 129 skipped. pre-commit run --all-files green.

Not addressed — pre-existing and outside this diff: downstream references in jvspatial/serverless/deferred_invoke.py:27, tests/serverless/test_deferred_invoke.py, and tests/api/test_storage_serve.py.

@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.051658 0.050772 -1.7% OK
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.050786 0.047749 -6.0% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.540245 0.462232 -14.4% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 1.190978 0.995260 -16.4% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.229175 1.124737 -8.5% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 0.963805 0.846372 -12.2% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001772 0.001597 -9.9% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.357989 0.341380 -4.6% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.416590 0.398594 -4.3% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.400185 0.360643 -9.9% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.401298 0.377014 -6.1% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.371018 0.344144 -7.2% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.434869 0.380685 -12.5% OK

@eldonm
eldonm merged commit e39036b into main Jul 31, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant