feat: implement smart foreground quota refresh with debounce
- Trigger immediate quota poll when main window gains focus. - Add 10s debounce to prevent API spam on frequent window switching. - Add concurrency guard to prevent overlapping poll requests. - Reset background polling interval after manual refresh. - Add unit tests for polling, debounce, and concurrency logic.
This commit is contained in:
1 parent
a988a33d21
commit
dd9e84a0db
3 files changed
+299
-35
No files matched your search
@@ -144,6 +144,10 @@ function createWindow() {
|
||||
const { level, message, lineNumber, sourceId } = details;
|
||||
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
|
||||
});
|
||||
|
||||
mainWindow.on('focus', () => {
|
||||
CloudMonitorService.handleAppFocus();
|
||||
});
|
||||
}
|
||||
|
||||
app.on('child-process-gone', (event, details) => {
|
||||
|
||||
@@ -6,17 +6,28 @@ import { logger } from '../utils/logger';
|
||||
export class CloudMonitorService {
|
||||
private static intervalId: NodeJS.Timeout | null = null;
|
||||
private static POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes
|
||||
private static DEBOUNCE_TIME = 10000; // 10 seconds
|
||||
private static lastFocusTime: number = 0;
|
||||
private static isPolling: boolean = false;
|
||||
|
||||
// Helper for testing
|
||||
static resetStateForTesting() {
|
||||
this.lastFocusTime = 0;
|
||||
this.isPolling = false;
|
||||
this.stop();
|
||||
}
|
||||
|
||||
static start() {
|
||||
if (this.intervalId) return;
|
||||
logger.info('Starting CloudMonitorService...');
|
||||
|
||||
// Set lastFocusTime to now to prevent "double-dip" on startup (focus event immediately after start)
|
||||
this.lastFocusTime = Date.now();
|
||||
|
||||
// Initial Poll
|
||||
this.poll().catch((e) => logger.error('Initial poll failed', e));
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
|
||||
}, this.POLL_INTERVAL);
|
||||
this.startInterval();
|
||||
}
|
||||
|
||||
static stop() {
|
||||
@@ -27,40 +38,95 @@ export class CloudMonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
static async poll() {
|
||||
logger.info('CloudMonitor: Polling quotas...');
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
/**
|
||||
* Called when the application window gains focus.
|
||||
* Triggers an immediate poll if not rate-limited by debounce.
|
||||
*/
|
||||
static async handleAppFocus() {
|
||||
const now = Date.now();
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
|
||||
let accessToken = account.token.access_token;
|
||||
if (account.token.expiry_timestamp < now + 600) {
|
||||
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
||||
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
account.token.access_token = newToken.access_token;
|
||||
account.token.expires_in = newToken.expires_in;
|
||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
accessToken = newToken.access_token;
|
||||
}
|
||||
|
||||
// 2. Fetch Quota
|
||||
// We delay slightly between requests to act human/avoid spike
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const quota = await GoogleAPIService.fetchQuota(accessToken);
|
||||
|
||||
// 3. Update DB
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
} catch (error) {
|
||||
logger.error(`Monitor: Failed to update ${account.email}`, error);
|
||||
// Could mark status as 'error' or 'rate_limited' if 429
|
||||
}
|
||||
// 1. Concurrency Guard: If we are already polling, don't pile up requests
|
||||
if (this.isPolling) {
|
||||
logger.info('Monitor: App focused, but polling is already in progress. Skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Check for Auto-Switch
|
||||
await AutoSwitchService.checkAndSwitchIfNeeded();
|
||||
// 2. Debounce: If we focused recently, don't poll again
|
||||
if (now - this.lastFocusTime < this.DEBOUNCE_TIME) {
|
||||
logger.info('Monitor: App focused, skipping poll (debounce active).');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Monitor: App focused, triggering immediate poll...');
|
||||
this.lastFocusTime = now;
|
||||
|
||||
// 3. Trigger Poll
|
||||
await this.poll().catch((e) => {
|
||||
logger.error('Monitor: Focus poll failed', e);
|
||||
});
|
||||
// 4. Reset the background interval so we don't double-poll shortly after
|
||||
this.resetInterval();
|
||||
}
|
||||
|
||||
private static startInterval() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
this.intervalId = setInterval(() => {
|
||||
this.poll().catch((e) => logger.error('Scheduled poll failed', e));
|
||||
}, this.POLL_INTERVAL);
|
||||
}
|
||||
|
||||
private static resetInterval() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.startInterval(); // Restart the 5-minute timer
|
||||
}
|
||||
}
|
||||
|
||||
static async poll() {
|
||||
if (this.isPolling) {
|
||||
return; // Extra safety
|
||||
}
|
||||
this.isPolling = true;
|
||||
|
||||
try {
|
||||
logger.info('CloudMonitor: Polling quotas...');
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
// 1. Check/Refresh Token if needed (give it a 10 min buffer here for safety)
|
||||
let accessToken = account.token.access_token;
|
||||
if (account.token.expiry_timestamp < now + 600) {
|
||||
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
||||
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
account.token.access_token = newToken.access_token;
|
||||
account.token.expires_in = newToken.expires_in;
|
||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
accessToken = newToken.access_token;
|
||||
}
|
||||
|
||||
// 2. Fetch Quota
|
||||
// We delay slightly between requests to act human/avoid spike
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const quota = await GoogleAPIService.fetchQuota(accessToken);
|
||||
|
||||
// 3. Update DB
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
} catch (error) {
|
||||
logger.error(`Monitor: Failed to update ${account.email}`, error);
|
||||
// Could mark status as 'error' or 'rate_limited' if 429
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check for Auto-Switch
|
||||
await AutoSwitchService.checkAndSwitchIfNeeded();
|
||||
} finally {
|
||||
this.isPolling = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { CloudMonitorService } from '../../services/CloudMonitorService';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { AutoSwitchService } from '../../services/AutoSwitchService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../ipc/database/cloudHandler');
|
||||
vi.mock('../../services/GoogleAPIService');
|
||||
vi.mock('../../services/AutoSwitchService');
|
||||
vi.mock('../../utils/logger');
|
||||
|
||||
describe('CloudMonitorService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
CloudMonitorService.resetStateForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
CloudMonitorService.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should start polling on start() and set up interval', async () => {
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
|
||||
CloudMonitorService.start();
|
||||
|
||||
// Should call initial poll
|
||||
expect(pollSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Fast forward 5 minutes
|
||||
await vi.advanceTimersByTimeAsync(1000 * 60 * 5);
|
||||
expect(pollSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
pollSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should poll accounts correctly', async () => {
|
||||
const mockAccounts = [
|
||||
{
|
||||
id: 'acc1',
|
||||
email: '[email protected]',
|
||||
token: { access_token: 'valid_token', expiry_timestamp: Date.now() / 1000 + 3600 },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
|
||||
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
|
||||
|
||||
// Start poll but don't await immediately, as it pauses
|
||||
const pollPromise = CloudMonitorService.poll();
|
||||
|
||||
// Advance time to pass the 1s sleep
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Now await
|
||||
await pollPromise;
|
||||
|
||||
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token');
|
||||
expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
|
||||
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
||||
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh token if expired during poll', async () => {
|
||||
const mockAccounts = [
|
||||
{
|
||||
id: 'acc1',
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'old_token',
|
||||
refresh_token: 'ref_token',
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) - 100, // Expired
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue(mockAccounts as never);
|
||||
vi.mocked(GoogleAPIService.refreshAccessToken).mockResolvedValue({
|
||||
access_token: 'new_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
vi.mocked(GoogleAPIService.fetchQuota).mockResolvedValue({ models: {} } as never);
|
||||
|
||||
// Same async pattern
|
||||
const pollPromise = CloudMonitorService.poll();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await pollPromise;
|
||||
|
||||
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
|
||||
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
|
||||
});
|
||||
|
||||
describe('handleAppFocus (Smart Refresh)', () => {
|
||||
it('should trigger poll when focused after debounce time', async () => {
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
|
||||
CloudMonitorService.start();
|
||||
vi.setSystemTime(Date.now() + 20000);
|
||||
|
||||
await CloudMonitorService.handleAppFocus();
|
||||
|
||||
// Called once by start, once by focus
|
||||
expect(pollSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should NOT trigger poll if debounced (focused too soon)', async () => {
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
|
||||
CloudMonitorService.start();
|
||||
|
||||
vi.setSystemTime(Date.now() + 1000);
|
||||
|
||||
await CloudMonitorService.handleAppFocus();
|
||||
|
||||
// Called once by start, 0 by focus
|
||||
expect(pollSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('debounce active'));
|
||||
});
|
||||
|
||||
it('should NOT trigger poll if already polling (concurrency guard)', async () => {
|
||||
// Let's spy on poll to silence start's poll
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
CloudMonitorService.start();
|
||||
pollSpy.mockRestore(); // Restore so we can test the real guard logic
|
||||
|
||||
vi.setSystemTime(Date.now() + 20000);
|
||||
|
||||
// 2. Mock getAccounts to delay
|
||||
let resolveGetAccounts: (value: unknown) => void;
|
||||
const getAccountsPromise = new Promise((resolve) => {
|
||||
resolveGetAccounts = resolve;
|
||||
});
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockImplementation(() => getAccountsPromise as never);
|
||||
|
||||
// 3. Trigger first focus -> starts poll, hangs at getAccounts or sleep
|
||||
// Actually poll() calls getAccounts first thing.
|
||||
const p1 = CloudMonitorService.handleAppFocus();
|
||||
|
||||
// Allow p1 to start execution and enter poll()
|
||||
// We invoke one run loop
|
||||
// But since everything is sync until first await, it should enter poll, set isPolling=true, call getAccounts, and await.
|
||||
|
||||
// 4. Trigger second focus immediately
|
||||
const p2 = CloudMonitorService.handleAppFocus();
|
||||
|
||||
// 5. Release the first poll
|
||||
resolveGetAccounts!([
|
||||
{ id: '1', token: { access_token: 'tok', expiry_timestamp: 9999999999 } },
|
||||
]);
|
||||
|
||||
// Advance timer for the 1s sleep inside poll
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('polling is already in progress'),
|
||||
);
|
||||
// getAccounts should be called once (by the first unblocked poll)
|
||||
// The second one blocked by guard before calling poll
|
||||
expect(CloudAccountRepo.getAccounts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reset interval after successful focus poll', async () => {
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
const setIntervalSpy = vi.spyOn(global, 'setInterval');
|
||||
|
||||
// Spy on poll to consume the start() call
|
||||
const pollSpy = vi.spyOn(CloudMonitorService, 'poll').mockResolvedValue(undefined);
|
||||
CloudMonitorService.start();
|
||||
pollSpy.mockRestore(); // Restore real poll
|
||||
|
||||
vi.setSystemTime(Date.now() + 20000);
|
||||
|
||||
// Needs to handle async poll
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([]);
|
||||
|
||||
const focusPromise = CloudMonitorService.handleAppFocus();
|
||||
await vi.advanceTimersByTimeAsync(1000); // For poll sleep
|
||||
await focusPromise;
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
// 1 for start (initial), 1 for reset = 2
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user