Skip to content

fix(context,cli) + feat(eda-kafka)!: the two wave-6 asks LaraFly owns - #3

Merged
ancongui merged 3 commits into
mainfrom
fix/wave6-asks
Sep 24, 2026
Merged

ancongui merged 3 commits into
mainfrom
fix/wave6-asks

Conversation

@ancongui

@ancongui ancongui commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes the two items the sixth wave's findings leave open on LaraFly's side. They are unrelated defects found by the same application, and they ship together because the CHANGELOG habit accumulates under [Unreleased] and one tag cuts every package.


1. L2b — a manifest naming a deleted class stops the two commands that repair it

What a developer hits today

Delete a #[Component], forget to recompile, and bootstrap/cache/firefly/component.php still names a class no autoloader can find. That state was unrecoverable by any single command.

EagerSingletonsPass has skipped a definition whose class no longer exists since 26.09.1, and it was not enough, because the guard protects the one pass it is written in. The deleted class was still in the manifest ContainerRegistrar received, so wireInterfaces() bound the interface it implemented — and tagged it as an implementation — to a class that was not there. The throw came out of make() for a perfectly live abstract, while resolving a bean nobody had touched, and named a file the developer had already deleted.

Three more places read a class straight off the same manifest with nothing between them and make():

Where Phase
RegisterBeanPostProcessorsPass 700 — before eager singletons
InfrastructureStartPass InfrastructureStart
RegisterEventListenersPass 800 — throws on the first dispatch, not at boot

