Skip to content

Latest commit

 

History

History
287 lines (198 loc) · 15.2 KB

File metadata and controls

287 lines (198 loc) · 15.2 KB
layout default
title v1.4.0
description Release notes for Neatoo RemoteFactory v1.4.0
parent Release Notes
nav_order 2

v1.4.0 — Factory Event Relay Redesign + Post-Return Ordering Fix

Release Date: 2026-04-14 NuGet: Neatoo.RemoteFactory 1.4.0 Breaking changes: Yes — IFactoryEventRelay surface replaced, [FactoryEventHandler<T>] instance-method handlers no longer generated. See Migration Guide below.

Pre-stable API note. v1.4.0 is an intentional minor bump carrying breaking changes. The 1.x line is still stabilizing the factory-event and relay surfaces; breaking changes remain permitted under minor bumps until the surface settles. A major bump will be reserved for stability commitments.


Overview

Client-side factory event delivery is redesigned around a single integration hook: IFactoryEventRelay.Relay(IReadOnlyList<FactoryEventBase> events). The prior Register/Unregister weak-ref machinery, the FactoryEventRelayRegistry static, the internal FactoryEventRelayDispatcher, and the source-generated instance-method [FactoryEventHandler<T>] client-relay emission path are all removed. A NoOpFactoryEventRelay is registered automatically in NeatooFactory.Remote mode so consumers who do not need events can ignore the hook entirely.

This release also fixes a timing bug: in v1.3.0 and earlier, _ = _relay.DispatchRelayedEvents(...) ran the relay's synchronous prologue before return deserialized; executed, so handlers invoked during _entity = await factory.Create(...) could observe the caller's pre-assignment state. v1.4.0 guarantees Relay is invoked strictly after the factory method's return value reaches the caller's continuation.

IL trimming preservation for event records moves from per-handler generator codegen to a single [DynamicallyAccessedMembers] annotation on FactoryEventBase with Inherited = true. Every descendant is preserved by base-class inheritance — no per-event registration is required.


What's New

IFactoryEventRelay.Relay(IReadOnlyList<FactoryEventBase>) — single integration hook

Consumers implement one method. RemoteFactory invokes it once per [Remote] factory call (even when the batch is empty), fire-and-forget, strictly after the caller's continuation resumes. Exceptions thrown from Relay are caught and logged, not propagated.

public interface IFactoryEventRelay
{
    // Called once per [Remote] factory call, after the caller's continuation resumes.
    // Exceptions are caught and logged; they do not propagate to the factory caller.
    // Consumer owns threading / sync-context marshaling inside this method.
    Task Relay(IReadOnlyList<FactoryEventBase> events);
}

A minimal bridge to a consumer's event aggregator:

public sealed class MyEventAggregatorRelay : IFactoryEventRelay
{
    private readonly IEventAggregator _aggregator;

    public MyEventAggregatorRelay(IEventAggregator aggregator) => _aggregator = aggregator;

    public Task Relay(IReadOnlyList<FactoryEventBase> events)
    {
        foreach (var evt in events)
        {
            _aggregator.Publish(evt); // dispatch per your aggregator's contract
        }
        return Task.CompletedTask;
    }
}

RemoteFactory does not ship a MediatR / event-aggregator bridge. The bridge is consumer-owned — a deliberate narrowing of the library's surface.

NoOpFactoryEventRelay default in Remote mode

AddNeatooRemoteFactory(NeatooFactory.Remote) registers NoOpFactoryEventRelay via TryAdd. Consumers that do not care about relayed events need do nothing. Registering a custom IFactoryEventRelay — before or after AddNeatooRemoteFactory — replaces the no-op via standard DI override semantics.

IFactoryEventRelay is NOT registered in NeatooFactory.Server or NeatooFactory.Logical modes (server never relays to itself; Logical has no serialization boundary and dispatches via the handler registry directly).

Post-return ordering guarantee

IFactoryEventRelay.Relay is now guaranteed to be invoked strictly after the factory method's return value has reached the caller. The dispatch site uses Task.Run + await Task.Yield() to push relay work behind the caller's continuation, fixing this class of bug:

// Before v1.4.0: Relay could run (at least its sync prologue) before this assignment completed.
_entity = await factory.Create(...);

// After v1.4.0: Relay runs strictly after _entity has been assigned and the continuation has resumed.

One [Remote] call = exactly one Relay invocation. Even when no events were raised, Relay is called with an empty IReadOnlyList<FactoryEventBase>. Consumers can use the empty-batch invocation as a signal that a factory call just returned — useful for batch-end bookkeeping or UI refresh triggers.

[FactoryEvent] attribute with Inherited = true on FactoryEventBase

