-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/relevance threshold route #226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
75db667
203e29f
6521cb3
c4feda8
4e565ea
1fd2f75
9446dd1
755e132
f4813b3
cbba9a2
d993cf1
e9c83d0
4b163a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { | ||
| AnticaptureClient, | ||
| FeedEventType, | ||
| FeedRelevance | ||
| } from '@notification-system/anticapture-client'; | ||
|
|
||
| interface CacheEntry { | ||
| value: string; | ||
| fetchedAt: number; | ||
| } | ||
|
|
||
| const ONE_DAY_MS = 86_400_000; | ||
pikonha marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| export class ThresholdRepository { | ||
| private cache = new Map<string, CacheEntry>(); | ||
|
|
||
| constructor( | ||
| private readonly anticaptureClient: AnticaptureClient, | ||
| private readonly cacheTtlMs: number = ONE_DAY_MS | ||
| ) {} | ||
|
|
||
| async getThreshold(daoId: string, type: FeedEventType): Promise<string | null> { | ||
| const cacheKey = `${daoId}:${type}`; | ||
| const cached = this.cache.get(cacheKey); | ||
|
|
||
| if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) { | ||
| return cached.value; | ||
| } | ||
|
|
||
| try { | ||
| const threshold = await this.anticaptureClient.getEventThreshold(daoId, type, FeedRelevance.High); | ||
|
|
||
| if (threshold !== null) { | ||
| this.cache.set(cacheKey, { value: threshold, fetchedAt: Date.now() }); | ||
| } | ||
|
|
||
| return threshold; | ||
| } catch (error) { | ||
| console.warn( | ||
| `[ThresholdRepository] Error fetching threshold for ${daoId}/${type}:`, | ||
| error instanceof Error ? error.message : error | ||
| ); | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,8 +5,9 @@ | |
|
|
||
| import { Trigger } from './base-trigger'; | ||
| import { VotingPowerRepository } from '../repositories/voting-power.repository'; | ||
| import { ThresholdRepository } from '../repositories/threshold.repository'; | ||
| import { DispatcherService, DispatcherMessage } from '../interfaces/dispatcher.interface'; | ||
| import { ProcessedVotingPowerHistory } from '@notification-system/anticapture-client'; | ||
| import { ProcessedVotingPowerHistory, FeedEventType } from '@notification-system/anticapture-client'; | ||
|
|
||
| const triggerId = 'voting-power-changed'; | ||
|
|
||
|
|
@@ -16,6 +17,7 @@ export class VotingPowerChangedTrigger extends Trigger<ProcessedVotingPowerHisto | |
| constructor( | ||
| private readonly dispatcherService: DispatcherService, | ||
| private readonly votingPowerRepository: VotingPowerRepository, | ||
| private readonly thresholdRepository: ThresholdRepository, | ||
| interval: number | ||
| ) { | ||
| super(triggerId, interval); | ||
|
|
@@ -40,17 +42,37 @@ export class VotingPowerChangedTrigger extends Trigger<ProcessedVotingPowerHisto | |
| return; | ||
| } | ||
|
|
||
| // Always advance the timestamp cursor even if all events are filtered out, | ||
| // to avoid reprocessing the same events on every cycle | ||
| this.lastProcessedTimestamp = String(Number(data[data.length - 1].timestamp) + 1); | ||
|
|
||
| const filtered = await this.filterByThreshold(data); | ||
| if (filtered.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| const message: DispatcherMessage<ProcessedVotingPowerHistory> = { | ||
| triggerId: this.id, | ||
| events: data | ||
| events: filtered | ||
| }; | ||
|
|
||
| await this.dispatcherService.sendMessage(message); | ||
| } | ||
|
|
||
| // Update the last processed timestamp to the most recent timestamp + 1 second | ||
| // Since data comes ordered by timestamp asc, the last item has the latest timestamp | ||
| // Adding 1 avoids reprocessing the same event since the API uses >= (gte) for fromDate | ||
| this.lastProcessedTimestamp = String(Number(data[data.length - 1].timestamp) + 1); | ||
| private async filterByThreshold( | ||
| data: ProcessedVotingPowerHistory[] | ||
| ): Promise<ProcessedVotingPowerHistory[]> { | ||
| const keep = await Promise.all( | ||
| data.map(async (event) => { | ||
| const type = event.changeType.toUpperCase(); | ||
| if (!Object.values(FeedEventType).includes(type as FeedEventType)) return true; | ||
|
|
||
| const threshold = await this.thresholdRepository.getThreshold(event.daoId, type as FeedEventType); | ||
| return threshold === null || Math.abs(Number(event.delta)) >= Number(threshold); | ||
| }) | ||
| ); | ||
|
|
||
| return data.filter((_, i) => keep[i]); | ||
|
Comment on lines
+65
to
+75
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. couldn't this be added to the votes endpoint query? i.e, we currently don't have it supported, but it would be a piece of cake to add it
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On votes, yes! But on this case, (voting power changes), it would not work. The flow here is:
As the threshold could be different for each type, we cannot filter on the query. We need to get all, then filter locally. The other option would be to use "relevance" as a query parameter, then the api filter on the repo-layer (but it looks ugly). |
||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { describe, it, expect, jest, beforeEach } from '@jest/globals'; | ||
| import { ThresholdRepository } from '../src/repositories/threshold.repository'; | ||
| import { FeedEventType, FeedRelevance } from '@notification-system/anticapture-client'; | ||
|
|
||
| const createMockAnticaptureClient = () => ({ | ||
| getEventThreshold: jest.fn<() => Promise<string | null>>() | ||
| }); | ||
|
|
||
| describe('ThresholdRepository', () => { | ||
| let repository: ThresholdRepository; | ||
| let mockClient: ReturnType<typeof createMockAnticaptureClient>; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| mockClient = createMockAnticaptureClient(); | ||
| repository = new ThresholdRepository(mockClient as any, 300_000); | ||
| }); | ||
|
|
||
| describe('getThreshold', () => { | ||
| it('should fetch threshold from client on cache miss', async () => { | ||
| mockClient.getEventThreshold.mockResolvedValue('40000000000000000000000'); | ||
|
|
||
| const result = await repository.getThreshold('ENS', FeedEventType.Delegation); | ||
|
|
||
| expect(result).toBe('40000000000000000000000'); | ||
| expect(mockClient.getEventThreshold).toHaveBeenCalledWith( | ||
| 'ENS', FeedEventType.Delegation, FeedRelevance.High | ||
| ); | ||
| }); | ||
|
|
||
| it('should return cached value on cache hit', async () => { | ||
| mockClient.getEventThreshold.mockResolvedValue('40000000000000000000000'); | ||
|
|
||
| await repository.getThreshold('ENS', FeedEventType.Delegation); | ||
| const result = await repository.getThreshold('ENS', FeedEventType.Delegation); | ||
|
|
||
| expect(result).toBe('40000000000000000000000'); | ||
| expect(mockClient.getEventThreshold).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('should cache separately per daoId and type', async () => { | ||
| mockClient.getEventThreshold | ||
| .mockResolvedValueOnce('1000') | ||
| .mockResolvedValueOnce('2000') | ||
| .mockResolvedValueOnce('3000'); | ||
|
|
||
| const r1 = await repository.getThreshold('ENS', FeedEventType.Delegation); | ||
| const r2 = await repository.getThreshold('ENS', FeedEventType.Transfer); | ||
| const r3 = await repository.getThreshold('UNISWAP', FeedEventType.Delegation); | ||
|
|
||
| expect(r1).toBe('1000'); | ||
| expect(r2).toBe('2000'); | ||
| expect(r3).toBe('3000'); | ||
| expect(mockClient.getEventThreshold).toHaveBeenCalledTimes(3); | ||
| }); | ||
|
|
||
| it('should refetch after TTL expires', async () => { | ||
| const shortTtlRepo = new ThresholdRepository(mockClient as any, 100); | ||
| mockClient.getEventThreshold | ||
| .mockResolvedValueOnce('1000') | ||
| .mockResolvedValueOnce('2000'); | ||
|
|
||
| const r1 = await shortTtlRepo.getThreshold('ENS', FeedEventType.Delegation); | ||
| expect(r1).toBe('1000'); | ||
|
|
||
| await new Promise(resolve => setTimeout(resolve, 150)); | ||
|
|
||
| const r2 = await shortTtlRepo.getThreshold('ENS', FeedEventType.Delegation); | ||
| expect(r2).toBe('2000'); | ||
| expect(mockClient.getEventThreshold).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('should return null when client returns null (fail-open)', async () => { | ||
| mockClient.getEventThreshold.mockResolvedValue(null); | ||
|
|
||
| const result = await repository.getThreshold('ENS', FeedEventType.Delegation); | ||
|
|
||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it('should not cache null responses', async () => { | ||
| mockClient.getEventThreshold | ||
| .mockResolvedValueOnce(null) | ||
| .mockResolvedValueOnce('5000'); | ||
|
|
||
| const r1 = await repository.getThreshold('ENS', FeedEventType.Delegation); | ||
| const r2 = await repository.getThreshold('ENS', FeedEventType.Delegation); | ||
|
|
||
| expect(r1).toBeNull(); | ||
| expect(r2).toBe('5000'); | ||
| expect(mockClient.getEventThreshold).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('should return null when client throws (fail-open)', async () => { | ||
| mockClient.getEventThreshold.mockRejectedValue(new Error('Network error')); | ||
|
|
||
| const result = await repository.getThreshold('ENS', FeedEventType.Delegation); | ||
|
|
||
| expect(result).toBeNull(); | ||
| }); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.