Skip to content

Commit 1284cb3

Browse files
authored
Merge pull request #27 from devicecloud-dev/feat/realtime-results
feat: realtime test status with polling backstop
2 parents 67366c0 + 74d76ce commit 1284cb3

7 files changed

Lines changed: 400 additions & 58 deletions

File tree

src/commands/cloud.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
EiOSVersions,
2828
} from '../types/domain/device.types';
2929
import { resolveAuth } from '../utils/auth';
30+
import { isCI } from '../utils/ci';
3031
import {
3132
CliError,
3233
coerceArray,
@@ -324,6 +325,16 @@ export const cloudCommand = defineCommand({
324325

325326
const auth = await resolveAuth({ apiKeyFlag });
326327

328+
// Nudge interactive api-key users toward `dcd login`, which unlocks live
329+
// (realtime) status updates. Suppressed in CI and non-interactive output.
330+
if (auth.mode === 'apiKey' && !json && !quiet && !isCI()) {
331+
out(
332+
colors.dim(
333+
'Tip: run `dcd login` for live test updates and a smoother experience than passing --api-key.',
334+
),
335+
);
336+
}
337+
327338
let compatibilityData: CompatibilityData;
328339
try {
329340
compatibilityData = await fetchCompatibilityData(apiUrl, auth);

src/gateways/realtime-gateway.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Supabase Realtime subscription to test-result status changes.
3+
*
4+
* This is a *latency optimisation* layered on top of HTTP polling, not a
5+
* replacement for it. A logged-in (bearer) user's JWT authorises a
6+
* `postgres_changes` subscription on the `results` table (RLS scopes rows to
7+
* the user's org via `app_metadata.org_ids`). Each relevant change fires
8+
* `onChange`, which the polling loop uses to fetch immediately instead of
9+
* waiting out the full backstop interval.
10+
*
11+
* We deliberately only read the `new` row's `test_upload_id` — Postgres always
12+
* ships the full new tuple on INSERT/UPDATE regardless of REPLICA IDENTITY, so
13+
* no DB change is required. Mirrors the frontend pattern in
14+
* dcd/frontend/app/stores/Results.store.ts.
15+
*/
16+
import {
17+
createClient,
18+
REALTIME_SUBSCRIBE_STATES,
19+
type SupabaseClient,
20+
} from '@supabase/supabase-js';
21+
22+
import { ENVIRONMENTS, type DcdEnvName } from '../config/environments';
23+
24+
export interface RealtimeResultsSubscription {
25+
/** Tear down the channel and close the socket. Best-effort, never throws. */
26+
unsubscribe(): Promise<void>;
27+
}
28+
29+
export interface RealtimeSubscribeOptions {
30+
/** Supabase JWT (AuthContext.accessToken) — authorises the socket for RLS. */
31+
accessToken: string;
32+
debug?: boolean;
33+
env: DcdEnvName;
34+
/** Stderr-safe logger; stdout is reserved by some callers (MCP). */
35+
log?: (message: string) => void;
36+
/** Fired when a result row for this upload changes. */
37+
onChange: () => void;
38+
orgId: string;
39+
uploadId: string;
40+
}
41+
42+
/**
43+
* The `new` record we care about from a `results` row change. Loosely typed —
44+
* we only ever read `test_upload_id`.
45+
*/
46+
interface ResultChangePayload {
47+
new?: { test_upload_id?: string } | null;
48+
}
49+
50+
export class RealtimeResultsGateway {
51+
/**
52+
* Open a realtime subscription to `results` changes for the given upload.
53+
* Construction never throws — on any failure the caller simply keeps polling.
54+
*/
55+
static subscribe(
56+
options: RealtimeSubscribeOptions,
57+
): RealtimeResultsSubscription {
58+
const { accessToken, debug, env, log, onChange, orgId, uploadId } = options;
59+
const dbg = (message: string) => {
60+
if (debug && log) log(`[DEBUG] [realtime] ${message}`);
61+
};
62+
63+
let client: SupabaseClient | undefined;
64+
try {
65+
const { url, anonKey } = ENVIRONMENTS[env].supabase;
66+
client = createClient(url, anonKey, {
67+
// Match SupabaseClientGateway / the frontend; no session persistence —
68+
// we set the token explicitly below.
69+
auth: { autoRefreshToken: false, persistSession: false },
70+
realtime: { params: { eventsPerSecond: 10 } },
71+
});
72+
73+
// Attach the user's JWT to the socket so RLS is enforced on the channel.
74+
client.realtime.setAuth(accessToken);
75+
76+
const channel = client
77+
.channel(`results-cli-${uploadId}`)
78+
.on(
79+
// The typings don't cover the postgres_changes overload cleanly.
80+
'postgres_changes' as never,
81+
{
82+
event: '*',
83+
schema: 'public',
84+
table: 'results',
85+
filter: `org_id=eq.${orgId}`,
86+
} as never,
87+
(payload: ResultChangePayload) => {
88+
if (payload.new?.test_upload_id === uploadId) {
89+
dbg(`change for upload ${uploadId}`);
90+
onChange();
91+
}
92+
},
93+
)
94+
.subscribe((status) => {
95+
if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {
96+
dbg('subscribed');
97+
} else if (
98+
status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR ||
99+
status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT ||
100+
status === REALTIME_SUBSCRIBE_STATES.CLOSED
101+
) {
102+
// Don't try to recover — the backstop poll covers us. Surface in
103+
// debug so a network-blocked websocket is diagnosable.
104+
dbg(`channel ${status}; relying on backstop poll`);
105+
}
106+
});
107+
108+
const activeClient = client;
109+
return {
110+
async unsubscribe() {
111+
try {
112+
await activeClient.removeChannel(channel);
113+
await activeClient.realtime.disconnect();
114+
} catch {
115+
/* best effort — process is exiting anyway */
116+
}
117+
},
118+
};
119+
} catch (error) {
120+
dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`);
121+
// Best-effort cleanup of a half-built client, then degrade to polling.
122+
try {
123+
client?.realtime.disconnect();
124+
} catch {
125+
/* ignore */
126+
}
127+
return { async unsubscribe() {} };
128+
}
129+
}
130+
}

src/services/results-polling.service.ts

Lines changed: 134 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import * as path from 'node:path';
22

33
import { ApiGateway } from '../gateways/api-gateway';
4+
import {
5+
RealtimeResultsGateway,
6+
type RealtimeResultsSubscription,
7+
} from '../gateways/realtime-gateway';
48
import { formatDurationSeconds } from '../methods';
59
import type { AuthContext } from '../types/domain/auth.types';
610
import { paths } from '../types/generated/schema.types';
@@ -65,12 +69,17 @@ export interface PollingResult {
6569
*/
6670
export class ResultsPollingService {
6771
// The run keeps executing in the cloud regardless of whether the CLI can
68-
// poll, so tolerate a long stretch of transient API/network blips (~5 min at
69-
// the 10s base interval) before giving up. Losing a run to a brief hiccup is
70-
// far more costly than waiting a bit longer.
72+
// poll, so tolerate a long stretch of transient API/network blips before
73+
// giving up. Losing a run to a brief hiccup is far more costly than waiting a
74+
// bit longer.
7175
private readonly MAX_SEQUENTIAL_FAILURES = 30;
72-
private readonly POLL_INTERVAL_MS = 10_000;
73-
// Cap for the backoff applied between failed polls.
76+
// Backstop poll cadence. Logged-in (bearer) users also get realtime pushes
77+
// (see RealtimeResultsGateway), so they only need an occasional reconciling
78+
// poll; api-key users have no realtime and rely on the faster interval.
79+
private readonly BEARER_POLL_INTERVAL_MS = 60_000;
80+
private readonly APIKEY_POLL_INTERVAL_MS = 20_000;
81+
// Base unit for the backoff applied between *failed* polls, and its cap.
82+
private readonly ERROR_BACKOFF_BASE_MS = 10_000;
7483
private readonly MAX_ERROR_BACKOFF_MS = 30_000;
7584

7685
/**
@@ -107,70 +116,137 @@ export class ResultsPollingService {
107116
let sequentialPollFailures = 0;
108117
let previousSummary = '';
109118

119+
const pollIntervalMs =
120+
auth.mode === 'bearer'
121+
? this.BEARER_POLL_INTERVAL_MS
122+
: this.APIKEY_POLL_INTERVAL_MS;
123+
124+
// "Poke" mechanism: a realtime change resolves the current inter-poll wait
125+
// early. If a poke lands while we're mid-fetch (not waiting) it's latched
126+
// and consumed by the next wait, so events are never silently dropped.
127+
let resolveWake: (() => void) | null = null;
128+
let pendingPoke = false;
129+
const poke = () => {
130+
if (resolveWake) {
131+
const r = resolveWake;
132+
resolveWake = null;
133+
r();
134+
} else {
135+
pendingPoke = true;
136+
}
137+
};
138+
const waitForNextPoll = (ms: number): Promise<void> => {
139+
if (pendingPoke) {
140+
pendingPoke = false;
141+
return Promise.resolve();
142+
}
143+
return new Promise<void>((resolve) => {
144+
const timer = setTimeout(() => {
145+
resolveWake = null;
146+
resolve();
147+
}, ms);
148+
resolveWake = () => {
149+
clearTimeout(timer);
150+
resolve();
151+
};
152+
});
153+
};
154+
155+
// Realtime is a latency optimisation over the backstop poll; only logged-in
156+
// (bearer) users can authenticate the socket under RLS. Any failure inside
157+
// the gateway degrades silently to pure polling.
158+
let subscription: RealtimeResultsSubscription | undefined;
159+
if (auth.mode === 'bearer' && auth.accessToken && auth.orgId && auth.env) {
160+
subscription = RealtimeResultsGateway.subscribe({
161+
accessToken: auth.accessToken,
162+
debug,
163+
env: auth.env,
164+
log: logger,
165+
onChange: poke,
166+
orgId: auth.orgId,
167+
uploadId,
168+
});
169+
if (debug && logger) {
170+
logger(
171+
`[DEBUG] Realtime enabled; backstop poll every ${pollIntervalMs / 1000}s`,
172+
);
173+
}
174+
}
175+
110176
if (debug && logger) {
111177
logger(`[DEBUG] Starting polling loop for results`);
112178
}
113179

114-
// Poll in a loop until all tests complete
115-
// eslint-disable-next-line no-constant-condition
116-
while (true) {
117-
try {
118-
const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
119-
120-
const { summary } = this.calculateStatusSummary(updatedResults);
121-
previousSummary = this.updateDisplayStatus(
122-
updatedResults,
123-
quiet,
124-
json,
125-
summary,
126-
previousSummary,
127-
);
180+
try {
181+
// Poll in a loop until all tests complete
182+
// eslint-disable-next-line no-constant-condition
183+
while (true) {
184+
try {
185+
const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
186+
187+
const { summary } = this.calculateStatusSummary(updatedResults);
188+
previousSummary = this.updateDisplayStatus(
189+
updatedResults,
190+
quiet,
191+
json,
192+
summary,
193+
previousSummary,
194+
);
195+
196+
const allComplete = updatedResults.every(
197+
(result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status),
198+
);
199+
200+
if (allComplete) {
201+
return await this.handleCompletedTests(updatedResults, {
202+
consoleUrl,
203+
debug,
204+
json,
205+
logger,
206+
testMetadata,
207+
uploadId,
208+
});
209+
}
128210

129-
const allComplete = updatedResults.every(
130-
(result) => !['PENDING', 'QUEUED', 'RUNNING'].includes(result.status),
131-
);
211+
// Reset failure counter on successful poll
212+
sequentialPollFailures = 0;
213+
214+
// Wait for the next backstop poll, or a realtime poke, whichever comes
215+
// first.
216+
await waitForNextPoll(pollIntervalMs);
217+
} catch (error) {
218+
// Re-throw RunFailedError immediately (test failures, not polling errors)
219+
if (error instanceof RunFailedError) {
220+
throw error;
221+
}
222+
223+
sequentialPollFailures++;
132224

133-
if (allComplete) {
134-
return await this.handleCompletedTests(updatedResults, {
135-
consoleUrl,
225+
// Handle polling errors (network issues, etc.)
226+
await this.handlePollingError(
227+
error,
228+
sequentialPollFailures,
136229
debug,
137-
json,
138230
logger,
139-
testMetadata,
140231
uploadId,
141-
});
232+
);
233+
234+
// Back off (capped) before retrying so a flaky API gets some breathing
235+
// room instead of being hammered on every failure.
236+
await this.sleep(
237+
Math.min(
238+
this.ERROR_BACKOFF_BASE_MS * sequentialPollFailures,
239+
this.MAX_ERROR_BACKOFF_MS,
240+
),
241+
);
142242
}
143-
144-
// Reset failure counter on successful poll
145-
sequentialPollFailures = 0;
146-
147-
// Wait before next poll
148-
await this.sleep(this.POLL_INTERVAL_MS);
149-
} catch (error) {
150-
// Re-throw RunFailedError immediately (test failures, not polling errors)
151-
if (error instanceof RunFailedError) {
152-
throw error;
243+
}
244+
} finally {
245+
if (subscription) {
246+
if (debug && logger) {
247+
logger('[DEBUG] Closing realtime subscription');
153248
}
154-
155-
sequentialPollFailures++;
156-
157-
// Handle polling errors (network issues, etc.)
158-
await this.handlePollingError(
159-
error,
160-
sequentialPollFailures,
161-
debug,
162-
logger,
163-
uploadId,
164-
);
165-
166-
// Back off (capped) before retrying so a flaky API gets some breathing
167-
// room instead of being hammered every 10s.
168-
await this.sleep(
169-
Math.min(
170-
this.POLL_INTERVAL_MS * sequentialPollFailures,
171-
this.MAX_ERROR_BACKOFF_MS,
172-
),
173-
);
249+
await subscription.unsubscribe();
174250
}
175251
}
176252
}

0 commit comments

Comments
 (0)