composer dump-autoload could not recover it either (package:discover boots the application too), so the only way out was rm bootstrap/cache/firefly/*.php, then composer dump-autoload, then firefly:cache, in that order.

The fix

The check moves to the one door every definition comes through. While firefly:cache or firefly:clear is the running command — AppScan::repairing(), deliberately wider than regenerating() and kept separate from it — BeanDefinitionRegistry::add() drops a definition whose class cannot be found and records it in a StaleDefinitionReport. The registrar and all four passes then see a manifest that agrees with what is on disk.

firefly:cache prints one extra line:

firefly:cache — wrote 14 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider

The manifest it writes no longer mentions the class, so the next run is an ordinary clean one and the line goes away.

What does NOT change, and why

Under every other command the manifest is trusted exactly as before, and a missing class still stops the boot with the container's own error. A class that has gone missing in a process about to serve traffic is not a stale cache — it is a broken deployment, a truncated artifact, a classmap built from a different tree — and dropping the definition there would hand the application an interface quietly rebound to whichever implementation happened to survive, with nothing said anywhere. A wrong answer nobody is told about is worse than the boot failure being removed.

Only a MISSING class is ever tolerated. A class that exists and cannot be constructed still fails fast, in a repair command as much as anywhere else.

BeanDefinitionRegistry's new constructor arguments both default to the previous behaviour, so an existing new BeanDefinitionRegistry filters nothing and reports nothing. No psr/log edge is added: the report is shaped like ConditionEvaluationReport and bound on the container, which is what lets firefly/cli read it.

Tests

  • packages/context/tests/Definition/StaleDefinitionFilterTest.php — the door itself, including the default that keeps a stale definition under any other command, and that a dropped entry never reaches toComponentManifest().
  • packages/context/tests/Pass/StaleManifestWiringTest.php — the four consumer paths run the way boot runs them, plus two controls: a boot that is not repairing still throws Target class [...] does not exist, and a class that exists and cannot be built still fails fast inside a repair boot.
  • packages/cli/tests/Cache/StaleManifestRecoveryTest.php — end to end. A real source tree whose manifests are compiled by a subprocess (every scanner class_exists()es, and PHP never forgets a declared class, so an in-process compile would make the test pass for entirely the wrong reason), the #[Primary] implementation then deleted from disk; firefly:cache exits 0 and names the entry, the rewritten manifest no longer carries it, firefly:clear boots past the same manifest, and the same manifest still fails loudly under migrate.
  • packages/context/tests/Scan/AppScanTest.php — repairing() covers both commands and regenerating() still covers only firefly:cache.

2. L13 — LaraFly wrote a dead-letter record with no headers, and PyFly writes three

BREAKING

KafkaConsumerClient::deadLetter() now takes the whole ReceivedEnvelope and a reason; deadLetterRaw() is gone.

dworkers runs LaraFly and PyFly against shared topics, so one <topic>.DLT held two kinds of record: PyFly's, which says why it died and where it came from, and LaraFly's, which was raw bytes with no provenance whatsoever. Whoever drained that topic could not tell a LaraFly poison record from a replayed payload, and the offset needed to go back and look at the original was not there. grep -rn 'x-dlt' packages/ returned nothing.

The port had two dead-letter methods and neither was handed the provenance it would have needed. They are now one, because the record already carries the bytes or the envelope, the topic it was read from, and the broker handle its offset hangs off — the only thing the consumer layer owes the DLT is why.

Migration: an application implementing KafkaConsumerClient itself (a test double, or a client over a different Kafka extension) replaces its two methods with the one. An application that merely uses the adapter sees no API change, only three headers it did not have.

The headers

Header Value
x-dlt-reason the short class name of the throw that refused the bytes (PyFly's type(exc).__name__), or RetriesExhausted for a record that decoded and then ran out of retries
x-dlt-source-topic the topic the record was consumed from — not the DLT, not the envelope's declared destination
x-dlt-source-offset the offset it sat at

Spelled exactly as PyFly spells them (src/pyfly/eda/adapters/kafka.py), because a topic both frameworks publish to is only readable if one kcat -C -t <topic>.DLT -f '%h' explains every record on it. A header the record cannot answer is left out rather than written empty — an x-dlt-source-offset of '' reads as an offset. producev() rather than produce(), which cannot carry headers; it has been in ext-rdkafka since 3.1, well below the librdkafka >= 1.5.3 this package already suggests. The poison path still re-produces the raw bytes verbatim.

RabbitMQ needs none of this and gets none. The broker itself stamps x-death — source queue, exchange, reason and count — on everything its x-dead-letter-exchange routes, and the framework never republishes a message there to have an opinion about. Postgres keeps the row.

Tests

dltHeaders() and dltPayload() are public and static for the same reason received() is: they are the halves of deadLetter() that owe nothing to ext-rdkafka, so the header set, the omission of an unanswerable header and the raw-bytes-verbatim rule are all unit-asserted with no extension installed. KafkaEventConsumerTest pins both reasons through the consumer. The ext+broker-gated KafkaRoundTripTest reads the three headers back off the real DLT record, which is the only place producev() is exercised at all.


Gates, locally

composer check (pint, phpstan level max, pest, deptrac), composer mono-validate, .venv-docs/bin/mkdocs build --strict, python3 book/build/verify_code.py, bash scripts/check-no-sensitive-tracked.sh — all green: 3805 passed, 6 skipped, 0 phpstan errors, 0 deptrac violations.

Docs

docs/cli.md and chapter 13 of both manuscripts carry the stale-manifest recovery, the console line and the reason only two commands drop anything. docs/modules/eda-brokers.md and the firefly/eda-kafka front page carry the three headers and why RabbitMQ does not get them. The CacheCommand listing under the provenance guard is re-quoted from the file, and tests/DocsProseIsRealTest.php's console-line canary moves 7 → 10 for the three new blocks.

Andres Contreras added 2 commits September 24, 2026 12:45
…he two commands that repair it, and firefly:cache says what it dropped

EagerSingletonsPass has skipped a definition whose class no longer exists since
26.09.1, and it was not enough: the guard protects the one pass it is written
in. The deleted class was still in the manifest ContainerRegistrar received, so
wireInterfaces() bound the interface it implemented — and tagged it as an
implementation — to a class autoloading could not find. The throw therefore came
out of make() for a perfectly live abstract while resolving a bean nobody had
touched, and named a file the developer had already deleted. Three more places
read a class straight off the same manifest with nothing between them and
make(): RegisterBeanPostProcessorsPass at phase 700 (before eager singletons),
InfrastructureStartPass, and RegisterEventListenersPass, whose listener closure
throws on the first dispatch rather than at boot. composer dump-autoload could
not recover it either, because package:discover boots the application too, so
the only way out was rm bootstrap/cache/firefly/*.php, then composer
dump-autoload, then firefly:cache, in that order.

The check now lives at the one door every definition comes through. While
firefly:cache or firefly:clear is the running command — AppScan::repairing(),
deliberately wider than regenerating() and kept separate from it —
BeanDefinitionRegistry::add() drops a definition whose class cannot be found and
records it in a StaleDefinitionReport, so the registrar and all four passes see
a manifest that agrees with what is on disk. CacheCommand prints one line naming
what was dropped; the manifest it writes no longer mentions the class, so the
next run is an ordinary clean one.

Under every other command nothing changes, and that asymmetry is the more
important half. A class that has gone missing in a process about to serve
traffic is a broken deployment, not a stale cache, and dropping the definition
there would hand the application an interface quietly rebound to whichever
implementation survived, with nothing said anywhere. Only a MISSING class is
ever tolerated: a class that exists and cannot be constructed still fails fast.

The registry's new constructor arguments both default to the previous behaviour,
so an existing `new BeanDefinitionRegistry` filters nothing and reports nothing.

Tests. StaleDefinitionFilterTest pins the door itself, including the default
that keeps a stale definition under any other command. StaleManifestWiringTest
runs the four consumer paths the way boot runs them and carries two controls: a
boot that is not repairing still throws "Target class [...] does not exist", and
a class that exists and cannot be built still fails fast in a repair boot.
StaleManifestRecoveryTest is the end-to-end case — a real source tree whose
manifests are compiled by a SUBPROCESS (a scanner class_exists()es, and PHP
never forgets a declared class, so an in-process compile would make the test
pass for the wrong reason), the #[Primary] implementation then deleted from
disk, firefly:cache exiting 0 and naming the entry, firefly:clear booting past
the same manifest, and the same manifest still failing loudly under migrate.

Docs. docs/cli.md and chapter 13 of both manuscripts carry the recovery, the
console line it prints and the reason only these two commands drop anything;
the CacheCommand listing under the provenance guard is re-quoted from the file.
…ree provenance headers PyFly writes

BREAKING: KafkaConsumerClient::deadLetter() takes the whole ReceivedEnvelope
and a reason; deadLetterRaw() is gone.

LaraFly wrote a dead-letter record with no headers at all, and PyFly writes
three. dworkers runs both frameworks against shared topics, so one <topic>.DLT
held two kinds of record: PyFly's, which says why it died and where it came
from, and LaraFly's, which was raw bytes with no provenance whatsoever. Whoever
drained that topic could not tell a LaraFly poison record from a replayed
payload, and the offset needed to go back and look at the original was not there
to be read. grep -rn 'x-dlt' packages/ returned nothing.

The port had two dead-letter methods — one taking an EventEnvelope for an
exhausted retry, one taking raw bytes for a poison record — and neither was
handed the provenance it would have needed. They are now one method taking the
record itself, because the record already carries the bytes or the envelope, the
topic it was read from, and the broker handle its offset hangs off; the only
thing this layer owes the DLT is WHY, which is the third argument.

RdKafkaConsumerClient stamps x-dlt-reason, x-dlt-source-topic and
x-dlt-source-offset, spelled exactly as PyFly spells them
(src/pyfly/eda/adapters/kafka.py), because a topic both frameworks publish to is
only readable if one `kcat -C -t <topic>.DLT -f '%h'` explains every record on
it. The reason is the throw's short class name — PyFly's type(exc).__name__ —
or RetriesExhausted for a record that decoded perfectly well and then ran out of
retries, a case PyFly does not have because it deliberately does not dead-letter
handler failures. A header the record cannot answer is left out rather than
written empty: an x-dlt-source-offset of '' reads as an offset. producev()
rather than produce(), which cannot carry headers; it has been in ext-rdkafka
since 3.1, well below the librdkafka >= 1.5.3 this package already suggests.

RabbitMQ needs none of this and gets none. The broker itself stamps x-death —
source queue, exchange, reason and count — on everything its
x-dead-letter-exchange routes, and the framework never republishes a message
there to have an opinion about. Postgres keeps the row.

Tests. dltHeaders() and dltPayload() are public and static for the same reason
received() is: they are the halves of deadLetter() that owe nothing to
ext-rdkafka, so the header set, the omission of a header the record cannot
answer, and the raw-bytes-verbatim rule are all unit-asserted with no extension
installed. KafkaEventConsumerTest pins both reasons through the consumer. The
ext+broker-gated KafkaRoundTripTest reads the three headers back off the real
DLT record, which is the only place producev() is exercised at all.
@ancongui ancongui changed the title fix(context,cli): a manifest naming a deleted class no longer stops the two commands that repair it fix(context,cli) + feat(eda-kafka)!: the two wave-6 asks LaraFly owns Sep 24, 2026
… made its own escape-hatch claim true

The guard's comment has always said firefly:cache and firefly:clear are the two
commands its skip restores, and the claim was only ever true of the pass it is
written in: a deleted #[Component] that implemented an interface breaks the boot
in ContainerRegistrar instead, and the throw comes out of a bean that still
exists. Say where the check actually lives now, and what this one is still for.
@ancongui
ancongui merged commit 19a57c7 into main Sep 24, 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