Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
1caebdc
Replace notification polling with Server-Sent Events (SSE)
mvdbeek Apr 16, 2026
a4ca9e2
Fix SSE control-queue routing for Celery + post-fork workers
mvdbeek Apr 16, 2026
88699a4
Fix history SSE fallback switching and forced refresh
mvdbeek Apr 16, 2026
b0d67e2
Split SSEEventDispatcher into its own module to drop inline imports
mvdbeek Apr 16, 2026
7e51a98
Add pg_notify to history audit triggers for existing installs
mvdbeek Apr 17, 2026
c25bc0b
Drive history/notifications SSE from config, not runtime socket state
mvdbeek Apr 17, 2026
1a83faf
Skip response buffering for non-HTML in galaxy-dev-server plugin
mvdbeek Apr 17, 2026
abd853a
Align history-audit trigger SQL between migration and runtime installer
mvdbeek Apr 17, 2026
9271e46
Address review feedback for SSE notifications
mvdbeek Apr 17, 2026
e815ce4
Ruff
mvdbeek Apr 17, 2026
cc99efb
Add category discriminator to notification content fixtures
mvdbeek Apr 17, 2026
e4aa51b
Fix mypy errors on sse-notifications branch
mvdbeek Apr 19, 2026
cedc5cb
Add SSE entry-point channel, dispatch observability, declare-queue cache
mvdbeek Apr 20, 2026
1863a76
Expose enable_sse_entry_point_updates through /api/configuration
mvdbeek Apr 20, 2026
46ca4f0
Fix SSE selenium tests for Playwright backend and user identity
mvdbeek Apr 21, 2026
ece52a4
Add types-cachetools to typecheck deps
mvdbeek Apr 21, 2026
0bec160
Swap Playwright text= selector for xpath in notification SSE test
mvdbeek Apr 23, 2026
da50d5e
Apply black formatting to SSE test
mvdbeek Apr 23, 2026
fee5e5f
TEMP: enable notification/SSE flags across all UI test workflows
mvdbeek Apr 23, 2026
c0cde1a
Route history_update SSE events for anonymous sessions
mvdbeek Apr 23, 2026
b4bf73a
Close EventSource on pagehide so full-page navigation doesn't race th…
mvdbeek Apr 23, 2026
57f1aba
Guarantee playwright.stop() runs even when browser.close() raises
mvdbeek Apr 23, 2026
4c095fc
Cancel background traffic before login/register so session cookie isn…
mvdbeek Apr 24, 2026
c386c5d
Tear down selenium driver when setUp fails before login completes
mvdbeek Apr 24, 2026
d6250e2
Extend auth-navigation abort to GalaxyApi (openapi-fetch) requests
mvdbeek Apr 24, 2026
ae520be
Skip SSE/polling watchers in iframed Galaxy instances
mvdbeek Apr 24, 2026
08f1fb4
Apply prettier to pendingRequestsMiddleware
mvdbeek Apr 24, 2026
2dc2c5b
Unify SSE: single shared EventSource, drop /api/notifications/stream
mvdbeek Apr 24, 2026
225ca58
Fix failing SSE integration tests
mvdbeek Apr 24, 2026
fca0cb0
Elect single history-audit monitor; add standalone daemon
mvdbeek Apr 24, 2026
b577670
Prune Kombu SQLAlchemy transport on a schedule
mvdbeek Apr 24, 2026
5cf90b8
Relocate queue_metrics; narrow its deps
mvdbeek Apr 24, 2026
a949308
Inject ControlTask factory + queues provider into SSEEventDispatcher
mvdbeek Apr 24, 2026
b3dfeba
Address arch-review polish items
mvdbeek Apr 24, 2026
07e8d85
Fix CI: Callable import + ControlTaskLike Protocol
mvdbeek Apr 24, 2026
88b8371
Fix CI: widen control_task_factory return to Any
mvdbeek Apr 24, 2026
a554aa2
Reorder NotificationManager import (isort)
mvdbeek Apr 24, 2026
8cf28fe
Apply black formatting
mvdbeek Apr 24, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/integration_selenium.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ env:
YARN_INSTALL_OPTS: --frozen-lockfile
GALAXY_CONFIG_SQLALCHEMY_WARN_20: '1'
GALAXY_DEPENDENCIES_INSTALL_WEASYPRINT: '1'
# TEMP: shake down SSE/notification system across full UI surface — revert before merge
GALAXY_CONFIG_OVERRIDE_ENABLE_NOTIFICATION_SYSTEM: '1'
GALAXY_CONFIG_OVERRIDE_ENABLE_SSE_HISTORY_UPDATES: '1'
GALAXY_CONFIG_OVERRIDE_ENABLE_SSE_ENTRY_POINT_UPDATES: '1'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/playwright.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ env:
GALAXY_TEST_SELENIUM_HEADLESS: 1
YARN_INSTALL_OPTS: --frozen-lockfile
GALAXY_CONFIG_SQLALCHEMY_WARN_20: '1'
# TEMP: shake down SSE/notification system across full UI surface — revert before merge
GALAXY_CONFIG_OVERRIDE_ENABLE_NOTIFICATION_SYSTEM: '1'
GALAXY_CONFIG_OVERRIDE_ENABLE_SSE_HISTORY_UPDATES: '1'
GALAXY_CONFIG_OVERRIDE_ENABLE_SSE_ENTRY_POINT_UPDATES: '1'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/selenium.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ env:
GALAXY_TEST_SKIP_FLAKEY_TESTS_ON_ERROR: 1
YARN_INSTALL_OPTS: --frozen-lockfile
GALAXY_CONFIG_SQLALCHEMY_WARN_20: '1'
# TEMP: shake down SSE/notification system across full UI surface — revert before merge
GALAXY_CONFIG_OVERRIDE_ENABLE_NOTIFICATION_SYSTEM: '1'
GALAXY_CONFIG_OVERRIDE_ENABLE_SSE_HISTORY_UPDATES: '1'
GALAXY_CONFIG_OVERRIDE_ENABLE_SSE_ENTRY_POINT_UPDATES: '1'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
4 changes: 4 additions & 0 deletions client/src/api/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import createClient from "openapi-fetch";

