Adhoc components publish structured events through a pluggable IAdhocEventBus. The bus decouples
the engine from observability concerns: log lines, query lifecycle hooks, per-step progress probes,
and any custom listener a host application wants to wire — all flow through the same single-method
abstraction. This is still early work, but the shape is stable enough that downstream projects can
already plug their own bus implementation and consume events for tracing, metrics, audit, or live
plan views.
@FunctionalInterface
public interface IAdhocEventBus {
void post(Object event);
}That is the whole contract. Implementations are free to dispatch synchronously, asynchronously,
through Guava's EventBus, through greenrobot, into a Spring ApplicationEventPublisher, into a
test-side List<Object> collector — anything that consumes a published object.
The engine never type-checks the bus. The only convention is that every event Adhoc publishes
implements IAdhocEvent:
public interface IAdhocEvent {
String getFqdn();
IAdhocEvent withFqdn(String fqdn);
}fqdn is the fully-qualified class name the event should be attributed to when bridged to SLF4J;
see the FQDN section below for why this matters.
All events live under eu.solven.adhoc.eventbus.
| Event | When it fires | Carries |
|---|---|---|
AdhocLogEvent |
Anywhere Adhoc would have emitted a plain SLF4J log line. Has level, message, tags, source, plus debug / explain / performance flags. |
The full message body + log level. |
QueryLifecycleEvent |
Start / done milestones of a top-level query. | The IQueryPod, tags. |
AdhocQueryPhaseIsCompleted |
Each main phase of ICubeQueryEngine finishes. |
The phase name, source. |
QueryStepIsEvaluating |
The Cube DAG enters a given CubeQueryStep. |
The step, the source object. |
QueryStepIsCompleted |
The Cube DAG finishes a CubeQueryStep. |
The step, the number of cells produced. |
TableStepIsEvaluating |
The Table DAG starts a TableQueryV4. |
The table query, the source. |
TableStepIsCompleted |
The Table DAG finishes a TableQueryV4. |
The table query, the source. |
Together, the …IsEvaluating / …IsCompleted pairs give fine-grained tracking of progress through
the two DAGs the engine builds (see CubeQueryEngine). A bus subscriber can
mirror those events into a live plan view, a Prometheus histogram, or a flamegraph without the
engine knowing.
Because AdhocLogEvent is "I would have logged this", every Adhoc event needs to be forked to
SLF4J so logging keeps working for users who do not wire a custom bus. Instead of calling
eventBus.post(event) directly, use:
import eu.solven.adhoc.eventbus.AdhocEventBusHelpersUnsafe;
AdhocEventBusHelpersUnsafe.logForkEventBus(eventBus, event);This helper:
- Fills in the event's
fqdnif missing (defaulting to the helper itself — see below for the recommended override). - Writes the event to SLF4J via the
AdhocEventsFromGuavaEventBusToSfl4jbridge — so users with only a logging backend still see the message. - Posts the event on the bus — so users with a real subscriber receive the structured event too.
The "fork to SLF4J + bus" idiom replaces the older IAdhocEventsListener-based approach (one
typed listener method per event class). That design was dropped — the reasons are not fully
captured in commits, but it was cumbersome to set up in tests and host projects, and getting the
FQDN propagation right across each typed callback was error-prone. The single helper is what
production code uses today.
When you inject an IAdhocEventBus into an Adhoc component (typically
eu.solven.adhoc.engine.CubeQueryEngine#eventBus), wrap it first:
import eu.solven.adhoc.eventbus.AdhocEventBusHelpersUnsafe;
IAdhocEventBus rawBus = /* your Guava / greenrobot / Spring bus adapter */;
IAdhocEventBus safe = AdhocEventBusHelpersUnsafe.safeWrapper(rawBus);
CubeQueryEngine engine = CubeQueryEngine.builder()
.eventBus(safe)
// ...
.build();safeWrapper returns a WrappingEventBusForSlf4jFQDN decorator. On every post(...):
- If the event implements
IAdhocEvent, it stamps the wrapper's own class name as the event's FQDN and routes the event throughlogForkEventBus(...). SLF4J emits the line; the underlying bus receives the event. Both happen even if Adhoc internals already calledlogForkEventBus— the wrapper is idempotent in the sense that re-stamping the FQDN is safe and the SLF4J side simply emits one line. - If the event is not an
IAdhocEvent(third-party event riding on the same bus), it forwards unchanged.
So the practical rule is: wrap once at injection time, then forget about it. Adhoc-internal
code can call eventBus.post(...) directly and the bridging still happens.
When SLF4J prints the line, the message must be attributed to the originating Adhoc class, not
to the bus bridge. If a logback layout uses %logger, the attribution always points at the
technical logger declared inside the bridge — meaningless for users debugging which step of the
engine produced the line.
%class solves this. Combined with SLF4J's LocationAwareLogger.log(marker, fqcn, level, …)
overload — which the bridge calls — and Logback's LoggerContext#frameworkPackages, the resolved
class lookup skips the bridge frames and lands on the real caller.
Recommended layout pattern fragment:
%class{15}\(%method:%line\)
%class{15}— fully-qualified class name, abbreviated to ≤15 segments (matches the FQDN embedded in the event, not the technical logger).%method:%line— the method and source line inside that class.
Together they give "which Adhoc class, which method, which line emitted this" — independent of how many bridge frames sit between the caller and the SLF4J implementation.
The bridge also exposes a helper to register the bridge frames as framework packages so Logback skips them when computing the caller location:
import eu.solven.adhoc.eventbus.AdhocEventBusHelpersUnsafe;
import ch.qos.logback.classic.LoggerContext;
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
AdhocEventBusHelpersUnsafe.addToFrameworkPackages(context.getFrameworkPackages());This adds Guava's Subscriber / Dispatcher / EventBus, the JDK reflection frames, and the
AdhocEventBusHelpersUnsafe class itself to the framework-packages list. Without this step,
%class may still resolve to one of those intermediate frames depending on JDK version and
classloader topology.
Important caveat: %logger will NOT respect this — it is hard-wired to the SLF4J logger
instance and ignores the FQDN argument of LocationAwareLogger.log. Use %class and accept the
small extra runtime cost of stack-walk attribution.
The minimal pattern, with Guava's EventBus:
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import eu.solven.adhoc.eventbus.AdhocEventBusHelpersUnsafe;
import eu.solven.adhoc.eventbus.IAdhocEventBus;
import eu.solven.adhoc.eventbus.QueryLifecycleEvent;
import eu.solven.adhoc.eventbus.QueryStepIsCompleted;
EventBus raw = new EventBus("adhoc");
raw.register(new Object() {
@Subscribe
public void onLifecycle(QueryLifecycleEvent e) { /* … */ }
@Subscribe
public void onStepDone(QueryStepIsCompleted e) { /* … */ }
});
IAdhocEventBus adhocBus = AdhocEventBusHelpersUnsafe.safeWrapper(raw::post);
CubeQueryEngine engine = CubeQueryEngine.builder()
.eventBus(adhocBus)
// ...
.build();Three things to notice:
raw::postis a method reference —IAdhocEventBusis a functional interface so anyConsumer<Object>adapts trivially.- The
@Subscribemethods see real Adhoc event types, not log strings — that is the whole point of having an event bus rather than parsing log lines. - SLF4J still receives every
AdhocLogEventbecausesafeWrapperroutes throughlogForkEventBus. The custom subscriber adds on top of, not instead of, the log output.
- CubeQueryEngine — the two-DAG workflow whose progress these events expose.
- Debug / Investigations —
debug/explainquery options that turn on the performance-sensitiveAdhocLogEventinstances.