Skip to content

Commit cf4b5cc

Browse files
authored
chore: global sync per minute safety limit (#2765)
1 parent 3d895ca commit cf4b5cc

File tree

11 files changed

+140
-10
lines changed

11 files changed

+140
-10
lines changed

packages/services/src/Domain/Application/Options/OptionalOptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export interface ApplicationOptionalConfiguratioOptions {
1818
*/
1919
webSocketUrl?: string
2020

21+
/**
22+
* Amount sync calls allowed per minute.
23+
*/
24+
syncCallsThresholdPerMinute?: number
25+
2126
/**
2227
* 3rd party library function for prompting U2F authenticator device registration
2328
*

packages/snjs/lib/Application/Dependencies/Dependencies.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,15 @@ import { Logger, isNotUndefined, isDeinitable, LoggerInterface } from '@standard
176176
import { EncryptionOperators } from '@standardnotes/encryption'
177177
import { AsymmetricMessagePayload, AsymmetricMessageSharedVaultInvite } from '@standardnotes/models'
178178
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
179+
import { SyncFrequencyGuard } from '@Lib/Services/Sync/SyncFrequencyGuard'
180+
import { SyncFrequencyGuardInterface } from '@Lib/Services/Sync/SyncFrequencyGuardInterface'
179181

180182
export class Dependencies {
181183
private factory = new Map<symbol, () => unknown>()
182184
private dependencies = new Map<symbol, unknown>()
183185

186+
private DEFAULT_SYNC_CALLS_THRESHOLD_PER_MINUTE = 200
187+
184188
constructor(private options: FullyResolvedApplicationOptions) {
185189
this.dependencies.set(TYPES.DeviceInterface, options.deviceInterface)
186190
this.dependencies.set(TYPES.AlertService, options.alertService)
@@ -1341,6 +1345,12 @@ export class Dependencies {
13411345
)
13421346
})
13431347

1348+
this.factory.set(TYPES.SyncFrequencyGuard, () => {
1349+
return new SyncFrequencyGuard(
1350+
this.options.syncCallsThresholdPerMinute ?? this.DEFAULT_SYNC_CALLS_THRESHOLD_PER_MINUTE,
1351+
)
1352+
})
1353+
13441354
this.factory.set(TYPES.SyncService, () => {
13451355
return new SyncService(
13461356
this.get<ItemManager>(TYPES.ItemManager),
@@ -1358,6 +1368,7 @@ export class Dependencies {
13581368
},
13591369
this.get<Logger>(TYPES.Logger),
13601370
this.get<WebSocketsService>(TYPES.WebSocketsService),
1371+
this.get<SyncFrequencyGuardInterface>(TYPES.SyncFrequencyGuard),
13611372
this.get<InternalEventBus>(TYPES.InternalEventBus),
13621373
)
13631374
})

packages/snjs/lib/Application/Dependencies/Types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const TYPES = {
2929
SessionManager: Symbol.for('SessionManager'),
3030
SubscriptionManager: Symbol.for('SubscriptionManager'),
3131
HistoryManager: Symbol.for('HistoryManager'),
32+
SyncFrequencyGuard: Symbol.for('SyncFrequencyGuard'),
3233
SyncService: Symbol.for('SyncService'),
3334
ProtectionService: Symbol.for('ProtectionService'),
3435
UserService: Symbol.for('UserService'),
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { SyncFrequencyGuard } from './SyncFrequencyGuard'
2+
3+
describe('SyncFrequencyGuard', () => {
4+
const createUseCase = () => new SyncFrequencyGuard(3)
5+
6+
it('should return false when sync calls threshold is not reached', () => {
7+
const useCase = createUseCase()
8+
9+
expect(useCase.isSyncCallsThresholdReachedThisMinute()).toBe(false)
10+
})
11+
12+
it('should return true when sync calls threshold is reached', () => {
13+
const useCase = createUseCase()
14+
15+
useCase.incrementCallsPerMinute()
16+
useCase.incrementCallsPerMinute()
17+
useCase.incrementCallsPerMinute()
18+
19+
expect(useCase.isSyncCallsThresholdReachedThisMinute()).toBe(true)
20+
})
21+
22+
it('should return false when sync calls threshold is reached but a new minute has started', () => {
23+
const spyOnGetCallsPerMinuteKey = jest.spyOn(SyncFrequencyGuard.prototype as any, 'getCallsPerMinuteKey')
24+
spyOnGetCallsPerMinuteKey.mockReturnValueOnce('2020-1-1T1:1')
25+
26+
const useCase = createUseCase()
27+
28+
useCase.incrementCallsPerMinute()
29+
useCase.incrementCallsPerMinute()
30+
useCase.incrementCallsPerMinute()
31+
32+
spyOnGetCallsPerMinuteKey.mockReturnValueOnce('2020-1-1T1:2')
33+
34+
expect(useCase.isSyncCallsThresholdReachedThisMinute()).toBe(false)
35+
36+
spyOnGetCallsPerMinuteKey.mockRestore()
37+
})
38+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { SyncFrequencyGuardInterface } from './SyncFrequencyGuardInterface'
2+
3+
export class SyncFrequencyGuard implements SyncFrequencyGuardInterface {
4+
private callsPerMinuteMap: Map<string, number>
5+
6+
constructor(private syncCallsThresholdPerMinute: number) {
7+
this.callsPerMinuteMap = new Map<string, number>()
8+
}
9+
10+
isSyncCallsThresholdReachedThisMinute(): boolean {
11+
const stringDateToTheMinute = this.getCallsPerMinuteKey()
12+
const persistedCallsCount = this.callsPerMinuteMap.get(stringDateToTheMinute) || 0
13+
14+
return persistedCallsCount >= this.syncCallsThresholdPerMinute
15+
}
16+
17+
incrementCallsPerMinute(): void {
18+
const stringDateToTheMinute = this.getCallsPerMinuteKey()
19+
const persistedCallsCount = this.callsPerMinuteMap.get(stringDateToTheMinute)
20+
const newMinuteStarted = persistedCallsCount === undefined
21+
22+
if (newMinuteStarted) {
23+
this.clear()
24+
25+
this.callsPerMinuteMap.set(stringDateToTheMinute, 1)
26+
} else {
27+
this.callsPerMinuteMap.set(stringDateToTheMinute, persistedCallsCount + 1)
28+
}
29+
}
30+
31+
clear(): void {
32+
this.callsPerMinuteMap.clear()
33+
}
34+
35+
private getCallsPerMinuteKey(): string {
36+
const now = new Date()
37+
38+
return `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}T${now.getHours()}:${now.getMinutes()}`
39+
}
40+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface SyncFrequencyGuardInterface {
2+
incrementCallsPerMinute(): void
3+
isSyncCallsThresholdReachedThisMinute(): boolean
4+
clear(): void
5+
}

packages/snjs/lib/Services/Sync/SyncService.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import {
9797
import { CreatePayloadFromRawServerItem } from './Account/Utilities'
9898
import { DecryptedServerConflictMap, TrustedServerConflictMap } from './Account/ServerConflictMap'
9999
import { ContentType } from '@standardnotes/domain-core'
100+
import { SyncFrequencyGuardInterface } from './SyncFrequencyGuardInterface'
100101

101102
const DEFAULT_MAJOR_CHANGE_THRESHOLD = 15
102103
const INVALID_SESSION_RESPONSE_STATUS = 401
@@ -169,6 +170,7 @@ export class SyncService
169170
private readonly options: ApplicationSyncOptions,
170171
private logger: LoggerInterface,
171172
private sockets: WebSocketsService,
173+
private syncFrequencyGuard: SyncFrequencyGuardInterface,
172174
protected override internalEventBus: InternalEventBusInterface,
173175
) {
174176
super(internalEventBus)
@@ -643,7 +645,8 @@ export class SyncService
643645
const syncInProgress = this.opStatus.syncInProgress
644646
const databaseLoaded = this.databaseLoaded
645647
const canExecuteSync = !this.syncLock
646-
const shouldExecuteSync = canExecuteSync && databaseLoaded && !syncInProgress
648+
const syncLimitReached = this.syncFrequencyGuard.isSyncCallsThresholdReachedThisMinute()
649+
const shouldExecuteSync = canExecuteSync && databaseLoaded && !syncInProgress && !syncLimitReached
647650

648651
if (shouldExecuteSync) {
649652
this.syncLock = true
@@ -1296,6 +1299,8 @@ export class SyncService
12961299

12971300
this.lastSyncDate = new Date()
12981301

1302+
this.syncFrequencyGuard.incrementCallsPerMinute()
1303+
12991304
if (operation instanceof AccountSyncOperation && operation.numberOfItemsInvolved >= this.majorChangeThreshold) {
13001305
void this.notifyEvent(SyncEvent.MajorDataChange)
13011306
}

packages/snjs/mocha/lib/AppContext.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ const MaximumSyncOptions = {
1616
let GlobalSubscriptionIdCounter = 1001
1717

1818
export class AppContext {
19-
constructor({ identifier, crypto, email, password, passcode, host } = {}) {
19+
constructor({ identifier, crypto, email, password, passcode, host, syncCallsThresholdPerMinute } = {}) {
2020
this.identifier = identifier || `${Math.random()}`
2121
this.crypto = crypto
2222
this.email = email || UuidGenerator.GenerateUuid()
2323
this.password = password || UuidGenerator.GenerateUuid()
2424
this.passcode = passcode || 'mypasscode'
2525
this.host = host || Defaults.getDefaultHost()
26+
this.syncCallsThresholdPerMinute = syncCallsThresholdPerMinute
2627
}
2728

2829
enableLogging() {
@@ -46,6 +47,7 @@ export class AppContext {
4647
undefined,
4748
this.host,
4849
this.crypto || new FakeWebCrypto(),
50+
this.syncCallsThresholdPerMinute,
4951
)
5052

5153
this.application.dependencies.get(TYPES.Logger).setLevel('error')

packages/snjs/mocha/lib/Applications.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import WebDeviceInterface from './web_device_interface.js'
22
import FakeWebCrypto from './fake_web_crypto.js'
33
import * as Defaults from './Defaults.js'
44

5-
export function createApplicationWithOptions({ identifier, environment, platform, host, crypto, device }) {
5+
export function createApplicationWithOptions({ identifier, environment, platform, host, crypto, device, syncCallsThresholdPerMinute }) {
66
if (!device) {
77
device = new WebDeviceInterface()
88
device.environment = environment
@@ -22,11 +22,12 @@ export function createApplicationWithOptions({ identifier, environment, platform
2222
defaultHost: host || Defaults.getDefaultHost(),
2323
appVersion: Defaults.getAppVersion(),
2424
webSocketUrl: Defaults.getDefaultWebSocketUrl(),
25+
syncCallsThresholdPerMinute,
2526
})
2627
}
2728

28-
export function createApplication(identifier, environment, platform, host, crypto) {
29-
return createApplicationWithOptions({ identifier, environment, platform, host, crypto })
29+
export function createApplication(identifier, environment, platform, host, crypto, syncCallsThresholdPerMinute) {
30+
return createApplicationWithOptions({ identifier, environment, platform, host, crypto, syncCallsThresholdPerMinute })
3031
}
3132

3233
export function createApplicationWithFakeCrypto(identifier, environment, platform, host) {

packages/snjs/mocha/lib/factory.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,16 @@ export async function createAndInitSimpleAppContext(
4343
}
4444
}
4545

46-
export async function createAppContextWithFakeCrypto(identifier, email, password) {
47-
return createAppContext({ identifier, crypto: new FakeWebCrypto(), email, password })
46+
export async function createAppContextWithFakeCrypto(identifier, email, password, syncCallsThresholdPerMinute) {
47+
return createAppContext({ identifier, crypto: new FakeWebCrypto(), email, password, syncCallsThresholdPerMinute })
4848
}
4949

5050
export async function createAppContextWithRealCrypto(identifier) {
5151
return createAppContext({ identifier, crypto: new SNWebCrypto() })
5252
}
5353

54-
export async function createAppContext({ identifier, crypto, email, password, host } = {}) {
55-
const context = new AppContext({ identifier, crypto, email, password, host })
54+
export async function createAppContext({ identifier, crypto, email, password, host, syncCallsThresholdPerMinute } = {}) {
55+
const context = new AppContext({ identifier, crypto, email, password, host, syncCallsThresholdPerMinute })
5656
await context.initialize()
5757
return context
5858
}

0 commit comments

Comments
 (0)