diff --git a/common/changes/@hcengineering/server-core/main_2025-10-27-15-02.json b/common/changes/@hcengineering/server-core/main_2025-10-27-15-02.json new file mode 100644 index 0000000000..128b33304e --- /dev/null +++ b/common/changes/@hcengineering/server-core/main_2025-10-27-15-02.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@hcengineering/server-core", + "comment": "Fix one second counters", + "type": "patch" + } + ], + "packageName": "@hcengineering/server-core" +} \ No newline at end of file diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index 6132ad8a59..96727e5e0c 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -84,6 +84,14 @@ describe('OneSecondCountersImpl', () => { expect(entries).toEqual([['op', 0]]) }) + it('focused: counter returns to 0 after operation finishes', async () => { + // Ensure explicitly that counter returns to 0 after the operation completes + await counters.withCounter('focus', 3, async () => 'ok') + const entries = Array.from(counters.entries()).sort((a, b) => a[0].localeCompare(b[0])) + // 'focus' counter should be 0 + expect(entries.find((e) => e[0] === 'focus')).toEqual(['focus', 0]) + }) + it('should decrement counter on operation error', async () => { const operation = jest.fn(async () => { throw new Error('Operation failed') @@ -198,6 +206,20 @@ describe('OneSecondCountersImpl', () => { expect(entries).toEqual([['op', 0]]) }) + it('focused: counter returns to 0 after timeout', async () => { + // Start an operation that never resolves + void counters.withCounter('timen', 4, async (): Promise => { + // never resolves + }) + // Counter should be incremented + expect(Array.from(counters.entries()).find((e) => e[0] === 'timen')?.[1]).toBe(4) + // Fast-forward past the timeout and run check + jest.advanceTimersByTime(61 * 1000) + counters.check() + // After timeout the counter for 'timen' should be back to 0 + expect(Array.from(counters.entries()).find((e) => e[0] === 'timen')).toEqual(['timen', 0]) + }) + it('should clean up multiple expired timeouts', async () => { // Create multiple operations void counters.withCounter('op1', 1, async (): Promise => {}) diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index bd4bbb9eae..76513c6ce7 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -431,7 +431,8 @@ export async function calcHashHash (ctx: MeasureContext, domain: Domain, adapter export class OneSecondCountersImpl implements OneSecondCounters { private readonly counters = new Map() - private counterTimeouts: [number, () => void][] = [] + private readonly counterTimeouts = new Map void]>() + ids: number = 0 add (counter: string, count: number): void { this.counters.set(counter, (this.counters.get(counter) ?? 0) + count) @@ -440,7 +441,8 @@ export class OneSecondCountersImpl implements OneSecondCounters { async withCounter(counter: string, count: number, op: () => Promise): Promise { this.add(counter, count) let cleared = false - this.counterTimeouts.push([ + const id = ++this.ids + this.counterTimeouts.set(id, [ platformNow() + 60 * 1000, // One minute timeout () => { if (!cleared) { @@ -455,6 +457,7 @@ export class OneSecondCountersImpl implements OneSecondCounters { if (!cleared) { this.add(counter, -count) } + cleared = true } } @@ -465,11 +468,11 @@ export class OneSecondCountersImpl implements OneSecondCounters { check (): void { // Check for timeouts const now = platformNow() - for (const [timeout, cb] of this.counterTimeouts) { + for (const [k, [timeout, cb]] of [...this.counterTimeouts.entries()]) { if (timeout < now) { cb() + this.counterTimeouts.delete(k) } } - this.counterTimeouts = this.counterTimeouts.filter((it) => it[0] >= now) } }