diff --git a/README.md b/README.md index 0c37ed8..aeef082 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ License

- cratis diagnose reporting one failed partition, the exception that caused it, then the workbench opening on the same event store + cratis diagnose reporting a healthy store, then the raw events on the log, then the read model those events produced - One command for the verdict, one for the exception behind it, then the live view.
- The reactor could not reach its mail server; the CLI names the observer, the partition and the event.
+ Is it healthy, what happened, and what state did that produce —
+ the three questions, in the order you actually ask them.
--- @@ -43,14 +43,14 @@ the server's auth, and you are on a box you reached over SSH. ``` ❯ cratis chronicle diagnose -── Chronicle Diagnostics 10:15:16 ───────────────────────────────────────────── +── Chronicle Diagnostics 14:18:21 ───────────────────────────────────────────── server: chronicle://chronicle-dev-client:***@localhost:35100/ event store: Bookshop / Default ✓ Connection connected ✓ Server version 16.7.0 ✓ Event stores 2 stores: System, Bookshop - ✓ Observers 6 active 3 disconnected + ✓ Observers 9 active ✗ Failed partitions 1 need attention → cratis chronicle failed-partitions list ✓ Recommendations none ✓ Event sequence tail: 22 @@ -161,13 +161,14 @@ Measured on one store of 23 events and 9 observers: | | `events get` | `observers list` | `event-types list` | |---|---|---|---| -| `-o plain` | 2,133 B | 794 B | 561 B | -| `-o json-compact` | 8,250 B | 1,701 B | 1,656 B | -| `-o json` | 9,966 B | 2,110 B | 2,505 B | +| `-o plain` | 1,615 B | 777 B | 561 B | +| `-o json-compact` | 7,329 B | 1,684 B | 1,656 B | +| `-o json` | 8,981 B | 2,093 B | 2,505 B | | `-q` | 59 B | 403 B | 310 B | -`plain` is tab-separated and about 4.5× smaller than `json` here — the gap widens with row -count, because JSON repeats every field name on every row. `-q` exists to be piped: +`plain` is tab-separated and 2.7× to 5.6× smaller than `json` on this store — widest on +`events get`, where JSON repeats four field names across every one of 23 rows. `-q` exists to +be piped: ```bash cratis chronicle observers list -q | xargs -I {} cratis chronicle observers replay {} -y @@ -179,30 +180,33 @@ This is the table you will spend the most time in, and two of its columns are ea misread: ``` -Id Type State Quarantined Next# LastHandled# Subscribed -Bookshop.Members Reducer Disconnected False 3 2 False -Bookshop.Books Reducer Disconnected False 11 10 False -Bookshop.OverdueNotices Reactor Disconnected False 22 21 False -Bookshop.BorrowedBooks Projection Active False 19 18 False -Bookshop.OverdueBooks Projection Active False 23 22 False +Id Type State Quarantined Next# LastHandled# Subscribed +Bookshop.Members Reducer Active False 23 2 False +Bookshop.Books Reducer Active False 23 10 False +Bookshop.BorrowedBooks Projection Active False 23 18 False +Bookshop.OverdueBooks Projection Active False 23 22 False +Bookshop.OverdueNotices Reactor Active False 23 22 False ``` | Column | What it means | |---|---| -| `LastHandled#` | the last event this observer finished processing | -| `Next#` | the next sequence number it will look at | +| `Next#` | the next sequence number this observer will look at | +| `LastHandled#` | the last event it actually processed | | `State` | `Active`, `Replaying`, `Suspended`, `Disconnected`, `Quarantined` or `Unknown` | | `Subscribed` | whether a client is currently attached to it | > [!NOTE] -> **A `Next#` behind the log tail is not by itself a problem.** An observer only advances over -> events it subscribes to. Above, the tail is 22 and `BorrowedBooks` sits at 19 — events 19 -> through 22 are reservations and overdue markings, none of which it observes. It is fully -> caught up. This is why `diagnose` reports failed partitions rather than sequence lag: lag is -> ambiguous, a failed partition is not. - -`Disconnected` means no client is attached — usually the application is not running. -It is the normal state for a store whose application is stopped, and it is not an error. +> **`LastHandled#` lagging the tail is normal, and is not the same thing as being behind.** +> Every observer above has `Next#` 23 against a tail of 22 — all of them are caught up. But +> `Members` last handled sequence 2, because no member has registered since; nothing between 3 +> and 22 was addressed to it. The two columns answer different questions: `Next#` is how far it +> has read, `LastHandled#` is the last thing it cared about. +> +> This is why `diagnose` reports failed partitions rather than sequence lag. Lag is ambiguous. +> A failed partition is not. + +`Disconnected` means no client is attached — usually the application is not running. It is the +normal state for a store whose application is stopped, and it is not an error. ## Following a failure to the event that caused it @@ -210,26 +214,36 @@ A partition is one event source's slice of an observer. When processing throws, stops **that partition** and leaves the rest of the observer running, so one bad entity does not halt everything. `failed-partitions show` prints what happened, per attempt: +
+diagnose flagging one failed partition in an otherwise healthy store, listing it, then printing the exception that stopped it +
+ +Nine observers running, one partition stopped. The verdict names the next command, the +list names the observer and the partition, and the partition turns out to be a book — +`978-0131177055` — whose overdue notice could not be sent. + +The partition is the ISBN because that is the event source id this application uses. Whatever +your entities are keyed by is what you will see here, which is what makes the failure +addressable rather than merely reported: + ``` -FailedPartition: 3447a2eb-5dc8-41d5-ab80-30948951dd44 +FailedPartition: caadc869-1251-41d0-9063-6947eaf74043 Observer: Bookshop.OverdueNotices -Partition: 00000014-1111-4222-8333-444444444444 -Attempts: 4 +Partition: 978-0131177055 +Attempts: 5 - --- Attempt at 2026-07-27T23:46:06.9840000+00:00 (Seq# 22) --- + --- Attempt at 2026-07-28T12:17:56.6680000+00:00 (Seq# 22) --- Exception has been thrown by the target of an invocation. smtp.bookshop.local: connection refused - ... - --- Attempt at 2026-07-27T23:46:21.4580000+00:00 (Seq# 22) --- ``` -Four attempts, and the gaps between them widen — 2 seconds, then 4, then 8. Chronicle backs -off and keeps retrying on its own, so a partition that failed on something transient -recovers without you. `retry-partition` exists for the other case: you have fixed the cause -and do not want to wait for the next attempt. +The gaps between attempts widen — 2 seconds, then 4, then 8. Chronicle backs off and keeps +retrying on its own, so a partition that failed on something transient recovers without you. +`retry-partition` exists for the other case: you have fixed the cause and do not want to wait +for the next attempt. ```bash -cratis chronicle observers retry-partition Bookshop.OverdueNotices 00000014-…-444444444444 -y +cratis chronicle observers retry-partition Bookshop.OverdueNotices 978-0131177055 -y ``` > [!WARNING] @@ -249,12 +263,13 @@ selected observer, `T` retries a failed partition, `S` and `U` stop and resume j `Ctrl+P` is the part worth knowing about. It searches **every kind of artifact at once**:
-typing one word into the workbench command palette and matching an observer, an event type, a projection, a read model and a failure at the same time +filtering the workbench observers view down to one application, then searching every artifact kind at once from the command palette
-One word, six matches, five kinds — the reactor and the projection's observer, the event -type they both read, the projection declaration, the read model it writes, and the failure the -reactor left behind. Picking one jumps to its view with the filter already applied. +`F` narrows the view to one application. `Ctrl+P` then matches a single word across five +kinds at once — the reactor, the projection's observer, the event type they both read, the +projection declaration and the read model it writes. Picking one jumps to its view with the +filter already applied. That breadth is the reason it is a palette and not a search box. "Overdue" is not a name you look up in one list; it is a thread running through five of them, and following it is what @@ -331,11 +346,11 @@ can work out that a stuck observer means `failed-partitions show` without being It is not a static word list:
-pressing tab after cratis chronicle observers replay and getting the nine observer ids registered on the live server +pressing tab after cratis chronicle read-models instances and getting the read model names registered on the live server
-Completing an observer id shells back into the CLI, which connects and returns what that -server has registered right now. Typing narrows to the two that match. +Completing a read model name shells back into the CLI, which connects and returns what that +server has registered right now — then the completed command runs against it. Observers, event stores, event types, projections, read models, jobs, recommendations, subscriptions, applications and users all complete this way — and context names, which come @@ -487,9 +502,8 @@ observes one thing, so a failure reads as a sentence. They are scripted, not screen-captured, and re-render from a clean checkout: ```bash -dotnet build -c Release -assets/demo-store/reset.sh # a throwaway Chronicle server with a story seeded into it -vhs assets/demo.tape # or palette, completions +assets/record.sh # every clip: sets up the store, waits for it, renders +assets/record.sh workbench # or just one ``` [`assets/RECORDING.md`](assets/RECORDING.md) covers how they were made, what the fixture diff --git a/assets/RECORDING.md b/assets/RECORDING.md index abb1aab..f8b5d3d 100644 --- a/assets/RECORDING.md +++ b/assets/RECORDING.md @@ -4,9 +4,8 @@ Everything in this directory that ends in `.tape` is a script. Nothing was perfo nothing was screen-captured, and any of it re-renders from a clean checkout: ```bash -dotnet build -c Release -assets/demo-store/reset.sh -vhs assets/demo.tape # or palette, completions +assets/record.sh # all four +assets/record.sh workbench # or one ``` That is the point. A hand-recorded screencast is a one-off you cannot fix a typo in; a tape is @@ -34,8 +33,9 @@ customer system — so [`demo-store/`](demo-store/) builds a third option: a thr in Docker with a small bookshop seeded into it. ```bash -assets/demo-store/reset.sh # fresh server, seed, exit -assets/demo-store/reset.sh drip 40 # append a trickle against a running one +assets/demo-store/reset.sh # fresh server, seed, exit +assets/demo-store/reset.sh serve 90 # stay connected and healthy +assets/demo-store/reset.sh serve-failing 90 # stay connected with one partition failing ``` It registers eight books, three members, two projections, two reducers and one reactor, then @@ -43,11 +43,16 @@ appends a story: borrowings, two returns, two reservations, two books going over reactor that sends overdue notices **throws for exactly one book**, which is what leaves a failed partition behind for the CLI to find. -Two properties matter: +Three properties matter: -- **Deterministic identifiers.** Event source ids are generated from a fixed pattern rather - than `Guid.NewGuid()`, so `00000014-1111-4222-8333-444444444444` is the same book on every - re-render and the tapes can name it directly. +- **Domain identifiers, not GUIDs.** A book is keyed by its ISBN and a member by a handle, so + `978-0131177055` is the same book on every re-render, the tapes can name it directly, and + every column the CLI prints stays readable. The first version of this fixture used + `Guid.NewGuid()` and the read model table came out as two columns of wrapped hex. +- **A connected client.** Three of the four clips run against `serve`, so observers report + Active. A client that has exited leaves everything `Disconnected`, which looks like a broken + system when it is only a stopped process — the first cut of this set had three red + `Disconnected` rows in the hero for no reason other than that the seeder had finished. - **The failure survives.** Chronicle retries a failed partition on its own with a widening backoff, and it *succeeds* the moment the client reconnects without the fault. So the seeder exits after seeding and the partition stays failed. An earlier version of the hero tape ran a @@ -151,8 +156,8 @@ Two consequences worth knowing before editing a tape: ## The settings, and what each is for All of them live in [`_style.tape`](_style.tape), which every other tape pulls in with -`Source assets/_style.tape`. Three recordings that drift apart in font size or theme read as -three screencasts; one shared file makes them read as one set. +`Source assets/_style.tape`. Four recordings that drift apart in font size or theme read as +four screencasts; one shared file makes them read as one set. | Setting | Why | |---|---| @@ -179,40 +184,53 @@ are not written the same way — and `completions.tape` has to be zsh. The most useful discipline here was editorial, not technical. -**One hero, then short single-purpose clips.** The top of the README gets the full arc — the -verdict, the exception behind it, the live view — at about 35 seconds. Everything after is -15-20 seconds and shows exactly one thing, sitting next to the prose that explains it. +**Show the tool working, not the system failing.** The first version of this set was built +entirely around a broken store: the hero opened on red ✗ marks and a four-deep stack trace, +and three observers showed `Disconnected` because the seeder had exited. It read as "this is +for when everything is on fire" rather than "this is what the tool does". Three of the four +clips now run against a healthy, connected system, and exactly one is about a failure. + +**One hero, then short single-purpose clips.** The top of the README gets the three questions +you actually open the tool to answer — is it healthy, what happened, what state did that +produce — at about 29 seconds. Everything after is 15-25 seconds and shows one thing, sitting +next to the prose that explains it. **A GIF has to show something text cannot.** That is the whole test. -- ✅ `demo.gif` — `diagnose` printing a verdict and naming the next command, then a full-screen - dashboard painting itself over the same terminal and narrowing live as a filter is typed. -- ✅ `palette.gif` — one word matching an observer, an event type, a projection, a read model - and a failure *at the same time*. A screenshot shows six rows; it cannot show them arriving - as the query resolves. +- ✅ `demo.gif` — the verdict, then the raw log, then the state those events produced. Three + commands, one question each, in the order they get asked. +- ✅ `workbench.gif` — a full-screen dashboard narrowing live as a filter is typed, then one + word matching five kinds of artifact *at the same time*. A screenshot shows five rows; it + cannot show them arriving as the query resolves. - ✅ `completions.gif` — a tab press that makes a network call. There is no way to convey that the list came from the server rather than from the script, except by watching it happen. -- ❌ **The failed-partition trail on its own.** Four attempts of the same stack trace is a wall - of output. It is in the hero, where it lasts seven seconds and carries the narrative, and the - README quotes it as text where it is searchable and loads instantly. +- ✅ `triage.gif` — one failed partition in a healthy store, and the trail from the verdict to + the exception. Kept short and kept to one clip: four attempts of the same stack trace is a + wall of output, and the README quotes the interesting part as text where it is searchable. - ❌ **The output formats.** The same command four ways is a table with byte counts, not a recording. -**Watch the total weight.** Three GIFs, 1.1 MB. +**Watch the total weight.** Four GIFs, under 1 MB. + +## Nothing destructive is typed on camera -## Recording something destructive +An earlier cut of `completions.tape` used `cratis chronicle observers replay ` to demonstrate +completion, because an observer id is a good thing to complete. That command reprocesses an +observer from sequence zero. It never ran — the tape stopped short of `Enter` and ended on +`Ctrl+C` — but it meant a single stray keystroke in a tape stood between a demo and a replay. -`completions.tape` types `cratis chronicle observers replay ` on camera — a command that -reprocesses an observer from sequence zero. It exists in the clip because an observer id is the -best demonstration of completion hitting the server, and it is safe because **the tape never -sends `Enter`**. It ends on `Ctrl+C`, which abandons the line. +It now completes `cratis chronicle read-models instances ` instead, which is read-only, and the +clip is better for it: the completion list has no `$system` entries in it, so there is no +backslash-escaped noise on screen either. -Audit before rendering: +Audit before rendering, and again whenever a tape changes: ```bash -awk '/^Show/{s=1} s' assets/completions.tape | grep -n "Enter" +grep -n "replay\|retry\|remove\|delete" assets/*.tape ``` -That should return nothing but comment lines. Then verify the server afterwards, as above. +That should return nothing. If a clip ever does need a destructive command, keep it off `Enter` +and verify the server before and after — capture the tail sequence number, the failed-partition +count and every observer's position, and diff them. No README asset justifies a tape typo replaying somebody's observer. diff --git a/assets/completions.gif b/assets/completions.gif index ab029a8..15244bb 100644 Binary files a/assets/completions.gif and b/assets/completions.gif differ diff --git a/assets/completions.tape b/assets/completions.tape index edd61f1..ac6fb12 100644 --- a/assets/completions.tape +++ b/assets/completions.tape @@ -1,11 +1,10 @@ # Tab completion that goes and asks the server. # -# assets/demo-store/reset.sh -# vhs assets/completions.tape +# assets/record.sh # -# The observer ids offered here are not a static word list baked into the completion script. -# The script shells back into `cratis _complete observers`, which opens a connection and -# returns what that server has registered at that moment. +# The read model names offered here are not a static word list baked into the completion +# script. The script shells back into `cratis _complete read-models`, which opens a connection +# and returns what that server has registered at that moment. # # Recorded under zsh rather than bash: the generated bash script calls `_init_completion`, # which comes from the bash-completion package, and the macOS system bash is 3.2 without it. @@ -23,19 +22,16 @@ Type "clear" Enter Sleep 1s Show -# `replay` reprocesses an observer from sequence zero. This tape never sends Enter — it ends -# on Ctrl+C, which abandons the line. Audit before rendering: grep -n 'Enter' completions.tape -Type "cratis chronicle observers replay " +Type "cratis chronicle read-models instances " Sleep 1500ms Tab Sleep 4s -Type "Bookshop.O" +# The first Tab inserted the common prefix `Bookshop.` shared by all four. `Bor` is the +# shortest string that picks one of them, so the second Tab can complete it in full. +Type "Bor" Sleep 1s Tab -Sleep 1500ms -Tab -Sleep 4s - -Ctrl+C Sleep 2s +Enter +Sleep 6s diff --git a/assets/demo-store/Domain.cs b/assets/demo-store/Domain.cs index 7a84c1d..97ce0da 100644 --- a/assets/demo-store/Domain.cs +++ b/assets/demo-store/Domain.cs @@ -5,14 +5,18 @@ namespace Bookshop; +// Event source ids here are the domain's own identifiers — an ISBN for a book, a handle for a +// member — rather than generated GUIDs. Chronicle takes any string, and it keeps every column +// of CLI output readable instead of 36 characters of hex. + [EventType] public record MemberRegistered(string Name, string Email); [EventType] -public record BookAddedToInventory(string Title, string Author, string Isbn); +public record BookAddedToInventory(string Title, string Author); [EventType] -public record BookBorrowed(Guid MemberId, DateTimeOffset DueBy); +public record BookBorrowed(string MemberId, DateTimeOffset DueBy); [EventType] public record BookReturned(); @@ -21,45 +25,43 @@ public record BookReturned(); public record BookMarkedOverdue(int DaysLate); [EventType] -public record BookReservationPlaced(Guid MemberId); +public record BookReservationPlaced(string MemberId); -public record Book(Guid Id, string Title, string Author, string Isbn); +public record Book(string Id, string Title, string Author); -public record Member(Guid Id, string Name, string Email); +public record Member(string Id, string Name, string Email); -public record BorrowedBook(Guid Id) +public record BorrowedBook(string Id) { public string Title { get; set; } = string.Empty; - public string Member { get; set; } = string.Empty; - public DateTimeOffset Borrowed { get; set; } + public string Borrower { get; set; } = string.Empty; public DateTimeOffset DueBy { get; set; } } -public record OverdueBook(Guid Id) +public record OverdueBook(string Id) { public string Title { get; set; } = string.Empty; - public string Member { get; set; } = string.Empty; public int DaysLate { get; set; } } public class Books : IReducerFor { public Task Added(BookAddedToInventory @event, Book? initialState, EventContext context) => - Task.FromResult(new Book(Guid.Parse(context.EventSourceId), @event.Title, @event.Author, @event.Isbn)); + Task.FromResult(new Book(context.EventSourceId.ToString(), @event.Title, @event.Author)); } public class Members : IReducerFor { public Task Registered(MemberRegistered @event, Member? initialState, EventContext context) => - Task.FromResult(new Member(Guid.Parse(context.EventSourceId), @event.Name, @event.Email)); + Task.FromResult(new Member(context.EventSourceId.ToString(), @event.Name, @event.Email)); } public class BorrowedBooks : IProjectionFor { public void Define(IProjectionBuilderFor builder) => builder .From(from => from - .Set(m => m.DueBy).To(e => e.DueBy) - .Set(m => m.Borrowed).ToEventContextProperty(c => c.Occurred)) + .Set(m => m.Borrower).To(e => e.MemberId) + .Set(m => m.DueBy).To(e => e.DueBy)) .Join(join => join .On(m => m.Id) .Set(m => m.Title).To(e => e.Title)) @@ -78,8 +80,8 @@ public void Define(IProjectionBuilderFor builder) => builder } /// -/// Sends the overdue notice. Deliberately fails for one book so the demo server has a -/// failed partition to inspect and retry. +/// Sends the overdue notice. Fails for one book on demand, so the triage clip has a real +/// exception to follow rather than a contrived one. /// public class OverdueNotices : IReactor { diff --git a/assets/demo-store/Program.cs b/assets/demo-store/Program.cs index 0953efa..416960a 100644 --- a/assets/demo-store/Program.cs +++ b/assets/demo-store/Program.cs @@ -3,13 +3,30 @@ using Cratis.Chronicle.Connections; // Modes: -// seed — register artifacts, append the baseline story, leave one failing partition behind -// drip — stay connected and append a trickle of events so a live dashboard visibly moves +// seed — register artifacts and append the baseline story, then exit +// serve — stay connected and healthy, so observers report Active +// serve-failing — stay connected with the overdue notice still failing, so one partition +// stays failed while everything around it keeps working +// drip — stay connected and append a trickle so a live dashboard visibly moves +// +// A client that has exited leaves every observer Disconnected, which looks alarming in a +// recording and is not what anyone's system looks like while it is running. The serve modes +// exist so the demos show a live system. var server = args.Length > 0 ? args[0] : "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35100/"; var storeName = args.Length > 1 ? args[1] : "Bookshop"; var seconds = args.Length > 2 ? int.Parse(args[2]) : 25; var mode = args.Length > 3 ? args[3] : "seed"; +// The book whose overdue notice cannot be sent. Armed BEFORE connecting: Chronicle retries a +// pending failed partition the moment a client reconnects, so arming it afterwards lets that +// first retry succeed and clears the very failure the triage clip is about. +const string FailingBook = "978-0131177055"; + +if (string.Equals(mode, "serve-failing", StringComparison.Ordinal)) +{ + OverdueNotices.FailForEventSourceId = FailingBook; +} + var options = new ChronicleOptions(new ChronicleConnectionString(server)); using var client = new ChronicleClient(options); var eventStore = await client.GetEventStore(storeName); @@ -17,28 +34,35 @@ // Give the client time to register artifacts with the server. await Task.Delay(TimeSpan.FromSeconds(5)); -// Deterministic ids so every re-render of the tapes shows the same identifiers. -static Guid Id(int n) => new($"0000{n:0000}-1111-4222-8333-444444444444"); - +// Domain identifiers, not GUIDs — an ISBN keys a book and a handle keys a member. They are +// stable across re-renders and readable in every column the CLI prints. var members = new[] { - (Id(1), "Ada Wong", "ada@example.com"), - (Id(2), "Grace Miller", "grace@example.com"), - (Id(3), "Kaito Mori", "kaito@example.com"), + ("ada.wong", "Ada Wong", "ada@example.com"), + ("grace.miller", "Grace Miller", "grace@example.com"), + ("kaito.mori", "Kaito Mori", "kaito@example.com"), }; var books = new[] { - (Id(10), "The Pragmatic Programmer", "Hunt & Thomas", "978-0135957059"), - (Id(11), "Domain-Driven Design", "Eric Evans", "978-0321125217"), - (Id(12), "Designing Data-Intensive Applications", "Martin Kleppmann", "978-1449373320"), - (Id(13), "Refactoring", "Martin Fowler", "978-0134757599"), - (Id(14), "Working Effectively with Legacy Code", "Michael Feathers", "978-0131177055"), - (Id(15), "Release It!", "Michael Nygard", "978-1680502398"), - (Id(16), "Accelerate", "Forsgren, Humble & Kim", "978-1942788331"), - (Id(17), "The Mythical Man-Month", "Fred Brooks", "978-0201835953"), + ("978-0135957059", "The Pragmatic Programmer", "Hunt & Thomas"), + ("978-0321125217", "Domain-Driven Design", "Eric Evans"), + ("978-1449373320", "Designing Data-Intensive Applications", "Martin Kleppmann"), + ("978-0134757599", "Refactoring", "Martin Fowler"), + ("978-0131177055", "Working Effectively with Legacy Code", "Michael Feathers"), + ("978-1680502398", "Release It!", "Michael Nygard"), + ("978-1942788331", "Accelerate", "Forsgren, Humble & Kim"), + ("978-0201835953", "The Mythical Man-Month", "Fred Brooks"), }; +if (mode is "serve" or "serve-failing") +{ + Console.WriteLine($"{mode}: connected for {seconds}s"); + await Task.Delay(TimeSpan.FromSeconds(seconds)); + Console.WriteLine("done"); + return; +} + if (string.Equals(mode, "drip", StringComparison.Ordinal)) { // The overdue notice succeeds here — this mode exists to make a live dashboard move, @@ -50,9 +74,9 @@ { var book = books[(n + 6) % books.Length]; var member = members[n % members.Length]; - await eventStore.EventLog.Append(book.Item1.ToString(), new BookReservationPlaced(member.Item1)); + await eventStore.EventLog.Append(book.Item1, new BookReservationPlaced(member.Item1)); await Task.Delay(TimeSpan.FromMilliseconds(1500)); - await eventStore.EventLog.Append(book.Item1.ToString(), new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(14))); + await eventStore.EventLog.Append(book.Item1, new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(14))); await Task.Delay(TimeSpan.FromMilliseconds(1500)); n++; } @@ -63,12 +87,12 @@ foreach (var (id, name, email) in members) { - await eventStore.EventLog.Append(id.ToString(), new MemberRegistered(name, email)); + await eventStore.EventLog.Append(id, new MemberRegistered(name, email)); } -foreach (var (id, title, author, isbn) in books) +foreach (var (id, title, author) in books) { - await eventStore.EventLog.Append(id.ToString(), new BookAddedToInventory(title, author, isbn)); + await eventStore.EventLog.Append(id, new BookAddedToInventory(title, author)); } var dueIn = new[] { 9, 12, 6, -3, -11, 4 }; @@ -76,23 +100,23 @@ { var member = members[i % members.Length]; await eventStore.EventLog.Append( - books[i].Item1.ToString(), + books[i].Item1, new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(dueIn[i]))); } -await eventStore.EventLog.Append(books[0].Item1.ToString(), new BookReturned()); -await eventStore.EventLog.Append(books[1].Item1.ToString(), new BookReturned()); +await eventStore.EventLog.Append(books[0].Item1, new BookReturned()); +await eventStore.EventLog.Append(books[1].Item1, new BookReturned()); -await eventStore.EventLog.Append(books[2].Item1.ToString(), new BookReservationPlaced(members[0].Item1)); -await eventStore.EventLog.Append(books[3].Item1.ToString(), new BookReservationPlaced(members[2].Item1)); +await eventStore.EventLog.Append(books[2].Item1, new BookReservationPlaced(members[0].Item1)); +await eventStore.EventLog.Append(books[3].Item1, new BookReservationPlaced(members[2].Item1)); // Two books go overdue. The notice for the second one cannot be sent, which leaves a // failed partition behind for the CLI to find. -OverdueNotices.FailForEventSourceId = books[4].Item1.ToString(); -await eventStore.EventLog.Append(books[3].Item1.ToString(), new BookMarkedOverdue(3)); -await eventStore.EventLog.Append(books[4].Item1.ToString(), new BookMarkedOverdue(11)); +OverdueNotices.FailForEventSourceId = FailingBook; +await eventStore.EventLog.Append(books[3].Item1, new BookMarkedOverdue(3)); +await eventStore.EventLog.Append(books[4].Item1, new BookMarkedOverdue(11)); -Console.WriteLine($"seeded. failing partition = {books[4].Item1} ({books[4].Item2})"); +Console.WriteLine($"seeded. failing partition = {FailingBook} ({books[4].Item2})"); await Task.Delay(TimeSpan.FromSeconds(seconds)); Console.WriteLine("done"); diff --git a/assets/demo-store/reset.sh b/assets/demo-store/reset.sh index 435e3e7..b6a2efd 100755 --- a/assets/demo-store/reset.sh +++ b/assets/demo-store/reset.sh @@ -1,12 +1,21 @@ #!/bin/bash # Rebuild the event store the README GIFs are recorded against. # -# Starts a throwaway Chronicle server, connects the Bookshop client, and appends a fixed -# story that ends with one reactor partition failing. Identifiers are deterministic, so -# every re-render of the tapes shows the same ids as the committed GIFs. +# Starts a throwaway Chronicle server, connects the Bookshop client, and appends a fixed story. +# Identifiers are deterministic, so every re-render shows the same ids as the committed GIFs. # -# ./reset.sh # fresh server, seed, exit -# ./reset.sh drip 40 # append a trickle for 40s against the running server +# ./reset.sh # fresh server, seed, exit +# ./reset.sh serve 90 # stay connected and healthy for 90s +# ./reset.sh serve-failing 90 # stay connected with one partition still failing +# ./reset.sh drip 40 # append a trickle against the running server +# +# The serve modes matter for recording. A client that has exited leaves every observer +# Disconnected, which looks like a broken system when it is only a stopped process. +# +# Deliberately NOT Chronicle's default 35000. Anything else on this machine — an Aspire +# AppHost's control plane, a compose stack — that has claimed 127.0.0.1:35000 wins the name +# `localhost`, and this script would then health-check and seed somebody else's server. +# Override with CHRONICLE_PORT only if you are sure 35000 is yours. set -e HERE="$(cd "$(dirname "$0")" && pwd)" @@ -18,9 +27,11 @@ BOOKSHOP="$HERE/bin/Release/net10.0/Bookshop" [ -x "$BOOKSHOP" ] || dotnet build "$HERE/DemoStore.csproj" -c Release -if [ "$1" = "drip" ]; then - exec "$BOOKSHOP" "$CONNECTION" Bookshop "${2:-40}" drip -fi +case "$1" in + serve|serve-failing|drip) + exec "$BOOKSHOP" "$CONNECTION" Bookshop "${2:-60}" "$1" + ;; +esac docker rm -f "$CONTAINER" >/dev/null 2>&1 || true docker run -d --name "$CONTAINER" -p "$PORT":35000 -p 30100:30000 -p 11100:11111 "$IMAGE" >/dev/null diff --git a/assets/demo.gif b/assets/demo.gif index eae8895..d7b183d 100644 Binary files a/assets/demo.gif and b/assets/demo.gif differ diff --git a/assets/demo.tape b/assets/demo.tape index 928d1ab..53c3021 100644 --- a/assets/demo.tape +++ b/assets/demo.tape @@ -1,11 +1,11 @@ -# Hero. The arc this tool exists for: a verdict, the event that caused it, then the live view. +# Hero. The three questions you actually open this tool to answer: is it healthy, what +# happened, and what state did that produce. # -# assets/demo-store/reset.sh # seed the store and leave one partition failing -# vhs assets/demo.tape +# assets/record.sh # sets up the store and renders every tape # -# Nothing here is driven by arrow keys. vhs cannot deliver them to the application on the -# recording machine — bash's own history does not respond to Up either — so the workbench -# segment uses only keys that verifiably arrive: F, printable characters, Escape and q. +# Recorded against a live, connected client so observers report Active. A client that has +# exited leaves everything Disconnected, which looks like a broken system when it is only a +# stopped process. Output assets/demo.gif @@ -20,29 +20,12 @@ Show Type "cratis chronicle diagnose" Enter Sleep 6s -# A partition id is 36 characters of nothing happening at reading speed, so this one line -# is typed fast. Everything the viewer is meant to read is at the default speed. -Set TypingSpeed 18ms -Type "cratis chronicle failed-partitions show Bookshop.OverdueNotices 00000014-1111-4222-8333-444444444444" Enter -Set TypingSpeed 55ms +Type "clear" Enter +Sleep 300ms +Type "cratis chronicle events get --from 11" Enter Sleep 7s Type "clear" Enter -Sleep 500ms - -Type "cratis chronicle workbench" Enter -Sleep 9s - -# F opens the filter on whichever view is showing. Typed slowly here because the narrowing -# is the content. -Type "F" -Sleep 1500ms -Set TypingSpeed 140ms -Type "Bookshop" -Set TypingSpeed 55ms -Sleep 4s - -Escape -Sleep 1500ms -Type "q" -Sleep 2s +Sleep 300ms +Type "cratis chronicle read-models instances Bookshop.BorrowedBook" Enter +Sleep 8s diff --git a/assets/palette.gif b/assets/palette.gif deleted file mode 100644 index 23a670c..0000000 Binary files a/assets/palette.gif and /dev/null differ diff --git a/assets/palette.tape b/assets/palette.tape deleted file mode 100644 index 2eb8a45..0000000 --- a/assets/palette.tape +++ /dev/null @@ -1,31 +0,0 @@ -# One query, every kind of artifact that touches it. -# -# assets/demo-store/reset.sh -# vhs assets/palette.tape - -Output assets/palette.gif - -Set Shell bash -Source assets/_style.tape - -Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter -Type "clear" Enter -Sleep 1s -Show - -Type "cratis chronicle workbench" Enter -Sleep 9s - -Ctrl+P -Sleep 2s - -# Typed slowly: the results resolving as the query narrows is the whole point of the clip. -Set TypingSpeed 160ms -Type "Overdue" -Set TypingSpeed 55ms -Sleep 5s - -Escape -Sleep 1s -Type "q" -Sleep 1500ms diff --git a/assets/record.sh b/assets/record.sh new file mode 100755 index 0000000..aa8bbff --- /dev/null +++ b/assets/record.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Renders every README GIF from scratch. +# +# assets/record.sh # all of them +# assets/record.sh demo # just one +# +# Each tape needs a particular server state, and the difference matters: three of the four are +# recorded against a live, connected client so observers report Active, and only triage is +# recorded with a fault armed. Getting that wrong produces a set where everything looks broken. +set -e + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/.." && pwd)" +cd "$REPO" + +[ -x Source/Cli/bin/Release/net10.0/Cratis.Cli ] || dotnet build -c Release + +stop_client() { pkill -f 'bin/Release/net10.0/Bookshop' 2>/dev/null || true; } +trap stop_client EXIT + +# Waits for the client's observers to report Active before rolling, otherwise the first frames +# catch a store that is still connecting. +start_client() { + stop_client + nohup "$HERE/demo-store/reset.sh" "$1" 600 >/dev/null 2>&1 & + printf 'connecting' + for _ in $(seq 1 40); do + if HOME="$HERE/.recording-home" CRATIS_NO_UPDATE_CHECK=1 \ + "$HERE/.recording-bin/cratis" chronicle observers list -o plain 2>/dev/null \ + | grep -q 'Bookshop.*Active'; then + echo; return + fi + printf '.'; sleep 2 + done + echo; echo "warning: client never reported Active" >&2 +} + +cli() { + HOME="$HERE/.recording-home" CRATIS_NO_UPDATE_CHECK=1 "$HERE/.recording-bin/cratis" "$@" +} + +# Renders are expensive and their failures are silent — a clip against the wrong state still +# produces a perfectly good-looking GIF of the wrong thing. So the state is asserted first. +require_state() { + local want=$1 n=0 + printf 'waiting for %s state' "$want" + while [ $n -lt 30 ]; do + local failures + failures=$(cli chronicle failed-partitions list -q 2>/dev/null | grep -c . || true) + case "$want" in + healthy) [ "$failures" = "0" ] && { echo; return; } ;; + failing) [ "$failures" != "0" ] && { echo; return; } ;; + esac + printf '.'; sleep 2; n=$((n + 1)) + done + echo + echo "error: store never reached '$want' state — refusing to record it" >&2 + exit 1 +} + +render() { + echo "── $1" + vhs "$HERE/$1.tape" +} + +"$HERE/demo-store/reset.sh" +HOME="$HERE/.recording-home" bash "$HERE/prepare-env.sh" + +want() { [ $# -eq 0 ] || [ -z "$1" ] || [ "$1" = "$2" ]; } + +if want "$1" demo || want "$1" workbench || want "$1" completions; then + # A healthy client clears the seeded failure through Chronicle's own retry, which is + # exactly the state these three want. + start_client serve + require_state healthy + want "$1" demo && render demo + want "$1" workbench && render workbench + want "$1" completions && render completions +fi + +if want "$1" triage; then + # A fresh store, then the fault armed before the client connects — Chronicle retries a + # pending failed partition the moment a client reconnects, so a late arm lets that first + # retry succeed and clears the very failure this clip is about. + "$HERE/demo-store/reset.sh" + start_client serve-failing + require_state failing + render triage +fi + +echo "done" diff --git a/assets/triage.gif b/assets/triage.gif new file mode 100644 index 0000000..068d506 Binary files /dev/null and b/assets/triage.gif differ diff --git a/assets/triage.tape b/assets/triage.tape new file mode 100644 index 0000000..2dd4936 --- /dev/null +++ b/assets/triage.tape @@ -0,0 +1,28 @@ +# The one clip about something being wrong: a single failing partition in an otherwise +# healthy store, and the trail from the verdict to the exception that caused it. +# +# assets/record.sh +# +# Recorded against `reset.sh serve-failing`, so the client is connected and every other +# observer is Active. The only red thing on screen is the actual problem. + +Output assets/triage.gif + +Set Shell bash +Source assets/_style.tape + +Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter +Type "clear" Enter +Sleep 1s +Show + +Type "cratis chronicle diagnose" Enter +Sleep 6s + +Type "cratis chronicle failed-partitions list" Enter +Sleep 5s + +# -o table is explicit because the pipe makes the CLI think a machine is reading and switch +# to JSON — correct behaviour, wrong for a demo. head keeps the first attempt on screen. +Type "cratis chronicle failed-partitions show Bookshop.OverdueNotices 978-0131177055 -o table | head -16" Enter +Sleep 8s diff --git a/assets/workbench.gif b/assets/workbench.gif new file mode 100644 index 0000000..cc2685f Binary files /dev/null and b/assets/workbench.gif differ diff --git a/assets/workbench.tape b/assets/workbench.tape new file mode 100644 index 0000000..e4722c4 --- /dev/null +++ b/assets/workbench.tape @@ -0,0 +1,43 @@ +# The workbench: filtering a view, then searching every artifact kind at once. +# +# assets/record.sh +# +# Not driven by arrow keys — vhs cannot deliver them to the application on the recording +# machine, and bash's own history does not respond to Up either. Only keys that verifiably +# arrive are used: F, printable characters, Escape and q. + +Output assets/workbench.gif + +Set Shell bash +Source assets/_style.tape + +Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter +Type "clear" Enter +Sleep 1s +Show + +Type "cratis chronicle workbench" Enter +Sleep 9s + +# F filters whichever view is showing. Typed slowly because the narrowing is the content. +Type "F" +Sleep 1200ms +Set TypingSpeed 140ms +Type "Bookshop" +Set TypingSpeed 55ms +Sleep 3500ms +Escape +Sleep 1500ms + +# Ctrl+P searches across every kind at once. +Ctrl+P +Sleep 1500ms +Set TypingSpeed 150ms +Type "Overdue" +Set TypingSpeed 55ms +Sleep 4500ms + +Escape +Sleep 1s +Type "q" +Sleep 1500ms