Skip to content

Commit efed148

Browse files
Merge pull request #207 from blockful/feat/cursor_refactor
Feat/cursor refactor
2 parents d91d0f8 + ebe45b9 commit efed148

8 files changed

Lines changed: 90 additions & 99 deletions

File tree

apps/logic-system/src/app.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,6 @@ export class App {
139139
if (this.voteConfirmationTrigger) {
140140
this.voteConfirmationTrigger.reset(initialTimestamp);
141141
}
142-
if (this.votingReminderTrigger30) {
143-
this.votingReminderTrigger30.reset(initialTimestamp);
144-
}
145-
if (this.votingReminderTrigger60) {
146-
this.votingReminderTrigger60.reset(initialTimestamp);
147-
}
148-
if (this.votingReminderTrigger90) {
149-
this.votingReminderTrigger90.reset(initialTimestamp);
150-
}
151142
}
152143

153144
async stop(): Promise<void> {

apps/logic-system/src/triggers/voting-reminder-trigger.ts

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { Trigger } from './base-trigger';
8-
import { ProposalOnChain, ListProposalsOptions, ProposalDataSource } from '../interfaces/proposal.interface';
8+
import { ProposalOnChain, ProposalDataSource } from '../interfaces/proposal.interface';
99
import { DispatcherService, DispatcherMessage } from '../interfaces/dispatcher.interface';
1010

1111
/**
@@ -27,7 +27,6 @@ const TRIGGER_ID_PREFIX = 'voting-reminder';
2727
const DEFAULT_WINDOW_SIZE = 5;
2828

2929
export class VotingReminderTrigger extends Trigger<ProposalOnChain> {
30-
private timestampCursor: number;
3130
private thresholdPercentage: number;
3231
private windowSize: number;
3332

@@ -36,31 +35,11 @@ export class VotingReminderTrigger extends Trigger<ProposalOnChain> {
3635
private readonly proposalRepository: ProposalDataSource,
3736
interval: number,
3837
thresholdPercentage: number = 75,
39-
windowSize: number = DEFAULT_WINDOW_SIZE,
40-
initialTimestamp?: string
38+
windowSize: number = DEFAULT_WINDOW_SIZE
4139
) {
4240
super(TRIGGER_ID_PREFIX, interval);
4341
this.thresholdPercentage = thresholdPercentage;
4442
this.windowSize = windowSize;
45-
46-
// Initialize timestamp - look back 24 hours by default
47-
if (initialTimestamp) {
48-
this.timestampCursor = parseInt(initialTimestamp, 10);
49-
} else {
50-
this.timestampCursor = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000); // 24 hours ago
51-
}
52-
}
53-
54-
/**
55-
* Resets the trigger state to initial timestamp
56-
* @param timestamp Optional timestamp to reset to, defaults to 24 hours ago
57-
*/
58-
public reset(timestamp?: string): void {
59-
if (timestamp) {
60-
this.timestampCursor = parseInt(timestamp, 10);
61-
} else {
62-
this.timestampCursor = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000); // 24 hours ago
63-
}
6443
}
6544

6645
/**
@@ -87,13 +66,6 @@ export class VotingReminderTrigger extends Trigger<ProposalOnChain> {
8766
};
8867

8968
await this.dispatcherService.sendMessage(message);
90-
91-
// Update timestamp to the most recent proposal's timestamp + 1
92-
// Since data comes ordered by timestamp desc, the first item has the latest timestamp
93-
if (proposals[0]?.timestamp) {
94-
this.timestampCursor = parseInt(proposals[0].timestamp, 10) + 1;
95-
}
96-
9769
}
9870

9971
/**
@@ -182,7 +154,6 @@ export class VotingReminderTrigger extends Trigger<ProposalOnChain> {
182154
protected async fetchData(): Promise<ProposalOnChain[]> {
183155
return await this.proposalRepository.listAll({
184156
status: 'ACTIVE',
185-
fromDate: this.timestampCursor,
186157
includeOptimisticProposals: false
187158
});
188159
}

apps/logic-system/tests/voting-reminder-trigger.test.ts

Lines changed: 3 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
66
import { VotingReminderTrigger } from '../src/triggers/voting-reminder-trigger';
77
import { ProposalOnChain } from '../src/interfaces/proposal.interface';
8-
import { DispatcherService, DispatcherMessage } from '../src/interfaces/dispatcher.interface';
8+
import { DispatcherService } from '../src/interfaces/dispatcher.interface';
99
import { MockedFunction } from 'jest-mock';
1010

1111
describe('VotingReminderTrigger', () => {
@@ -111,30 +111,6 @@ describe('VotingReminderTrigger', () => {
111111
expect(mockDispatcherService.sendMessage).not.toHaveBeenCalled();
112112
});
113113

114-
it('should update timestampCursor after processing', async () => {
115-
const proposalStart = baseTime - 3600;
116-
const proposalEnd = baseTime + 300; // within 90-95% window
117-
118-
const proposal: ProposalOnChain = {
119-
id: 'proposal-123',
120-
daoId: 'test-dao',
121-
description: 'Test proposal',
122-
timestamp: proposalStart.toString(),
123-
endTimestamp: proposalEnd.toString(),
124-
status: 'ACTIVE'
125-
} as ProposalOnChain;
126-
127-
// Process proposal
128-
await trigger.process([proposal]);
129-
130-
// Check that timestampCursor was updated to the proposal's timestamp + 1
131-
// +1 to avoid duplicates since API uses >= comparison
132-
const updatedTimestamp = (trigger as any).timestampCursor;
133-
expect(updatedTimestamp).toBe(proposalStart + 1);
134-
135-
expect(mockDispatcherService.sendMessage).toHaveBeenCalledTimes(1);
136-
});
137-
138114
it('should skip proposals without required timestamps', async () => {
139115
const proposal: ProposalOnChain = {
140116
id: 'proposal-123',
@@ -187,7 +163,7 @@ describe('VotingReminderTrigger', () => {
187163
});
188164

189165
describe('fetchData', () => {
190-
it('should fetch active proposals with fromDate filter', async () => {
166+
it('should fetch active proposals without timestamp filter', async () => {
191167
const proposals = [
192168
{ id: 'prop-1', status: 'ACTIVE' },
193169
{ id: 'prop-2', status: 'ACTIVE' }
@@ -197,46 +173,15 @@ describe('VotingReminderTrigger', () => {
197173

198174
const result = await trigger['fetchData']();
199175

200-
// Should include fromDate filter with timestampCursor and exclude optimistic proposals
176+
// Should only filter by status ACTIVE, no fromDate
201177
expect(mockProposalRepository.listAll).toHaveBeenCalledWith({
202178
status: 'ACTIVE',
203-
fromDate: expect.any(Number),
204179
includeOptimisticProposals: false
205180
});
206181
expect(result).toEqual(proposals);
207182
});
208183
});
209184

210-
describe('reset', () => {
211-
it('should reset the timestampCursor', () => {
212-
// Create trigger with initial timestamp
213-
const initialTimestamp = '1000000';
214-
const triggerWithTimestamp = new VotingReminderTrigger(
215-
mockDispatcherService,
216-
mockProposalRepository,
217-
30000,
218-
90,
219-
5,
220-
initialTimestamp
221-
);
222-
223-
// Reset without timestamp - should default to 24 hours ago
224-
triggerWithTimestamp.reset();
225-
226-
// Access private property for testing
227-
const resetTimestamp = (triggerWithTimestamp as any).timestampCursor;
228-
const twentyFourHoursAgo = Math.floor((Date.now() - 24 * 60 * 60 * 1000) / 1000);
229-
230-
// Should be around 24 hours ago (within 1 second tolerance)
231-
expect(Math.abs(resetTimestamp - twentyFourHoursAgo)).toBeLessThanOrEqual(1);
232-
233-
// Reset with specific timestamp
234-
triggerWithTimestamp.reset('2000000');
235-
const specificTimestamp = (triggerWithTimestamp as any).timestampCursor;
236-
expect(specificTimestamp).toBe(2000000);
237-
});
238-
});
239-
240185
describe('time calculation', () => {
241186
it('should calculate time elapsed percentage correctly', () => {
242187
const startTime = 1000;

packages/anticapture-client/dist/anticapture-client.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ class AnticaptureClient {
121121
console.warn(`Skipping ${dao.id} due to API error: ${error instanceof Error ? error.message : error}`);
122122
}
123123
}
124+
// Sort globally by timestamp desc (most recent first)
125+
if (variables?.fromEndDate) {
126+
allProposals.sort((a, b) => parseInt(b?.endTimestamp || '0') - parseInt(a?.endTimestamp || '0'));
127+
}
128+
else {
129+
allProposals.sort((a, b) => parseInt(b?.timestamp || '0') - parseInt(a?.timestamp || '0') || 0);
130+
}
124131
return allProposals;
125132
}
126133
try {

packages/anticapture-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"dependencies": {
3636
"@graphql-typed-document-node/core": "^3.2.0",
3737
"axios": "^1.7.2",
38+
"axios-retry": "^4.5.0",
3839
"graphql": "^16.11.0",
3940
"viem": "^2.34.0",
4041
"zod": "^3.22.4"

packages/anticapture-client/src/anticapture-client.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AxiosInstance } from 'axios';
2+
import axiosRetry, { exponentialDelay, isNetworkOrIdempotentRequestError } from 'axios-retry';
23
import { print } from 'graphql';
34
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
45
import { z } from 'zod';
@@ -24,8 +25,23 @@ type ProposalNonVoter = z.infer<typeof SafeProposalNonVotersResponseSchema>['pro
2425
export class AnticaptureClient {
2526
private readonly httpClient: AxiosInstance;
2627

27-
constructor(httpClient: AxiosInstance) {
28+
constructor(httpClient: AxiosInstance, maxRetries: number = 4, timeout: number = 15000) {
2829
this.httpClient = httpClient;
30+
this.httpClient.defaults.timeout = timeout;
31+
32+
axiosRetry(this.httpClient, {
33+
retries: maxRetries,
34+
retryDelay: exponentialDelay, // 1s, 2s, 4s, 8s
35+
retryCondition: (error) => {
36+
return isNetworkOrIdempotentRequestError(error) ||
37+
(error.response?.status !== undefined && error.response.status >= 500);
38+
},
39+
onRetry: (retryCount, error, requestConfig) => {
40+
console.warn(
41+
`[AnticaptureClient] Retry ${retryCount}/${maxRetries} for ${requestConfig.url || 'request'}: ${error.message}`
42+
);
43+
},
44+
});
2945
}
3046

3147
/**
@@ -165,6 +181,13 @@ export class AnticaptureClient {
165181
}
166182
}
167183

184+
// Sort globally by timestamp desc (most recent first)
185+
if (variables?.fromEndDate) {
186+
allProposals.sort((a, b) => parseInt(b?.endTimestamp || '0') - parseInt(a?.endTimestamp || '0'));
187+
} else {
188+
allProposals.sort((a, b) => parseInt(b?.timestamp || '0') - parseInt(a?.timestamp || '0') || 0);
189+
}
190+
168191
return allProposals;
169192
}
170193

packages/anticapture-client/tests/anticapture-client.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,40 @@ describe('AnticaptureClient', () => {
102102
{ id: 'p3', description: 'Proposal 3', title: null, daoId: 'DAO3' }
103103
]);
104104
});
105+
106+
it('sorts proposals globally by timestamp DESC regardless of DAO order', async () => {
107+
// Simulate proposals from different DAOs with unsorted timestamps
108+
mockQuery
109+
.mockResolvedValueOnce({
110+
proposals: {
111+
items: [{ id: 'dao1-old', description: 'Old from DAO1', title: null, timestamp: '1000' }],
112+
totalCount: 1
113+
}
114+
})
115+
.mockResolvedValueOnce({
116+
proposals: {
117+
items: [{ id: 'dao2-newest', description: 'Newest from DAO2', title: null, timestamp: '3000' }],
118+
totalCount: 1
119+
}
120+
})
121+
.mockResolvedValueOnce({
122+
proposals: {
123+
items: [{ id: 'dao3-middle', description: 'Middle from DAO3', title: null, timestamp: '2000' }],
124+
totalCount: 1
125+
}
126+
});
127+
128+
const result = await client.listProposals();
129+
130+
// Should be sorted by timestamp DESC (newest first)
131+
expect(result).toHaveLength(3);
132+
expect(result[0]!.id).toBe('dao2-newest');
133+
expect(result[0]!.timestamp).toBe('3000');
134+
expect(result[1]!.id).toBe('dao3-middle');
135+
expect(result[1]!.timestamp).toBe('2000');
136+
expect(result[2]!.id).toBe('dao1-old');
137+
expect(result[2]!.timestamp).toBe('1000');
138+
});
105139
});
106140
});
107141

pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)