Skip to content

Event log persistence for the SQL Server and PostgreSQL persisters - #5655

Draft
warwickschroeder wants to merge 12 commits into
masterfrom
tf832-sql-eventlogs
Draft

Event log persistence for the SQL Server and PostgreSQL persisters#5655
warwickschroeder wants to merge 12 commits into
masterfrom
tf832-sql-eventlogs

Conversation

@warwickschroeder

Copy link
Copy Markdown
Contributor

The event log is the list ServicePulse shows on its Events page. It worked on RavenDB and threw NotImplementedException on both EF Core persisters, so nothing could be written or read. This implements it.

Both EF persisters still log "is still under development and is not ready for use" at startup, so no customer is affected today.

What changed

  • EventLogItemEntity, its configuration, and one migration per provider. EventLogDataStore now implements both methods.
  • The write moved to the interface that owns it. AuditEventLogWriter called IErrorMessageDataStore.StoreEventLogItem while IEventLogDataStore.Add sat unused. Now it calls Add, and the stray member is gone from IErrorMessageDataStore and both implementations.
  • Event log items expire on the EF side too. RetentionSweeper sweeps them on its existing hourly timer against a separate EventsRetentionPeriod, independent of ErrorRetentionPeriod.
  • Conditional GET. GetEventLogItems takes an optional knownVersion. On a match the persister skips the page query and EventLogApiController answers 304, with ETag and Total-Count still sent.
  • knownVersion drops a query per poll on the EF persisters. A read is three queries: count, newest RaisedAt, then the page. When the caller's version matches, the page query is skipped and the two that remain are satisfied by IX_EventLogItems_RaisedAt_Id without reading row data. ServicePulse polls this every five seconds, so on an idle instance that is the common case rather than the exception. RavenDB saves no database work, because its version is the query's own ResultEtag and does not exist until the query has run; there the saving is only the serialised response body.
  • IEventLogDataStore now documents retention, which was previously invisible at the seam despite each persister enforcing it differently.

Tests

21 new tests plus 3 added to RetentionSweepTests, and one acceptance test for the 304. The 14 in EventLogDataStoreTests are the valuable ones: they live in the shared root of ServiceControl.Persistence.Tests, so they run as conformance tests against RavenDB, SQL Server and PostgreSQL. Round-tripping, ordering, paging boundaries and all four knownVersion cases have to behave identically on all three.

Comment thread src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs Outdated
Comment thread src/ServiceControl.Persistence.RavenDB/EventLogDataStore.cs
Comment thread docs/testing.md Outdated
public long Id { get; set; }

// The API-visible identity, assigned by EventLogMappingDefinition as
// "EventLogItem/{Category}/{EventType}/{guid}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a lot like Raven Ids!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sure is, which is why I havent made this the PK and introduced a long for that. Ill take another look into if we need this field at all in EF.

@warwickschroeder warwickschroeder Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep the API-visible identity for each event log item the same. The database ID field used by the persistence doesn't need to be this though.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand what you mean

@warwickschroeder warwickschroeder Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit should move the Id generation into the persistence seams and out of the abstraction layer.

Comment thread src/ServiceControl.Persistence.EFCore/Entities/EventLogItemEntity.cs Outdated

public DateTime RaisedAt { get; set; }

public List<string> RelatedTo { get; set; } = [];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is EF smart to serialize this as xml/json?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SQL server, yes. It maps to a json array string. In Postrgres, maps to a a native text[] type

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just looked in SP and it seems this is the only place we are using the "related_to" prop, see https://github.com/Particular/ServicePulse/blob/5f0ede166f9a1209a288659d4f8f0c59067525ea/src/Frontend/src/components/EventLogItem.vue#L30-L33 and it seems we are only looking at the first item!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeh looks that way, only the leading /message/ link. Ill add a board card to discuss.

// rather than the shape of MessageFailed.
class EventLogWriterTests : PersistenceTestBase
{
public EventLogWriterTests() => RegisterServices = services => services.AddSignalR();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need SignalR?

@warwickschroeder warwickschroeder Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, looks like SignalR is still fully wired up and broadcasting 25 domain event types in ServiceControl... just to nobody it seems!

I did a search in ServicePulse and ServiceInsight and neither are clients of it. Integration events is something seperate and dont use SignalR. I dont know what else to check to make sure this isnt being used. Ill add something to our board to confirm and potentially clean up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's no clients signalR doesn't actually "broadcast" anything, the only cost would be if there's a distributed backplane set up but looking at that it's only using the in-memory one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only product that consumed SignalR was SP a long long time ago, so I think is time to remove it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once (if) SignalR is removed, I will remove it from this test too

Comment thread src/ServiceControl.Persistence.Tests/EventLogWriterTests.cs Outdated
Comment on lines +34 to +39
if (results is null)
{
return StatusCode((int)HttpStatusCode.NotModified);
}

return Ok(results);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels weird.
My thought is that we have middleware that handles it for us

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do have middleware for this, but the problem with that is that it was still querying the database. This skips the query if the version tag matches the header

{
var query = dbContext.EventLogItems.AsNoTracking();

var total = await query.LongCountAsync();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these data pages really so big that doing two database roundtrips is faster than just going to get the data?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reliably create a version id, we need to count all rows. There could be a lot of rows in the event logs. Maybe this would work? AI recons that would be 1 round trip, matching (and improving) what Raven is currently doing as its 1 trip, but also returning data.

var stats = await query
    .GroupBy(_ => 1)
    .Select(g => new { Total = g.LongCount(), Newest = g.Max(e => (DateTime?)e.RaisedAt) })
    .FirstOrDefaultAsync();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep that worked. 1 round trip now.

Comment thread src/ServiceControl.Persistence.EFCore/Infrastructure/RetentionSweeper.cs Outdated

return results;
// Null items means "you already hold this version"
if (results is null)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (results is null)
if (results is null && totalCount > 0)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggestion doesnt look right. results being null means that the If-None-Match matched the current version of the data. I dont think we want to check for actual data

@warwickschroeder
warwickschroeder marked this pull request as draft July 29, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants