One Format String, Two Meanings
The same .NET format specifier means thirty-two hex digits on a Guid and a grouped decimal on a long - and Trill's timeline writes its sorted-set members one way then reads them the other, so every non-empty timeline throws.
Standard format strings in .NET are not a vocabulary; they are seven or eight overloaded letters whose meaning is decided entirely by the type on the left of the dot. "N" on a Guid and "N" on a long are unrelated instructions to unrelated formatters, and the compiler will not tell you which one you asked for because both calls are ToString(string) and both return a string. Part 9 left the Stories and Users services. This part reads the 104-line file that is the entire persistence layer of Trill's Timeline service, and the one line in it that breaks every read.
The four keys
Trill.Services.Timeline.Infrastructure/Redis/RedisStorage.cs is the whole thing — no Mongo, no repositories, no ORM. Its key schema is three private methods at the bottom of the file:
private static string GetFollowersKey(Guid userId) => $"users:{userId:N}:followers";
private static string GetTimelineKey(Guid userId) => $"users:{userId:N}:timeline";
private static string GetStoryKey(long storyId) => $"stories:{storyId}";
Note the difference already visible here. The two Guid keys use the :N interpolation format — 32 hexadecimal digits, no hyphens, users:cadd6df605e24587b05740428e9f366a:timeline. The long key does not use a format at all — stories:5. The author knew that "N" was a Guid thing and that a long did not need one. That knowledge does not survive the next forty lines.
The write
AddStoryToTimelineAsync, lines 89 to 94:
public async Task AddStoryToTimelineAsync(Guid userId, Story story)
{
var score = story.Visibility.From.ToUnixTimeMilliseconds();
var entry = new SortedSetEntry(story.Id.ToString("N"), score);
await _database.SortedSetAddAsync(GetTimelineKey(userId), new[] {entry});
}
story.Id is a long — Trill.Services.Timeline.Core/Data/Story.cs:8, public long Id { get; set; }.
long.ToString("N") is the standard Numeric format specifier. It produces a number with group separators and — because NumberFormatInfo.NumberDecimalDigits defaults to 2 — two decimal places. Story 5 is written into the sorted set as the member "5.00". A realistic snowflake id such as 1234567890 is written as "1,234,567,890.00". And because ToString(string) without an IFormatProvider uses CultureInfo.CurrentCulture, the same service running in a container with a German locale writes "5,00" instead. Three different strings for one integer, depending on machine configuration.
Twenty-six lines above, the same file gets the identical idiom right, because the receiver is a different type:
public async Task FollowAsync(Guid followerId, Guid followeeId)
{
await _database.SetAddAsync(GetFollowersKey(followeeId), followerId.ToString("N"));
}
Here "N" means the Guid format specifier: 32 hex digits, no hyphens, no braces. And the matching read parses it back correctly:
public async Task<IReadOnlyCollection<Guid>> GetAllFollowersAsync(Guid userId)
{
var followers = await _database.SetMembersAsync(GetFollowersKey(userId));
return followers.Select(x => Guid.Parse(x)).ToArray();
}
ToString("N") is correct on line 63 and wrong on line 92, and the difference is not visible at either call site. It is a copy of a working idiom onto a type the idiom does not apply to — the most ordinary mistake in software, made invisible by the fact that both types answer the same method with the same argument.
The read
GetTimelineAsync, lines 21 to 28:
var now = DateTime.UtcNow;
var minScore = GetScore(from ?? DateTime.UtcNow.AddDays(-7));
var maxScore = GetScore(to ?? DateTime.UtcNow);
var storyIds = await _database.SortedSetRangeByScoreAsync(GetTimelineKey(userId), minScore, maxScore);
var storyKeys = storyIds.Select(x => (RedisKey) GetStoryKey((long)x)).ToArray();
var storyEntries = await _database.StringGetAsync(storyKeys);
SortedSetRangeByScoreAsync returns the members — the strings that were written. (long)x is StackExchange.Redis's explicit conversion from RedisValue to long, which tries to parse the raw value as a 64-bit integer and, on failure, throws. The exception message in the library's string table is Unable to cast from {0} to long: '{1}'.
"5.00" does not parse as a long. Neither does "1,234,567,890.00". GetTimelineAsync throws an InvalidCastException for any timeline that contains at least one story.
The failure has a clean, cruel shape: an empty timeline works. storyIds is empty, storyKeys is an empty array, StringGetAsync returns nothing, and the method returns a well-formed empty Paged<Story> with a 200. So the endpoint is healthy for every user who has never received a story, and broken for every user who has. The moment fan-out succeeds for a follower, that follower's timeline stops responding.
What the user sees
The endpoint is two inline lambdas in Api/Startup.cs — no controller, no exception mapper, and, unlike its sibling services, no AddErrorHandler registration anywhere in Infrastructure/Extensions.cs. So the InvalidCastException propagates out of the endpoint delegate and becomes a bare 500.
On the client, Trill.Web.UI/Pages/Index.razor has a “Your timeline” toggle:
private async Task LoadTimelineAsync()
{
_loadingStories = true;
_stories = new PagedDto<StoryDto>();
await MessageService.Loading("Fetching the timeline...", 1);
_stories = await ApiResponseHandler.HandleAsync(TimelineService.GetAsync(AuthenticationService.User.Id));
_loadingStories = false;
if (_stories is null)
{
await MessageService.Error("Couldn't load the timeline :(", 1);
}
MessageService.Destroy();
}
The failure branch exists and is handled politely. A toast appears — “Couldn't load the timeline 😦” — and the feed stays empty. The application's headline feature fails as what looks exactly like a transient outage, every time, for every user it has ever worked for. The one presentation choice that would have surfaced the bug — letting the error through — is the one the client is careful not to make.
One letter, four contracts
The general shape of the trap is worth tabulating, because "N" is unusually overloaded even by .NET's standards. Every row below is the same one-character argument to the same method name:
| Receiver | What "N" means |
Example output |
|---|---|---|
Guid |
the Guid format specifier — 32 hex digits, no hyphens or braces | cadd6df605e24587b05740428e9f366a |
long, int |
the Number format — group separators, two decimal places, culture-dependent | 1,234,567,890.00 |
double, decimal |
the same Number format | 3.50 |
DateTime, TimeSpan, an enum |
not a valid standard specifier for the type — FormatException |
— |
Three different behaviours and one exception, selected entirely by the static type on the left. The compiler cannot help, because ToString(string) is the same signature on all of them; an analyser cannot easily help either, because "N" is a legal argument in most of these rows. The only defence is the habit of never passing a bare format string to something whose type you have not just read.
The last row is the mildly reassuring one: had the author reached for story.CreatedAt.ToString("N") instead, the service would have thrown loudly on the first write, and the bug would have lived for minutes rather than years. The numeric types are dangerous here precisely because they accept the specifier and return something plausible.
Why this one changed how I write
I have used ToString("N") on Guids for years without thinking about it, and I will not do that again without an IFormatProvider or a comment. Three specific habits come out of this file.
Never format an identifier with a standard specifier. An identifier's string form is a wire contract; a standard format specifier is a presentation instruction whose meaning depends on both the type and the ambient culture. story.Id.ToString(CultureInfo.InvariantCulture) is the minimum. Better still, do not format it at all — SortedSetEntry takes a RedisValue, and RedisValue has an implicit conversion from long that round-trips exactly:
var entry = new SortedSetEntry(story.Id, score);
One argument shorter, and there is no format string left to get wrong.
Write the round trip, not the write. The reason the Guid half of this file is correct is that FollowAsync and GetAllFollowersAsync were obviously written as a pair — one writes ToString("N"), the other reads Guid.Parse. The story half was not: the write is in AddStoryToTimelineAsync and the read is in GetTimelineAsync, forty lines and one abstraction apart, and no single function ever performs both. Serialisation code should be tested by its inverse, and when there is no test, at least written adjacently.
A format-specifier bug is a data-migration problem, not a code fix. This is the part teams get wrong under time pressure. Changing line 92 fixes new writes; every member already in every user's sorted set is still "5.00", and the read will still throw the first time it encounters one. Shipping the one-line fix without a ZREMRANGEBYSCORE-and-refan or a tolerant parser leaves the incident open. Any bug that has written malformed data has two halves, and the code half is the cheap one.
To be fair to the file: it is otherwise the best-executed idea in the estate. One canonical copy of each story body, only the identifiers fanned out, re-hydrated with a single multi-get, scored so that the sorted set does the visibility windowing for free. That design is what part 11 is about — and it is genuinely good, which is what makes a single misapplied format specifier such an expensive thing to have in it.