FactoryEventAttribute is applied once to FactoryEventBase. Via Inherited = true, every descendant is discoverable at runtime through GetCustomAttribute<FactoryEventAttribute>(inherit: true). Consumers inherit from FactoryEventBase as before — no opt-in per event type.

Trimming preservation via base-class annotation

FactoryEventBase now carries [DynamicallyAccessedMembers(PublicConstructors | PublicProperties)] with Inherited = true. Every descendant's constructors and properties survive PublishTrimmed=true without generator involvement. This supersedes the v1.2.0 per-handler DtoConstructorRegistry.PreserveType<T>() emission path for event records — one annotation on the base, instead of one registration call per handler.

A PublishTrimmed smoke test in RemoteFactory.TrimmingTests verifies the round-trip on a trimmed build.

Runtime FactoryEventTypeRegistry

Internal, runtime-populated, lazily scans AppDomain.CurrentDomain.GetAssemblies() on first use for non-abstract FactoryEventBase descendants and caches TypeFullName → Type. No source generation, no consumer configuration. On a TypeFullName miss the scan re-runs once (to pick up dynamically-loaded assemblies) before the deserializer throws UnknownFactoryEventTypeException.

UnknownFactoryEventTypeException (public)

Thrown by FactoryEventDeserializer when a relayed event's TypeFullName is not loaded on the client. Caught by the relay dispatch isolation and logged; Relay is not invoked for that call, and the factory caller's await is unaffected.

New log events

EventId Name Emitted when
3008 FactoryEventRelayFailed IFactoryEventRelay.Relay throws
3009 FactoryEventDeserializationFailed UnknownFactoryEventTypeException during batch deserialization
3011 NoOpFactoryEventRelayFirstEvent First event arrives at the no-op default (hint to the consumer that they likely meant to register a real relay)
3012 FactoryEventTypeRegistryCollision Two loaded assemblies expose a FactoryEventBase descendant with the same TypeFullName

New diagnostic: NF0503 (Warning)

Instance-method [FactoryEventHandler<T>] handlers — the former client-relay shape — are no longer generated. The generator emits NF0503 as a Warning on the instance method, guiding the consumer to either make the method static (server handler, unchanged behavior) or implement IFactoryEventRelay (client receiver). This replaces the silent-skip behavior with a compile-time signal without breaking existing builds.


Breaking Changes

Removed: IFactoryEventRelay.Register / Unregister

Both methods are gone. Replaced by Task Relay(IReadOnlyList<FactoryEventBase> events). Any consumer code calling _relay.Register(this) or _relay.Unregister(this) is now a compile error.

Removed: FactoryEventRelayRegistry

The public static FactoryEventRelayRegistry is deleted. Its runtime role is taken by an internal FactoryEventTypeRegistry; there is no public replacement because consumers no longer register event types — inheriting FactoryEventBase is sufficient.

Removed: FactoryEventRelayDispatcher

Internal dispatcher type is deleted. Nothing to migrate — it was never part of the documented surface.

Instance-method [FactoryEventHandler<T>] — no longer generated

Classes decorated with [FactoryEventHandler<T>] whose handler is an instance method no longer produce any generated code. The generator emits NF0503 (Warning) pointing at the instance method. The handler is never invoked at runtime. This is the silent-failure footgun to watch for during migration: handlers that used to fire no longer fire, and only the NF0503 warning flags it.

Server-side static-method [FactoryEventHandler<T>] handlers are unchanged — dispatched via FactoryEventHandlerRegistry during server-side factory execution exactly as before.


Migration Guide

Replace instance-method handlers with IFactoryEventRelay

Before (v1.3.0 and earlier):

[FactoryEventHandler<OrderCheckoutCompleted>]
[FactoryEventHandler<OrderShipped>]
public class OrderEventsClientHandler
{
    private readonly IFactoryEventRelay _relay;

    public OrderEventsClientHandler(IFactoryEventRelay relay)
    {
        _relay = relay;
        _relay.Register(this); // removed in v1.4.0
    }

    public void Dispose() => _relay.Unregister(this); // removed in v1.4.0

    internal Task Handle(OrderCheckoutCompleted evt) { /* ... */ return Task.CompletedTask; }
    internal Task Handle(OrderShipped evt)           { /* ... */ return Task.CompletedTask; }
}

After (v1.4.0):

public sealed class OrderEventsClientRelay : IFactoryEventRelay
{
    public Task Relay(IReadOnlyList<FactoryEventBase> events)
    {
        foreach (var evt in events)
        {
            switch (evt)
            {
                case OrderCheckoutCompleted completed: Handle(completed); break;
                case OrderShipped shipped:             Handle(shipped);   break;
            }
        }
        return Task.CompletedTask;
    }

