Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public override async Task HandleItemAsync(WorkItemContext context)
}

if (wi.UpgradePlans || wi.RemoveOldUsageStats)
await _organizationRepository.SaveAsync(results.Documents);
await _organizationRepository.SaveAsync(results.Documents, o => o.Notifications(wi.UpgradePlans));

// Sleep so we are not hammering the backend.
await Task.Delay(TimeSpan.FromSeconds(2.5), _timeProvider);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public override async Task HandleItemAsync(WorkItemContext context)
}

if (workItem.UpdateDefaultBotList || workItem.IncrementConfigurationVersion || workItem.RemoveOldUsageStats)
await _projectRepository.SaveAsync(results.Documents);
await _projectRepository.SaveAsync(results.Documents, o => o.Notifications(workItem.UpdateDefaultBotList || workItem.IncrementConfigurationVersion));

// Sleep so we are not hammering the backend.
await Task.Delay(TimeSpan.FromSeconds(2.5), _timeProvider);
Expand Down
4 changes: 2 additions & 2 deletions src/Exceptionless.Core/Migrations/UpdateEventUsage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ private async Task UpdateOrganizationsUsageAsync(MigrationContext context)
}
}

await _organizationRepository.SaveAsync(organization);
await _organizationRepository.SaveAsync(organization, o => o.Notifications(false));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve overage transitions during usage migration

When this repeatable migration repairs a current-month usage total from below the live limit to at or above it, Notifications(false) removes the OrganizationChanged signal that previously refreshed connected clients, while this path publishes no replacement PlanOverage. Users with warm Svelte or Angular organization caches therefore continue seeing the organization as under limit until an unrelated refresh; suppress routine migration notifications, but emit a targeted signal when the repaired current-month total crosses the effective limit.

AGENTS.md reference: AGENTS.md:L74-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in ee08a86: the repeatable usage repair migration now detects only a current-month transition from below the live effective monthly limit to at or above it, saves the repaired usage silently, then publishes one monthly PlanOverage. Repeat runs remain silent because the persisted total is already repaired. The regression reproduced the issue before the fix (Expected 1, Actual 0), runs the migration twice to prove one-shot behavior, and both UpdateEventUsageMigrationTests plus all 27 UsageServiceTests pass; the build is clean.

await UpdateProjectsUsageAsync(context, organization);
processed++;
await context.Lock.RenewAsync();
Expand Down Expand Up @@ -142,7 +142,7 @@ private async Task UpdateProjectsUsageAsync(MigrationContext context, Organizati
usage.Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider);
}

await _projectRepository.SaveAsync(project);
await _projectRepository.SaveAsync(project, o => o.Notifications(false));
}
catch (Exception ex)
{
Expand Down
6 changes: 4 additions & 2 deletions src/Exceptionless.Core/Services/UsageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ await _cache.RemoveAllAsync(new[] {
});

await _cache.SetAsync(GetTotalCacheKey(utcNow, organizationId), usage.Total, TimeSpan.FromHours(8));
await _organizationRepository.SaveAsync(organization);
// Usage counters and last-event timestamps are operational updates, not user-facing entity changes.
await _organizationRepository.SaveAsync(organization, o => o.Notifications(false));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate organization state on plan overage

When this usage save is the one that crosses the monthly or hourly limit, the backend can still emit PlanOverage, but the Svelte client does not refetch organization data for that message: src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte only invalidates queries inside isEntityChangedType, and PlanOverage is not a *Changed entity message. Before this change, the accompanying OrganizationChanged from this SaveAsync refreshed is_over_monthly_limit / is_throttled; with Notifications(false), connected users can remain on stale organization state and miss the overage banner until another organization change or a reload. Please keep an organization invalidation for overage transitions or add a PlanOverage handler in the client.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in f8449f0: the Svelte websocket handler now recognizes PlanOverage and invalidates only the affected organization, organization stats, and organization list queries. The endpoint also evaluates pending usage so the refetch observes the transition. Covered by the targeted frontend query-invalidation test and OrganizationEndpointTests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep clearing throttled state visible to clients

When an organization hits the hourly bucket limit, the new PlanOverage handler refreshes the Svelte organization query to is_throttled: true, but this save is also where the completed bucket's throttled cache key is removed; with repository notifications disabled here, the previous OrganizationChanged refetch that cleared the hourly overage banner is no longer emitted. In an active session that does not remount or focus-refetch after the 5-minute throttle window, users can keep seeing the destructive hourly overage banner even though GetUsageAsync would now return IsThrottled = false; please keep a clear-side invalidation/notification for this state transition.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 15e27df: hourly throttle transitions now write a per-bucket marker before PlanOverage is published. Saving that completed bucket emits one OrganizationChanged while routine usage saves remain silent. Covered by SavePendingUsageAsync_HourlyThrottleClears_PublishesOrganizationChangedMessage; the full UsageServiceTests suite passes.

}
}

