Trending Means All-Time and Frozen
Trill's entire ranking engine is thirty-nine lines of OrderByDescending().Take(10) over counters that only ever increase - no time window, no decay, no visibility filter - so once the top ten stabilises it can never change again.
“Trending” is a word that promises a derivative. Not “what has the most”, but “what is gaining fastest, right now” — which requires at minimum a time bucket and something to compare it against. Most products that ship a trending tab discover this in the second month, when the leaderboard has calcified around whatever was popular in week one. Part 11 read Trill's Timeline service, which windows correctly. This part reads the service that reports the product's only numbers, and finds no time in it at all.
The entire ranking engine
Trill.Services.Analytics.Core/Services/TrendingService.cs, in full apart from the constructor:
public async Task<IEnumerable<Story>> GetTopStoriesAsync()
=> await _databaseProvider.Stories.AsQueryable()
.OrderByDescending(x => x.TotalRate)
.Take(10)
.ToListAsync();
public async Task<IEnumerable<Tag>> GetTopTagsAsync()
=> await _databaseProvider.Tags.AsQueryable()
.OrderByDescending(x => x.OccurenceCount)
.Take(10)
.ToListAsync();
public async Task<IEnumerable<User>> GetTopUsersAsync()
=> await _databaseProvider.Users.AsQueryable()
.OrderByDescending(x => x.FollowersCount)
.ThenByDescending(x => x.StoriesCount)
.Take(10)
.ToListAsync();
Three sorts and three Take(10). The API surface above it is TrendingController, thirty-one lines, three GETs — /trending/stories, /trending/tags, /trending/users — with no parameters, no paging, no filters and no date range. That is the complete reporting surface of Trill's analytics service.
What is absent is more of a specification than what is present: no time window, no decay function, no velocity, no normalisation, no minimum-volume threshold, and no visibility filter. “Trending” here means “highest cumulative value since the beginning of time”.
The practical consequence is worth stating as a scenario rather than a critique. A story published on day one accumulates a rating of 5. Two years later, a story published this morning is at 4. The two-year-old story ranks above it, permanently, and the only way for the new one to displace it is to overtake it in absolute votes — which becomes harder as the corpus grows, because the incumbent keeps accumulating too. Once the top ten stabilises, it can only be entered by exceeding an all-time maximum, so the list converges and stops moving. The screen labelled “Trending” is a hall of fame.
Counters that only go up
Every number in that service is maintained by an event handler incrementing a field. There is no aggregation job, no recomputation, no query-time arithmetic.
Tag occurrences. Events/External/Handlers/StorySentHandler.cs loops the story's tags:
foreach (var tag in @event.Tags)
{
var isNew = await _tagsService.TryAddAsync(new Tag
{
Name = tag,
OccurenceCount = 1
});
if (isNew)
{
continue;
}
await _tagsService.IncrementOccurrencesCountAsync(tag);
}
Nothing decrements. There is no story-deletion path, no un-tagging, no time bucket. OccurenceCount is monotonic from the first StorySent the service ever handled. (The field name is misspelled — one r in Occurence — and the misspelling is on the wire: it propagates verbatim into the client's TagDto and is rendered on the Trending page. Persistence models are the API contract here; TrendingController returns IEnumerable<Story>, IEnumerable<Tag> and IEnumerable<User> directly, so adding an internal field publishes it.)
Story count per user. UsersService.IncrementStoriesCountAsync is Inc(s => s.StoriesCount, 1) on every StorySent. Monotonic.
Follower and following counts. Two independent updates, one method:
private async Task SetFollowersCountAsync(Guid followerId, Guid followeeId, int value)
{
var filterFollowee = builder.Eq(x => x.Id, followeeId);
var updateFollowee = Builders<User>.Update.Inc(s => s.FollowersCount, value);
var filterFollower = builder.Eq(x => x.Id, followerId);
var updateFollower = Builders<User>.Update.Inc(s => s.FollowingCount, value);
await _databaseProvider.Users.FindOneAndUpdateAsync(filterFollowee, updateFollowee);
await _databaseProvider.Users.FindOneAndUpdateAsync(filterFollower, updateFollower);
}
Two writes, no transaction between them. A process death between the two lines leaves the graph permanently asymmetric — one side's FollowersCount incremented, the other side's FollowingCount not — and nothing ever reconciles it, because there is no source of truth to replay from. Inc(..., -1) on unfollow has no floor at zero either, so a redelivery drives the count negative. Analytics does register the outbox decorator, which deduplicates redeliveries — except that appsettings.development.json sets outbox.enabled: false, which is exactly the configuration a developer runs.
And FindOneAndUpdateAsync on a missing _id is a silent no-op. UserCreated and UserFollowed arrive on independent queues from the same exchange, so a follow that overtakes its user's creation increments nothing and is lost with no error. FollowersCount under-counts permanently, by a number nobody can compute.
Story ratings. Analytics does not compute this at all:
public async Task SetTotalRateAsync(long storyId, int totalRate)
{
var update = Builders<Story>.Update.Set(s => s.TotalRate, totalRate);
await _databaseProvider.Stories.FindOneAndUpdateAsync(filter, update);
}
Set, not Inc — it mirrors a value the Stories service computed and shipped in StoryRated.TotalRate. Last-writer-wins over an unordered event stream, so a stale redelivery rolls the rating backwards and the ranking with it.
The blanket catch that accidentally works
TagsService.TryAddAsync deserves its own paragraph, because it is wrong in a way that produces correct behaviour:
public async Task<bool> TryAddAsync(Tag tag)
{
try
{
if (await _databaseProvider.Tags.AsQueryable().AnyAsync(x => x.Name == tag.Name))
{
return false;
}
await _databaseProvider.Tags.InsertOneAsync(tag);
return true;
}
catch
{
return false;
}
}
Check-then-insert is a classic race. Two concurrent StorySent events carrying the same new tag both pass the AnyAsync check and both insert; the unique index on tags.Name — the only index in the entire service — rejects the loser with a DuplicateKeyException; the blanket catch swallows it and returns false; and the caller, seeing false, falls through to IncrementOccurrencesCountAsync. The exception handler is implementing upsert semantics by accident, and the accident is the reason the counter is right.
It is still the wrong code, for the reason blanket catches are always wrong: it cannot tell a duplicate-key violation from a connection failure. A Mongo outage silently reports “this tag already exists”, after which the caller attempts an increment against the same dead connection and throws from a different line. The failure surfaces one call later, in the wrong place, with the wrong shape. InsertOneAsync wrapped in catch (MongoWriteException e) when (e.WriteError.Category == ServerErrorCategory.DuplicateKey) gets the same behaviour and keeps the outage visible.
Nothing is indexed and everything is scanned
The unique index on tags.Name is the only index the service creates — and it is created inside an unawaited Task.Run over a DI scope that is disposed on the synchronous return path, the same startup hazard part 6 traced through Stories and Users.
There is no index on TotalRate, none on OccurenceCount, none on FollowersCount. All three trending queries are therefore a full collection scan followed by an in-memory sort, on collections that grow forever and are never pruned — no TTL, no retention, no archival. Every story ever sent is retained in perpetuity for the purpose of computing a top ten. And the client fires all three endpoints sequentially on page load.
IDatabaseProvider is worth a word of credit in the middle of this. Three typed collection properties, injected, mockable:
public IMongoCollection<Story> Stories => _database.GetCollection<Story>("stories");
public IMongoCollection<Tag> Tags => _database.GetCollection<Tag>("tags");
public IMongoCollection<User> Users => _database.GetCollection<User>("users");
That is a cleaner seam than either Ads or Stories manages — both of those reach for IMongoDatabase and repeat collection-name string literals across four or five files. One small class removes an entire category of typo.
The same metric, three different answers
Here is the finding that makes this an architecture problem rather than a ranking problem. The same story, carrying the same Highlighted flag, is subject to three different inclusion rules depending on which screen you are looking at:
| Trending | Timeline | Stories feed | |
|---|---|---|---|
| Visibility window enforced | no | yes, twice | yes |
| Ordering | TotalRate, all time |
Visibility.From, 7 days |
recency |
| Paid content included | yes, indistinguishably | yes, delayed | yes |
| Rating source | mirrored | mirrored | authoritative |
So an expired advertisement — one whose paid window closed weeks ago — is correctly hidden from every timeline and stays in /trending/stories forever, rendered by the identical Story.razor component as organic content. Visibility.Highlighted is persisted on the Analytics document. It is read by no query, no DTO and no screen. One .Where(x => !x.Visibility.Highlighted) separates paid from organic; it is not written anywhere.
Nothing else about advertising is measured either. Searching the whole service for impression, view, click and reach returns nothing, because none of those concepts exists in any of the eleven repositories — which part 13 explains, since the billing model charges for time rather than delivery.
What I would build instead, and what I would keep
The minimum honest version of “trending” is a time-bucketed counter and a ratio. Increment tag:{name}:{yyyyMMddHH} on each occurrence with a TTL of a few days; rank by the sum over the last N buckets divided by the sum over the N before that; apply a minimum-volume floor so a tag going from 1 to 3 does not outrank one going from 900 to 1,400. That is a handful of Redis commands and it produces a list that moves.
To be fair to Trill: for a teaching estate, counters incremented by event handlers are exactly the right thing to demonstrate, and the shape — separate read model, populated asynchronously from integration events, queried without touching the write side — is textbook and correct. The gap is not the technique; it is that the word on the screen claims a property the technique cannot provide. If the endpoint were /top/stories rather than /trending/stories, the implementation would be entirely honest, and the article would not exist.
There is one more structural tell in this service and it is a good closing note. Api/Startup.cs calls app.UseConvey(); app.UseCore(); app.UseRouting(); — routing after the middleware pipeline — while the sibling Ads service calls app.UseRouting(); app.UseCore();. Two services, same framework, same author, opposite order. And UseConvey() is called twice: once at Startup.cs:36 and again inside UseCore(). Copy-paste is this estate's distribution mechanism, and it distributes ordering decisions as faithfully as it distributes code.
Next, the service whose entire commercial model turns out to be one boolean on somebody else's entity: an ad is a story with a flag.