-
Notifications
You must be signed in to change notification settings - Fork 51
Event log persistence for the SQL Server and PostgreSQL persisters #5655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
5330be8
Add EventLogItems entity and related configurations, migrations for S…
warwickschroeder a730c18
Update sql migrations. Add postgres migrations
warwickschroeder 34f50d4
Implement EventLogDataStore methods for adding and retrieving event l…
warwickschroeder 53ec9b9
Add event log retention period and implement sweeping logic for expir…
warwickschroeder 2b63efd
Enhance documentation for IEventLogDataStore interface, clarifying it…
warwickschroeder 6ba6717
Add EnableIntegratedServicePulse setting to App.config
warwickschroeder 0b2cb5f
Review changes
warwickschroeder 8acc89d
Regenerate migrations after rebase
warwickschroeder ab3177a
- Remove RavenDB specifics into the RavenDB persistence seam.
warwickschroeder 3bef62a
Update EventLogItems migration for PostgreSQL and SQL Server
warwickschroeder 42011db
Refactor GetEventLogItems method to return QueryResult type and updat…
warwickschroeder 4fb0eb9
include HighestId in etag versioning and add unit test
warwickschroeder 3461632
Remove global event id
warwickschroeder f1698db
Add eventlogs design
warwickschroeder c77f102
Regenerate eventlog migrations
warwickschroeder File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # Event log design | ||
|
|
||
| ## What it is | ||
|
|
||
| The event log is the primary instance's activity feed: the chronological "what has this instance noticed" list that ServicePulse shows. Message failures, retries, redirects, heartbeats, custom checks and integration failures all surface here. | ||
|
|
||
| It is a **projection of domain events, not a log file**. Nothing writes to it directly. Components raise domain events for their own reasons, and the event log turns a chosen subset of those into feed items. An event only appears if someone has declared how it should read, which makes the feed an editorial selection rather than a dump. | ||
|
|
||
| Only the primary instance has an event log. Audit and monitoring instances have none. | ||
|
|
||
| Four contracts define the whole part: `EventLogItem` (what is written), `EventLogItemView` (what is read), `EventLogMappingDefinition<TEvent>` (how a component declares an event belongs in the feed), and `IEventLogDataStore` (the storage seam). Everything else is machinery behind them. | ||
|
|
||
| ## What gets recorded | ||
|
|
||
| `EventLogItem` is the write contract, and it carries **no identity**. The identity is minted in each persistence seam. | ||
|
|
||
| `EventLogItemView` is the same shape plus `Id`, which is assigned by storage rather than by the application. | ||
|
|
||
| `RelatedTo` entries are built by helpers with fixed prefixes: `/message/{id}`, `/endpoint/{name}`, `/machine/{name}`, `/host/{guid}`, `/customcheck/{id}`, `/recoverability/groups/{id}`. The helpers only prefix a string, so passing the wrong property yields a link that resolves to nothing. | ||
|
|
||
| ## Declaring an event | ||
|
|
||
| A component makes one of its events visible with two things, and nothing in the event log changes: | ||
|
|
||
| 1. A class deriving **directly** from `EventLogMappingDefinition<TEvent>`. Derive through an intermediate non-generic base and the definition is silently skipped at registration. | ||
| 2. A matching `services.AddEventLogMapping<TDefinition>()` in that component's configuration. Two definitions for one event type is an error. | ||
|
|
||
| A definition is a **declarative builder, not a handler**: its constructor calls `Description(…)`, and optionally `RaisedAt(…)`, `Severity(…)` / `TreatAsError()` and the `RelatesTo*` helpers, to specify how one row reads. Definitions live with the component that raises the event: **this part owns the machinery, the components own the content**. An event with no declaration is ignored, deliberately and silently. | ||
|
|
||
| ## Reading the feed | ||
|
|
||
| `GET /api/eventlogitems` is the entire HTTP surface, gated on `Permissions.ErrorEventLogView`. It takes `page` and `pageSize`, returns one page of `EventLogItemView` newest first, and sets `Total-Count`, `ETag` and `Link`. There is no write, no delete, no per-item lookup, and no filtering or search: `Category`, `Severity` and `EventType` are returned but cannot be queried on. | ||
|
|
||
| Clients discover new items by **polling**; nothing is pushed, and recording an item has no outward effect at all. Because polling is the only path, a caller that echoes its `ETag` back as `If-None-Match` gets `304` with no body, and the request costs a header exchange instead of a page of JSON. | ||
|
|
||
| Timestamps are when the thing happened, so an item can land in the middle of the feed rather than at its head. | ||
|
|
||
| ## The storage seam | ||
|
|
||
| `IEventLogDataStore` has two methods, and its XML docs are the binding contract: | ||
|
|
||
| - `Add(EventLogItem)` persists one item. **Identity is the store's to assign** and surfaces on `EventLogItemView.Id`. That makes `Id` opaque: a stable key within one store, not something to parse. | ||
| - `GetEventLogItems(PagingInfo, knownVersion)` returns a `QueryResult` carrying the page, the total count independent of paging, and an `ETag`. Two obligations: the `ETag` is surfaced **verbatim**, so whatever the client echoes back arrives here unchanged and can be compared, and it **must change when retention removes items**, not only when one is added, since nothing else tells a polling client its cached page has gone stale. | ||
|
|
||
| ## Retention | ||
|
|
||
| Items age out on their own after `EventsRetentionPeriod`, 14 days by default. Nothing a user does removes one and there is no API to try. Enforcement is left to each storage backend and is invisible through the seam, so a change to the setting applies retrospectively on some backends and to new items only on others. | ||
|
|
||
| ## Failure behaviour | ||
|
|
||
| Recording is **in-band, not best-effort**. Domain event dispatch rethrows, so if storage is unreachable the failure propagates to whatever raised the event and that operation fails. There is no retry, no queue and no buffer in front of the write. | ||
|
|
||
| ## Known limits | ||
|
|
||
| - **The feed is per error instance.** A federated deployment gets no aggregation and nothing in the response says which instance answered. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
104 changes: 104 additions & 0 deletions
104
src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| namespace ServiceControl.AcceptanceTests.EventLogs | ||
| { | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Net; | ||
| using System.Net.Http; | ||
| using System.Net.Http.Json; | ||
| using System.Threading.Tasks; | ||
| using AcceptanceTesting; | ||
| using AcceptanceTesting.EndpointTemplates; | ||
| using NServiceBus; | ||
| using NServiceBus.AcceptanceTesting; | ||
| using NUnit.Framework; | ||
| using ServiceBus.Management.Infrastructure.Settings; | ||
| using ServiceControl.EventLog; | ||
|
|
||
| [TestFixture] | ||
| class When_the_event_log_is_polled_with_an_etag : AcceptanceTest | ||
| { | ||
| [Test] | ||
| public async Task Should_answer_not_modified_only_for_the_current_etag() | ||
| { | ||
| string etag = null; | ||
| HttpStatusCode currentEtagStatus = default; | ||
| HttpStatusCode unknownEtagStatus = default; | ||
| var unknownEtagReturnedItems = false; | ||
|
|
||
| await Define<ScenarioContext>() | ||
| .WithEndpoint<StartingEndpoint>() | ||
| .Done(async c => | ||
| { | ||
| var first = await this.GetRaw("/api/eventlogitems/"); | ||
|
|
||
| if (first.StatusCode != HttpStatusCode.OK) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| var items = await first.Content.ReadFromJsonAsync<List<EventLogItem>>(SerializerOptions); | ||
|
|
||
| // Keep polling until the endpoint's startup event has landed. An empty event | ||
| // log has a stable ETag of its own and would make the assertions meaningless. | ||
| if (items is not { Count: > 0 }) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // Raw header: an unquoted value fails EntityTagHeaderValue parsing, and this test | ||
| // has to observe that rather than throw on it. | ||
| if (!first.Headers.TryGetValues("ETag", out var values)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| etag = values.FirstOrDefault(); | ||
|
|
||
| if (string.IsNullOrEmpty(etag)) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| currentEtagStatus = (await Poll(etag)).StatusCode; | ||
|
|
||
| var unknown = await Poll("\"not-an-etag-this-instance-ever-issued\""); | ||
| unknownEtagStatus = unknown.StatusCode; | ||
|
|
||
| var unknownBody = await unknown.Content.ReadFromJsonAsync<List<EventLogItem>>(SerializerOptions); | ||
| unknownEtagReturnedItems = unknownBody is { Count: > 0 }; | ||
|
|
||
| return true; | ||
| }) | ||
| .Run(); | ||
|
|
||
| using (Assert.EnterMultipleScope()) | ||
| { | ||
| Assert.That(etag, Is.Not.Null.And.Not.Empty, | ||
| "the endpoint must emit an ETag or nothing downstream can cache it"); | ||
|
|
||
| Assert.That(currentEtagStatus, Is.EqualTo(HttpStatusCode.NotModified), | ||
| "a client holding the current version must be told so, not handed the page again"); | ||
|
|
||
| Assert.That(unknownEtagStatus, Is.EqualTo(HttpStatusCode.OK), | ||
| "an unrecognised validator is a cache miss: this is what stops an unconditional 304 passing"); | ||
|
|
||
| Assert.That(unknownEtagReturnedItems, Is.True, | ||
| "a cache miss must carry the items, not an empty body with a 200"); | ||
| } | ||
| } | ||
|
|
||
| Task<HttpResponseMessage> Poll(string ifNoneMatch) | ||
| { | ||
| var request = new HttpRequestMessage(HttpMethod.Get, "/api/eventlogitems/"); | ||
| request.Headers.TryAddWithoutValidation("If-None-Match", ifNoneMatch); | ||
|
|
||
| return HttpClient.SendAsync(request); | ||
| } | ||
|
|
||
| public class StartingEndpoint : EndpointConfigurationBuilder | ||
| { | ||
| public StartingEndpoint() => | ||
| EndpointSetup<DefaultServerWithoutAudit>(c => c.SendHeartbeatTo(Settings.DEFAULT_INSTANCE_NAME)); | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.