The Filter That Drops the Future
Trill's push service gates stories on a visibility window and silently discards anything scheduled for later - a Message Filter where a delay was intended, with no scheduler, no delay queue and no re-delivery anywhere in the estate.
“Not yet” and “never” are different answers, and message handlers conflate them constantly. A condition guards a side effect, the condition is false, the handler returns successfully, the broker acknowledges, and the message is gone. Nothing failed. Nothing retried. The work was not deferred; it was declined, permanently, by a piece of code that looks like a filter and was meant to be a delay.
Part 10 took apart the Pusher's streaming loop. This part is about the four lines upstream of it, which decide what ever enters the channel at all.
Four lines
internal sealed class StorySentHandler : IEventHandler<StorySent>
{
private readonly StorySentChannels _channels;
public StorySentHandler(StorySentChannels channels)
{
_channels = channels;
}
public async Task HandleAsync(StorySent @event)
{
if (@event.Visibility.From <= DateTime.UtcNow)
{
await _channels.Writer.WriteAsync(@event);
}
}
}
Trill.Pusher/src/Trill.Pusher/Events/External/Handlers/StorySentHandler.cs:8-24.
That is a textbook Message Filter in Hohpe and Woolf's sense: a component that consumes from a channel and forwards only messages meeting a criterion, discarding the rest. As a filter it is correct and idiomatic — one condition, one write, no side effects on the reject path.
The trouble is that the domain it filters does not model a criterion. It models an embargo.
The domain says “later”, not “no”
Stories carry a Visibility value object, and it is properly built:
public class Visibility : IEquatable<Visibility>
{
public DateTime From { get; }
public DateTime To { get; }
public bool Highlighted { get; }
public Visibility(DateTime from, DateTime to, bool highlighted)
{
if (from >= to)
{
throw new InvalidVisibilityPeriodException(from, to);
}
From = from;
To = to;
Highlighted = highlighted;
}
public static Visibility Default(DateTime from) => new Visibility(from, from.AddDays(7), false);
}
Trill.Services.Stories/src/.../Core/ValueObjects/Visibility.cs:6-24. An invariant enforced in the constructor, a named domain exception, and a sensible seven-day default. This is a well-made value object.
And the command that produces it accepts a window from the caller:
var visibility = command.VisibleFrom.HasValue && command.VisibleTo.HasValue
? new Visibility(command.VisibleFrom.Value, command.VisibleTo.Value, command.Highlighted)
: Visibility.Default(now);
Application/Commands/Handlers/SendStoryHandler.cs:61-63. SendStory has nullable VisibleFrom and VisibleTo properties; the estate's own .rest sample sends them as null, which is why the default path is the one anyone testing this would take.
So VisibleFrom in the future is a first-class, supported, domain-validated input. Post a story visible from tomorrow and the domain accepts it, the repository stores it, and the StorySent integration event is published with Visibility.From set to tomorrow.
At which point the Pusher's handler evaluates tomorrow <= DateTime.UtcNow, finds it false, returns, and Convey acknowledges the message. A story scheduled for the future is silently discarded from the real-time feed, permanently. Not deferred, not queued, not retried at the appointed hour — dropped, with no log line and no error. Tomorrow arrives and nothing pushes it, because the only copy of the event the Pusher will ever see was consumed and thrown away today.
What is missing to make it work
There is no scheduler anywhere in the Pusher, and no delayed-delivery mechanism anywhere in the estate. Specifically:
- No RabbitMQ delayed-message exchange. The broker image is built from
rabbitmq:3-managementwith anenabled_pluginsfile containing exactly[rabbitmq_management,rabbitmq_prometheus].— norabbitmq_delayed_message_exchange. - No
x-message-ttl, no per-messageexpiration, no dead-letter exchange. Searching all eleven repositories forx-dead-letterreturns nothing, which rules out the classic TTL-plus-DLX delay trick as well. - No
IHostedServiceor timer in the Pusher. The service has exactly three registered handlers and two gRPC methods. - No persistence. The Pusher's only storage is the in-process channel from part 9, so even holding the event in memory until its time came would not survive a restart.
Any one of those would have turned the filter into a delay. The Message Expiration and delayed-exchange answers are the standard ones and they are configured nowhere; the simplest correct fix — keep a sorted list of pending events and a timer that promotes them into the channel when their From passes — is perhaps forty lines and would still lose everything on restart.
And the other end of the window is never checked
Visibility.To does not appear in the Pusher at all. The handler tests From and nothing else, so:
- A story whose window has already closed is pushed to every connected client as if it were new. Re-publish an old
StorySent— a redelivery after a consumer crash, say — and an expired story appears live in the feed. - A story whose window has not yet opened is destroyed.
The domain models an embargo with two ends, and the push path implements exactly half of one of them, in the direction that loses data.
The contrast with the estate's other consumers is instructive, because they got it right. Trill.Services.Timeline stores the visibility window on write and applies it at read time:
var story = JsonSerializer.Deserialize<Story>(entry);
if (story.Visibility.From <= now && story.Visibility.To >= now)
{
stories.Add(story);
}
Trill.Services.Timeline/src/.../Redis/RedisStorage.cs:37-41. Both ends of the window, evaluated against the clock at the moment of the query. A story scheduled for tomorrow is stored today and simply does not appear in timelines until tomorrow, and it disappears again when the window closes. Analytics likewise persists the full Visibility on its own copy of the story.
Two of the three consumers of StorySent treat visibility as a property of the data and filter at read; the third treats it as a property of the delivery and filters at write. Read-time filtering is the right call for anything backed by storage, because the decision is re-evaluated every time and no information is destroyed. Write-time filtering is only safe when “not yet” cannot happen — and here it can, by design, from the public API.
That difference is the transferable lesson, and it is bigger than this repository: filtering on a time window at consume time is only correct if the message can never arrive early. If it can, you are not filtering; you are silently expiring work, and the failure is invisible because a discarded message and a processed message produce identical acknowledgements.
The expiry that is not on the wire
There is one expiry setting in the estate, and it is worth naming so it is not mistaken for the missing one. The Saga's appsettings.json:81-88 configures the outbox:
"outbox": {
"enabled": true,
"type": "sequential",
"expiry": 3600,
"intervalMilliseconds": 2000,
"inboxCollection": "inbox",
"outboxCollection": "outbox"
}
expiry: 3600 is a Message Expiration applied to the dedupe window — how long processed message ids are retained in the inbox collection so that a redelivery can be recognised as a duplicate. It says nothing about how long a message may live on the wire, and it is not read by anything in the Pusher, which has no outbox registration at all despite referencing Convey.MessageBrokers.Outbox in its .csproj.
So the estate's only expiry concept governs deduplication, and the two things that would have made the visibility window work — a broker-level TTL and a delayed exchange — appear nowhere. Combined with the durable-queues-on-ephemeral-volumes problem from part 4, Trill's messages have exactly two possible lifetimes: forever, or until the next teardown. A story that wants to appear in six hours has no representation in that scheme at all.
The fair reading
I do not think this is carelessness. VisibleFrom and VisibleTo are the estate's ad-scheduling feature — Highlighted is on the same value object, and paid highlighted stories are the entire reason the Ads service and the saga exist. The Pusher's handler is dated from the same four-minute burst as everything else, and a real-time feed genuinely should not push a story before its window opens. The condition is right. What is missing is the other branch: the one that says “and when it does open, push it then.”
To be fair as well, the version of this bug that matters most cannot fire from the estate's own default path. Visibility.Default(now) sets From to the handler's clock reading, so an ordinary story — the kind the .rest files post, with both dates null — always satisfies From <= UtcNow by a few milliseconds and always gets through. You have to use the scheduling feature to lose anything, and nothing in the estate uses it.
Which is exactly why it survived. A half-built feature only fails when someone uses the half that was built.
There is one more thing this handler does that no other consumer of StorySent risks, and it is a contract problem rather than a scheduling one: it dereferences @event.Visibility.From on a field that two of its three sibling consumers independently made optional. That thread runs through part 13.
Next, though, the estate's orchestrator — five compensation methods, and not one of them can be reached: a saga that cannot compensate.