Expand Down Expand Up @@ -204,7 +205,8 @@ await _cache.RemoveAllAsync(new[] {

await _cache.SetAsync(GetTotalCacheKey(utcNow, project.OrganizationId, projectId), usage.Total, TimeSpan.FromHours(8));

await _projectRepository.SaveAsync(project);
// Project configuration changes have their own save path and should remain the source of ProjectChanged messages.
await _projectRepository.SaveAsync(project, o => o.Notifications(false));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models';
import { useEventListener } from 'runed';
import { SvelteSet } from 'svelte/reactivity';
import { debounce } from 'throttle-debounce';

import FreePlanNotification from './notifications/free-plan-notification.svelte';
import HourlyOverageNotification from './notifications/hourly-overage-notification.svelte';
Expand Down Expand Up @@ -95,19 +94,21 @@
);
const requiresPremiumUpgrade = $derived(requiresPremium && !organization?.has_premium_features && !needsProjectConfiguration);

const refetchConfigurationState = debounce(1500, async () => {
await Promise.all([organizationQuery.refetch(), projectsQuery.refetch()]);
});

useEventListener(document, 'PersistentEventChanged', (event) => {
const message = (event as CustomEvent<WebSocketMessageValue<'PersistentEventChanged'>>).detail;

if (message.change_type === ChangeType.Removed || message.organization_id !== currentOrganizationId.current || !message.project_id) {
const projectNeedsConfiguration = projects.some((project) => project.id === message.project_id && project.is_configured === false);

if (
message.change_type === ChangeType.Removed ||
message.organization_id !== currentOrganizationId.current ||
!message.project_id ||
!projectNeedsConfiguration ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record configuration events before project data loads

When a PersistentEventChanged message arrives while the projects query is still loading, projects is empty, so this guard discards the message instead of recording its project_id. If the initial query then returns the project with is_configured: false before the asynchronous SetProjectIsConfiguredWorkItem finishes, the setup notification remains visible until that job publishes ProjectChanged (or indefinitely if the job is delayed or fails), whereas the previous handler recorded the event regardless of query timing. Preserve valid IDs even when the project has not loaded yet.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 910499a: valid PersistentEventChanged project IDs are now recorded immediately for the current organization, without requiring the projects query to have loaded first. Once project data arrives, the local configured-ID set suppresses the stale setup notification without refetching organization or project data. Added a focused early-event regression; Svelte validation is clean and all 404 frontend unit tests pass.

configuredProjectIds.has(message.project_id)
) {
return;
}

configuredProjectIds.add(message.project_id);
void refetchConfigurationState();
});
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ChangeType } from '$features/websockets/models';
import { render } from '@testing-library/svelte';
import { tick } from 'svelte';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import OrganizationNotifications from './organization-notifications.svelte';

const organizationRefetch = vi.hoisted(() => vi.fn());
const projectsRefetch = vi.hoisted(() => vi.fn());
const projects = vi.hoisted(() => [{ id: 'project-id', is_configured: false, organization_id: 'organization-id' }]);

vi.mock('$features/organizations/api.svelte', () => ({
getOrganizationQuery: () => ({ data: undefined, refetch: organizationRefetch }),
getOrganizationsQuery: () => ({ data: { data: [] } })
}));

vi.mock('$features/organizations/context.svelte', () => ({
organization: { current: 'organization-id' }
}));

vi.mock('$features/projects/api.svelte', () => ({
getOrganizationProjectsQuery: () => ({ data: { data: projects }, isSuccess: true, refetch: projectsRefetch })
}));

vi.mock('$features/users/api.svelte', () => ({
getMeQuery: () => ({ data: { organization_ids: ['organization-id'], roles: [] } })
}));

describe('OrganizationNotifications', () => {
beforeEach(() => {
organizationRefetch.mockReset();
projectsRefetch.mockReset();
projects[0]!.is_configured = false;
});

it('does not refetch organization or project state for persistent event changes', async () => {
render(OrganizationNotifications, {
isChatEnabled: false,
openChat: vi.fn()
});

const message = {
change_type: ChangeType.Added,
organization_id: 'organization-id',
project_id: 'project-id'
};

document.dispatchEvent(new CustomEvent('PersistentEventChanged', { detail: message }));
document.dispatchEvent(new CustomEvent('PersistentEventChanged', { detail: message }));
await tick();

expect(organizationRefetch).not.toHaveBeenCalled();
expect(projectsRefetch).not.toHaveBeenCalled();
});

it('does not refresh configuration state for an already configured project', async () => {
projects[0]!.is_configured = true;
render(OrganizationNotifications, {
isChatEnabled: false,
openChat: vi.fn()
});

document.dispatchEvent(
new CustomEvent('PersistentEventChanged', {
detail: {
change_type: ChangeType.Added,
organization_id: 'organization-id',
project_id: 'project-id'
}
})
);
await tick();

expect(organizationRefetch).not.toHaveBeenCalled();
expect(projectsRefetch).not.toHaveBeenCalled();
});
});
41 changes: 41 additions & 0 deletions tests/Exceptionless.Tests/Services/UsageServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
using Foundatio.AsyncEx;
using Foundatio.Messaging;
using Foundatio.Repositories;
using Foundatio.Repositories.Elasticsearch;
using Foundatio.Repositories.Models;
using Xunit;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;

Expand Down Expand Up @@ -273,6 +275,45 @@ await messageBus.SubscribeAsync<PlanOverage>(po =>
Assert.Equal(0, usage.Deleted);
}

[Fact]
public async Task SavePendingUsageAsync_UsageOnlyChanges_DoesNotPublishEntityChangedMessages()
{
// Arrange
var organization = await _organizationRepository.AddAsync(new Organization { Name = "Test", MaxEventsPerMonth = 750, PlanId = _plans.SmallPlan.Id }, o => o.ImmediateConsistency().Cache());
var project = await _projectRepository.AddAsync(new Project { Name = "Test", OrganizationId = organization.Id, NextSummaryEndOfDayTicks = TimeProvider.GetUtcNow().UtcDateTime.Ticks }, o => o.ImmediateConsistency().Cache());
var organizationRepository = Assert.IsAssignableFrom<ElasticRepositoryBase<Organization>>(_organizationRepository);
var projectRepository = Assert.IsAssignableFrom<ElasticRepositoryBase<Project>>(_projectRepository);
int notificationCount = 0;
Func<object, BeforePublishEntityChangedEventArgs<Organization>, Task> organizationHandler = (_, _) =>
{
Interlocked.Increment(ref notificationCount);
return Task.CompletedTask;
};
Func<object, BeforePublishEntityChangedEventArgs<Project>, Task> projectHandler = (_, _) =>
{
Interlocked.Increment(ref notificationCount);
return Task.CompletedTask;
};
organizationRepository.BeforePublishEntityChanged.AddHandler(organizationHandler);
projectRepository.BeforePublishEntityChanged.AddHandler(projectHandler);

try
{
// Act
await _usageService.IncrementTotalAsync(organization.Id, project.Id);
TimeProvider.Advance(TimeSpan.FromMinutes(10));
await _usageService.SavePendingUsageAsync();

// Assert
Assert.Equal(0, notificationCount);
}
finally
{
organizationRepository.BeforePublishEntityChanged.RemoveHandler(organizationHandler);
projectRepository.BeforePublishEntityChanged.RemoveHandler(projectHandler);
}
}

[Fact]
public async Task CanGetEventsLeft()
{
Expand Down
Loading