import { pendingRequestsMiddleware } from "@/api/client/pendingRequestsMiddleware";
import { createRateLimiterMiddleware } from "@/api/client/rateLimiter";
import type { GalaxyApiPaths } from "@/api/schema";
import { getAppRoot } from "@/onload/loadConfig";
Expand All @@ -12,6 +13,9 @@ function getBaseUrl() {
function apiClientFactory() {
const client = createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });

// Registered first so aborted requests bypass the rate-limiter queue.
client.use(pendingRequestsMiddleware);

// TODO: Adjust based on server limits (maybe this goes in Galaxy config?)
client.use(
createRateLimiterMiddleware({
Expand Down
26 changes: 26 additions & 0 deletions client/src/api/client/pendingRequestsMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Middleware } from "openapi-fetch";

import { getPendingAbortSignal, SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";

/**
* Attaches the shared pending-requests signal to every ``GalaxyApi`` request.
* The ``openapi-fetch`` client uses native ``fetch()`` so the axios
* interceptor does not apply; without this middleware, login/register
* navigations cannot cancel in-flight ``/api/...`` calls and a late
* anonymous-cookie response can clobber the authenticated ``galaxysession``
* cookie. See ``client/src/api/pendingRequests.ts`` for the race.
*/
export const pendingRequestsMiddleware: Middleware = {
async onRequest({ request }) {
if (request.headers.has(SKIP_PENDING_REQUESTS_HEADER)) {
const headers = new Headers(request.headers);
headers.delete(SKIP_PENDING_REQUESTS_HEADER);
return new Request(request, { headers });
}
const shared = getPendingAbortSignal();
// Combine with any signal the caller may have set so we don't silently
// drop their cancellation semantics.
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([request.signal, shared]) : shared;
return new Request(request, { signal });
},
};
58 changes: 58 additions & 0 deletions client/src/api/pendingRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Shared ``AbortController`` used by both the axios interceptor and the
* ``openapi-fetch`` middleware so we can cancel every in-flight request in
* one shot right before a login/register navigation.
*
* Why this exists: when the server processes a request that carries the old
* anonymous ``galaxysession`` cookie *after* ``handle_user_login`` has marked
* that session ``is_valid=False``, it creates a fresh anonymous session and
* responds with ``Set-Cookie: galaxysession=<new>``. Delivered into the cookie
* jar after the login response but before the new page loads, it overwrites
* the just-issued authenticated cookie — so the new page loads anonymous and
* ``wait_for_logged_in`` times out in selenium. Aborting the TCP connection
* before the response headers are parsed prevents that ``Set-Cookie`` from
* ever applying.
*/
import axios, { type InternalAxiosRequestConfig } from "axios";

let activeController = new AbortController();

/** Explicit opt-out header that the login/register POST itself sets. */
export const SKIP_PENDING_REQUESTS_HEADER = "x-galaxy-skip-pending-abort";

/**
* The signal every request should ride on by default. Read lazily so the
* ``openapi-fetch`` middleware picks up the fresh signal after each
* ``cancelPendingRequests()`` rotation.
*/
export function getPendingAbortSignal(): AbortSignal {
return activeController.signal;
}

/**
* Install a request interceptor that attaches the shared signal to every
* outgoing axios request that didn't set one itself. Call once at app boot.
*/
export function installPendingRequestsInterceptor() {
axios.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (config.signal !== undefined) {
return config;
}
if (config.headers?.[SKIP_PENDING_REQUESTS_HEADER]) {
delete config.headers[SKIP_PENDING_REQUESTS_HEADER];
return config;
}
config.signal = activeController.signal;
return config;
});
}

/**
* Abort every request that is using the shared signal (both axios via the
* interceptor and ``openapi-fetch`` via its middleware) and install a fresh
* controller so subsequent requests can still go out.
*/
export function cancelPendingRequests() {
activeController.abort();
activeController = new AbortController();
}
67 changes: 67 additions & 0 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,33 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/events/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Server-Sent Events stream for real-time updates.
* @description Opens a Server-Sent Events (SSE) connection that pushes real-time
* updates for notifications, history changes, and other events.
*
* On reconnect, the browser sends the ``Last-Event-ID`` header automatically.
* If the notification system is enabled, any notifications created since that
* timestamp are delivered as a catch-up ``notification_status`` event.
*
* Anonymous users receive only broadcast events.
*/
get: operations["stream_events_api_events_stream_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/exports": {
parameters: {
query?: never;
Expand Down Expand Up @@ -33194,6 +33221,46 @@ export interface operations {
};
};
};
stream_events_api_events_stream_get: {
parameters: {
query?: never;
header?: {
"Last-Event-ID"?: string | null;
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
"run-as"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Request Error */
"4XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
/** @description Server Error */
"5XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
};
};
index_api_exports_get: {
parameters: {
query?: {
Expand Down
23 changes: 17 additions & 6 deletions client/src/components/Login/LoginForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
import { computed, ref } from "vue";
import { useRouter } from "vue-router/composables";

import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
import localize from "@/utils/localization";
import { withPrefix } from "@/utils/redirect";
import { errorMessageAsString } from "@/utils/simple-error";
Expand Down Expand Up @@ -86,13 +88,22 @@ async function submitLogin() {
redirect = props.redirect ?? null;
}

// Close SSE, stop polling, and abort in-flight axios before sending the
// login POST — otherwise a late anonymous-cookie response can overwrite
// the authenticated cookie we're about to receive.
discardActiveConnectionsBeforeAuthNavigation();

try {
const response = await axios.post(withPrefix("/user/login"), {
login: login.value,
password: password.value,
redirect: redirect,
session_csrf_token: props.sessionCsrfToken,
});
const response = await axios.post(
withPrefix("/user/login"),
{
login: login.value,
password: password.value,
redirect: redirect,
session_csrf_token: props.sessionCsrfToken,
},
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
);

if (response.data.message && response.data.status) {
alert(response.data.message);
Expand Down
27 changes: 19 additions & 8 deletions client/src/components/Register/RegisterForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
} from "bootstrap-vue";
import { computed, type Ref, ref } from "vue";

import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
import { getOIDCIdpsWithRegistration, type OIDCConfig } from "@/components/User/ExternalIdentities/ExternalIDHelper";
import { Toast } from "@/composables/toast";
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
import localize from "@/utils/localization";
import { withPrefix } from "@/utils/redirect";
import { errorMessageAsString } from "@/utils/simple-error";
Expand Down Expand Up @@ -72,15 +74,24 @@ const registerColumnDisplay = computed(() => Boolean(props.termsUrl));
async function submit() {
disableCreate.value = true;

// Close SSE, stop polling, and abort in-flight axios before sending the
// register POST — otherwise a late anonymous-cookie response can overwrite
// the authenticated cookie we're about to receive.
discardActiveConnectionsBeforeAuthNavigation();

try {
const response = await axios.post(withPrefix("/user/create"), {
email: email.value,
username: username.value,
password: password.value,
confirm: confirm.value,
subscribe: subscribe.value,
session_csrf_token: props.sessionCsrfToken,
});
const response = await axios.post(
withPrefix("/user/create"),
{
email: email.value,
username: username.value,
password: password.value,
confirm: confirm.value,
subscribe: subscribe.value,
session_csrf_token: props.sessionCsrfToken,
},
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
);

if (response.data.message && response.data.status) {
Toast.info(response.data.message);
Expand Down
26 changes: 26 additions & 0 deletions client/src/composables/useAuthNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { cancelPendingRequests } from "@/api/pendingRequests";
import { useEntryPointStore } from "@/stores/entryPointStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useNotificationsStore } from "@/stores/notificationsStore";

/**
* Tear down every long-lived connection and cancel every in-flight request
* (both axios and ``openapi-fetch``/GalaxyApi) so that nothing issued under
* the old anonymous ``galaxysession`` cookie can land on the server after
* ``handle_user_login`` has invalidated it. See
* ``client/src/api/pendingRequests.ts`` for the race this guards against.
*
* Call this synchronously as the first step of a login or registration
* submit, before the authenticating POST goes out. The shared abort
* controller is rotated, so the login/register POST (issued right after)
* will use a fresh signal and is not affected.
*/
export function discardActiveConnectionsBeforeAuthNavigation() {
// Order: close SSE streams first (synchronous TCP close), then stop the
// polling watchers so they can't kick off new fetches, then abort any
// requests still in flight via the shared AbortController.
useHistoryStore().stopWatchingHistory();
useEntryPointStore().stopWatchingEntryPoints();
useNotificationsStore().stopWatchingNotifications();
cancelPendingRequests();
}
Loading
Loading