    private void Handle(OrderCheckoutCompleted evt) { /* ... */ }
    private void Handle(OrderShipped evt)           { /* ... */ }
}

Notes:

  • No Register/Unregister calls. Lifetime is managed by the DI container.
  • Type switching replaces attribute-directed dispatch. If the number of event types is large, consider delegating to your event aggregator and letting it route.
  • Server-side static-method [FactoryEventHandler<T>] is unaffected. Only the client-receiver shape changes.

Register the relay in DI

// Consumer wins regardless of order — AddNeatooRemoteFactory uses TryAdd for the no-op default.
services.AddSingleton<IFactoryEventRelay, OrderEventsClientRelay>();
services.AddNeatooRemoteFactory(NeatooFactory.Remote);

// Or after — standard DI override semantics apply:
services.AddNeatooRemoteFactory(NeatooFactory.Remote);
services.AddSingleton<IFactoryEventRelay, OrderEventsClientRelay>();

If your app does not consume relayed events at all, register nothing — NoOpFactoryEventRelay is resolved automatically.

Bridging to an event aggregator / MediatR

RemoteFactory does not ship a bridge. Wire one yourself inside your IFactoryEventRelay implementation:

public sealed class MediatrFactoryEventRelay : IFactoryEventRelay
{
    private readonly IMediator _mediator;
    public MediatrFactoryEventRelay(IMediator mediator) => _mediator = mediator;

    public async Task Relay(IReadOnlyList<FactoryEventBase> events)
    {
        foreach (var evt in events)
        {
            await _mediator.Publish(evt);
        }
    }
}

React to the timing fix

If any caller code had worked around the v1.3.0 timing bug by deferring event-dependent reads with Task.Yield() or Task.Delay(1), those workarounds can be removed — the ordering guarantee is now part of the framework.

// Before — workaround for pre-v1.4.0 timing bug
_entity = await factory.Create(...);
await Task.Yield(); // no longer needed
UseEntity(_entity);

// After
_entity = await factory.Create(...);
UseEntity(_entity); // Relay runs strictly after this point

Handling the empty-batch invocation

Relay is called once per [Remote] call, even when no events were raised. Consumers that care only about non-empty batches can short-circuit:

public Task Relay(IReadOnlyList<FactoryEventBase> events)
{
    if (events.Count == 0) return Task.CompletedTask;
    // ... normal dispatch
}

Consumers that want a "factory call just returned" signal can use the empty batch as the trigger.

Remove the old skill / doc references

Any docs or helper code that referenced IFactoryEventRelay.Register(handler), FactoryEventRelayRegistry, or instance-method [FactoryEventHandler<T>] client handlers is now stale. See the updated Factory Events, Events, Interfaces Reference, Attributes Reference, and IL Trimming pages.


Bug Fixes

Post-return ordering bug in client-side event relay

Prior to v1.4.0, MakeRemoteDelegateRequest.ForDelegate invoked the relay via _ = _relay.DispatchRelayedEvents(result.RelayedEvents, ...) before executing return deserialized;. Because DispatchRelayedEvents was an async method, its synchronous prologue — and potentially the handlers themselves, up to the first real await — ran before the caller's continuation resumed. A caller writing _entity = await factory.Create(...) could therefore have a handler dispatch and read _entity while it was still null.

v1.4.0's dispatch site uses Task.Run + await Task.Yield() to ensure the relay queues behind the caller's continuation in any host (Blazor sync context, ASP.NET sync context, console/ThreadPool with no sync context). The new RelayTimingTests integration test fails against the old code and passes against the new code. See plan Factory Events Client Relay Redesign rules 6 and 7.


Commits

  • feat! Factory event relay redesign — IFactoryEventRelay.Relay(IReadOnlyList<FactoryEventBase>) replaces Register/Unregister
  • feat! Remove instance-method [FactoryEventHandler<T>] client-relay generator path
  • feat NoOpFactoryEventRelay default in NeatooFactory.Remote mode (TryAdd semantics)
  • feat FactoryEventAttribute + [DynamicallyAccessedMembers] on FactoryEventBase with Inherited = true
  • feat Runtime FactoryEventTypeRegistry (internal) — runtime assembly scan, no codegen
  • feat UnknownFactoryEventTypeException + FactoryEventDeserializer
  • feat NF0503 diagnostic (Warning) for ignored instance-method handlers
  • feat Log events 3008, 3009, 3011, 3012 for relay failure paths
  • fix Post-return ordering — Relay invoked strictly after caller's continuation resumes
  • test RelayTimingTests (integration) + FactoryEventTypeRegistryTests / FactoryEventDeserializerTests (unit) + PublishTrimmed smoke test

See docs/todos/completed/factory-events-relay-redesign.md for full plan + verification details.