Repository navigation
AsyncAPI 3.x: track what the service consumes and emits over Kafka - #1875
Draft
LautaroPetaccio wants to merge 1 commit into
Draft
LautaroPetaccio wants to merge 1 commit into
LautaroPetaccio wants to merge 1 commit into
Conversation
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 12, 2026 02:54
6e5b3a5 to
ab3be6e
Compare
LautaroPetaccio
marked this pull request as draft
October 12, 2026 12:45
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 17, 2026 23:48
ab3be6e to
2dc2c92
Compare
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 26, 2026 13:17
2dc2c92 to
b67137e
Compare
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 26, 2026 14:23
b67137e to
b824ba0
Compare
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 26, 2026 18:40
b824ba0 to
3d26a80
Compare
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 27, 2026 00:32
3d26a80 to
a57baba
Compare
arcuri82
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 27, 2026 08:07
a57baba to
d100a14
Compare
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 27, 2026 16:12
d100a14 to
b4425a3
Compare
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 27, 2026 17:53
b4425a3 to
68665bc
Compare
A settle window can end early on coverage staying stable, but that says nothing about the message itself. With the service's Kafka client instrumented, the controller can see the message being taken, and see the thread that took it come back for more, which is what a consumer loop does once its handler has returned. This adds that tracking, the messages the service emits as a by-product, and a completion strategy built on it. - Method replacements for poll(Duration) on KafkaConsumer and on the Consumer interface, and for send(record) and send(record, callback) on KafkaProducer and on the Producer interface. Replacements match the class a call is compiled against, so both are needed: NCS declares the concrete classes, Spring Kafka calls the interfaces. kafka-clients is only a test dependency; records are read by reflection. They are in a new KAFKA category, behind --instrumentMR_KAFKA, experimental and off by default. - poll(long), removed in kafka-clients 4.0 (checked against the 4.0.0 jar), is in classes of its own: every replacement of a class is resolved against the library on first use, and a missing method fails all of them. ReplacementList asserted at most one replacement class per target for constructor lookups; it asserts at most one that replaces constructors, and the visitor takes the consume method from the class of the chosen replacement. - A send made from inside another replaced send on the same thread is not recorded again. That covers Spring's CloseSafeProducer, which sends each record twice through Producer, and KafkaProducer.send (record) calling send(record, null) on itself. A send a callback or a custom producer makes from inside another send, on the same thread, is not recorded either. - ExecutionTracer.setExecutingDriverAction, set by EMController around executeAsyncApiAction, on that thread only. Calls made on that thread while it is set are not recorded. A driver class loaded once the agent is in place and the replacement categories are set gets the replacements too, and a package filter cannot tell it from the service, as they can share a prefix. With NCS's driver loaded that way and the flag not set, its publishes are recorded as messages the service emitted. Calls a driver makes on threads it starts itself are recorded as the service's; docs/write_driver.md says so. - AdditionalInfo records, per action, the messages emitted: address, payload as text, cut at 4096 characters and never inside a surrogate pair, whether it was cut, and the payload's class, 100 per action at most. A String is recorded as given and bytes as UTF-8; any other object, boxed primitives included, is kept as its class name, as its bytes are up to the serializer and calling toString would run SUT code. The text is what goes on the wire only with StringSerializer or ByteArraySerializer. The messages reach the core through AdditionalInfoDto.emittedMessages, and AsyncApiFitness keeps them on each action's AsyncApiCallResult, payload cut at 1024 characters, in white-box mode only. - AsyncMessageTracker, reset with ExecutionTracer, keeps per thread the batches a poll returned; a batch counts as handled once the same thread polls again with a timeout. A message received again is unhandled again, and a batch read into a test that is already over is dropped. - HANDLER_RETURN, appended to --asyncApiCompletion and to AsyncApiActionDto.Completion, with SettleEnd.HANDLER_RETURN: the window ends once something was polled from the published address in this action and all of it was handled. It needs --asyncApiSettleMs and --instrumentMR_KAFKA. External drivers read the tracker through a new MESSAGE_HANDLING_STATE agent command. A state that cannot be read, without the agent or when the agent fails to answer, means the rest of the window. Worth flagging for review, as this is a heuristic with blind spots: - Work handed off to another thread looks done as soon as it is handed off, since the receiving thread polls again at once. - A poll with a zero timeout does not count as the handler having returned. Spring Kafka polls that way to keep the consumer alive while it retries a failed batch. A loop that only polls with a zero timeout therefore never looks done, and waits for the deadline. - A failed record that an error handler seeks back to reads as handled in the moment between the failed attempt and the next poll's return. When it comes back it is unhandled again. - What was polled in the current action counts, so a message left over from an earlier action and consumed now counts too. - A thread that closes its consumer without polling again leaves its last batch unhandled, and the wait goes to the deadline. Two E2Es run with HANDLER_RETURN: NcsKafkaEMTest, where the service polls a KafkaConsumer and answers with a KafkaProducer from its own code, and ShopKafkaHandlerReturnEMTest, where Spring Kafka's listener containers poll through the Consumer interface. Every wait ends on the handler returning, none on the deadline, and the string branches reached by taint in the synchronous listeners are still covered. The Shop listener that hands rendering off to an executor is the blind spot above, and is not asserted on. It is a class of its own because the Kafka replacements have to be asked for when the service is started. No behaviour changes unless --instrumentMR_KAFKA or HANDLER_RETURN is set. No shared REST code is touched.
LautaroPetaccio
force-pushed
the
feature/asyncapi-kafka-tracking
branch
from
October 27, 2026 20:12
68665bc to
369e5b2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eighth in the AsyncAPI white-box stack, on top of #1874. With the service's Kafka client instrumented, EvoMaster sees what the service consumes and emits, and a settle window can end when the service is done with the message, rather than when its coverage stays stable.
Everything new is behind two experimental options, both off by default:
--instrumentMR_KAFKAand--asyncApiCompletion HANDLER_RETURN.What it adds
KafkaConsumerClassReplacement,KafkaConsumerInterfaceClassReplacementpoll(Duration)onKafkaConsumerand on theConsumerinterface.KafkaConsumerLegacyPollClassReplacement,KafkaConsumerInterfaceLegacyPollClassReplacementpoll(long), in classes of their own (see below).KafkaProducerClassReplacement,KafkaProducerInterfaceClassReplacementsend(record)andsend(record, callback)onKafkaProducerand on theProducerinterface.KafkaReplacementHelperReplacementCategory.KAFKA,--instrumentMR_KAFKAmethodReplacementCategories().EmittedMessage/EmittedMessageDtoAdditionalInfo/AdditionalInfoDtoemittedMessagesper action, copied inEMController.computeTestResults.AsyncApiCallResult.getEmittedMessages()AsyncApiFitnesskeeps on each action's result what the service emitted while it was handled, in white-box mode only: address, payload cut at 1024 characters, whether it was cut, and the payload's class, as results are kept for every action in the archive. This is the surface the next PR turns into targets and assertions.AsyncMessageTrackerExecutionTracer; a batch read into a test that is already over is dropped.MessageHandlingStateExecutionTracer.setExecutingDriverActionEMControllersets aroundexecuteAsyncApiAction; calls made on that thread while it is set are not recorded.HANDLER_RETURN--asyncApiCompletion,AsyncApiActionDto.MessageCompletionStrategyandAsyncApiReplyDto.SettleEndReason.AsyncApiSettleends the window once something was polled from the published address in this action and all of it is handled, capped by--asyncApiSettleMs.SutController.getMessageHandlingState/Command.MESSAGE_HANDLING_STATEOBJECTIVE_UPDATE_COUNTworks, null included when the agent fails to read it. A state that cannot be read means the rest of the window.Things that are less obvious than they look
KafkaConsumer/KafkaProducerfields; Spring Kafka's listener container polls aConsumerandKafkaTemplatesends through aProducer. Library code is instrumented too, so the interface replacements fire inside Spring.poll(long)is in classes of its own.initMethodsresolves every replacement of a class the first time one is used, and a missing method breaks all of them.poll(long)is gone from kafka-clients 4.0 (checked against the 4.0.0 jar withjavap;poll(Duration)and bothsendsignatures are still there). Next topoll(Duration), it would break every poll on 4.x.CloseSafeProducersends every record twice throughProducer.send(once fromKafkaTemplate, once to its delegate), andKafkaProducer.send(record)callssend(record, null)on itself. A send made from inside another replaced send on the same thread is not recorded again.KafkaClientsTestchecks both through the instrumenting class loader, and both fail without the guard. The price: a send that a callback or a custom producer makes from inside another send, on the same thread, is not recorded either.executeAsyncApiAction, while it runs: calls a driver makes on threads it starts itself, such as a consumer it keeps polling in the background, are recorded as the service's, anddocs/write_driver.mdsays so. Being per thread, it leaves the service's consumer threads recorded while the driver publishes.Longis 8 binary bytes withLongSerializer), and callingtoStringwould run SUT code from inside the replacement. The text is what goes on the wire only withStringSerializerorByteArraySerializer: spring-kafka'sJsonSerializer, for one, writes a String quoted.The completion is a heuristic, and these are its blind spots
What this changes outside AsyncAPI
ReplacementList.getReplacementsasserted that a constructor lookup finds at most one replacement class per target. It asserts at most one class that replaces constructors instead, since the twoKafkaConsumerclasses replace none.MethodReplacementMethodVisitortakes the consume method from the class of the chosen replacement instead of the first candidate. For every existing replacement there is exactly one class per target, so nothing changes for them. Happy to split that into its own PR if you would rather.ExecutionTracer.reset()also resetsAsyncMessageTracker, after releasing its own lock.ExecutionTracer.getActionIndex()is new.client-java/instrumentation/pom.xml: kafka-clients in test scope (it already came through spring-kafka; the version is managed in the root pom).docs/write_driver.md: one paragraph on what--instrumentMR_KAFKArecords, that the driver's calls on the thread runningexecuteAsyncApiActionare not taken for the service's, and that calls on threads it starts itself can be.docs/options.mdregenerated.Not here
KafkaProducerby the configured serializer, so a payload sent as an object is recorded as its class name.Testing
AsyncMessageTrackerTest(new): 13 tests. Handled on the same thread's next waiting poll, not on a zero-timeout poll nor on another thread's; batches per thread, per address and per action; a message received again is unhandled again; a batch read into a test that is over is dropped; reset.KafkaConsumerInterfaceClassReplacementTest(8),KafkaConsumerInterfaceLegacyPollClassReplacementTest(3), against kafka-clients'MockConsumer: records reach the caller, tracker state, zero timeout, driver flag, exceptions go through unchanged, a record polled again is unhandled again.KafkaConsumerClassReplacementTest(2),KafkaConsumerLegacyPollClassReplacementTest(2), against a realKafkaConsumerwith no broker: an unsubscribed consumer fails before connecting, which shows the original is reached and the previous batch is handled.KafkaProducerInterfaceClassReplacementTest(14) againstMockProducer,KafkaProducerClassReplacementTest(3) against a realKafkaProducerwith no broker: what is recorded, callbacks, bytes, only a prefix of long bytes decoded, boxed primitives and object payloads never turned into text, truncation, the per-action bound, driver flag, a send that throws, a wrapping producer recorded once.KafkaClientsTest(new, 6 tests): the replacements applied by the instrumenting class loader to SUT code declaring the classes and the interfaces,poll(long),KafkaProducercalling itself andKafkaTemplateoverCloseSafeProducereach recorded once, nothing tracked without the category.EmittedMessageTest(6: the cut, never inside a surrogate pair),MessageHandlingStateTest(2),ExecutionTracerTest(+2: flag per thread, recording),ServerControllerTest(+2: the new command, and a question the agent fails to answer gets null),ReplacementListTest(+1).client-java/instrumentation: 838 tests, 0 failures, 1 skipped (TestabilityExcInstrumentedTest, already disabled).AsyncApiSettleTest(+11): handled at once, after a while, nothing received, never handled, state or address unreadable, a state that fails to be read, the address asked is the published one, other completions never read it, interrupt.EMControllerTest(+6): the driver runs flagged and the flag is cleared after a failure, a wait ended by a consumer thread polling again through the real tracker, the deadline when nothing is consumed, the whole window without the agent, emitted messages in the test results.client-java/controller: 1514 tests, 0 failures, 2 skipped.RedisHeuristicsCalculatorTest.testKeysPatternNoMatchis flaky, and does not touch anything changed here.EMConfigTest(+2: HANDLER_RETURN needs a settle window and the Kafka instrumentation; the KAFKA category only when asked for),AsyncApiFitnessTest(+4: HANDLER_RETURN sent, emitted messages kept per action and through a copy, fewer entries than actions, none in black-box),AsyncApiCallResultTest(new, 5: what a result keeps of an emitted message, a payload cut at 1024 characters and flagged, never inside a surrogate pair, the type of a payload with no text).AsyncApi*Test,ConfigToMarkdownTestandEMConfigTest: 296 tests, 0 failures.NcsKafkaEMTest(+1,testRunEMWithHandlerReturn): a 5 s window ends onHANDLER_RETURNevery time and never on the deadline, every reply still arrives,bessjandcheckTrianglestill reach their results, and every emitted message recorded is on a reply topic. The whole class: 6 tests, 0 failures.ShopKafkaHandlerReturnEMTest(new, inspring-asyncapi-kafka-listener): the Shop service's three@KafkaListeners, whose containers poll throughConsumer. Every operation has waits that end onHANDLER_RETURN, none goes to the 5 s deadline,ExpressDispatchandUrgentEscalationare still reached by taint (the listener compares the strings before it returns), and nothing is recorded as emitted, as the service publishes nothing (it has noKafkaTemplateor producer). The invoice rendered on an executor is not asserted on. It is a class of its own because the Kafka replacements are only applied to classes loaded after they are asked for, and the service, Spring's containers included, is started once ininitClass; the module runs each class in a JVM of its own, soShopKafkaEMTestis unchanged. The whole module: 3 tests, 0 failures.