diff --git a/apps/api/src/ipns/ipns.service.spec.ts b/apps/api/src/ipns/ipns.service.spec.ts index 214df9219c..f0bcd9c39b 100644 --- a/apps/api/src/ipns/ipns.service.spec.ts +++ b/apps/api/src/ipns/ipns.service.spec.ts @@ -936,6 +936,10 @@ describe('IpnsService', () => { metadataCid: testMetadataCid, }); + // Delegated routing publish is fire-and-forget; flush microtask queue + // so the detached .then() callback that records metrics can run. + await new Promise(process.nextTick); + expect(mockStartTimer).toHaveBeenCalledWith({ operation: 'publish', source: '' }); expect(mockEndTimer).toHaveBeenCalledWith({ result: 'success' }); expect(mockMetricsService.ipnsPublishDuration.observe).toHaveBeenCalledWith( @@ -974,6 +978,9 @@ describe('IpnsService', () => { metadataCid: testMetadataCid, }); + // Delegated routing publish is fire-and-forget; flush microtask queue + await new Promise(process.nextTick); + expect(mockMetricsService.ipnsPublishDuration.observe).toHaveBeenCalledWith( { outcome: 'error' }, expect.any(Number) @@ -995,11 +1002,41 @@ describe('IpnsService', () => { metadataCid: testMetadataCid, }); + // Delegated routing publish is fire-and-forget; flush microtask queue + await new Promise(process.nextTick); + expect(mockMetricsService.ipnsPublishDuration.observe).toHaveBeenCalledWith( { outcome: 'error' }, expect.any(Number) ); }); + + it('should log error and not crash when metrics observe() throws', async () => { + mockFolderIpnsRepo.findOne.mockResolvedValue(null); + mockFolderIpnsRepo.create.mockReturnValue({ ...mockFolderEntity, sequenceNumber: '1' }); + mockFolderIpnsRepo.save.mockResolvedValue({ ...mockFolderEntity, sequenceNumber: '1' }); + mockMetricsService.ipnsPublishDuration.observe.mockImplementation(() => { + throw new Error('metrics explosion'); + }); + + const loggerSpy = jest.spyOn(service['logger'], 'error').mockImplementation(); + + const result = await service.publishRecord(testUserId, { + ipnsName: testIpnsName, + record: testRecord, + metadataCid: testMetadataCid, + }); + + // Flush fire-and-forget promise chain + await new Promise(process.nextTick); + + expect(result.success).toBe(true); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to record IPNS publish metrics') + ); + + loggerSpy.mockRestore(); + }); }); describe('conflict detection', () => { diff --git a/apps/api/src/ipns/ipns.service.ts b/apps/api/src/ipns/ipns.service.ts index 2a2c71845e..5b79f4a988 100644 --- a/apps/api/src/ipns/ipns.service.ts +++ b/apps/api/src/ipns/ipns.service.ts @@ -72,24 +72,32 @@ export class IpnsService { dto.expectedSequenceNumber ); - // Publish to delegated routing API (best-effort — DB is the reliable source) + // Publish to delegated routing API (fire-and-forget — DB is the reliable source). + // DHT propagation via someguy takes ~10-30s per record. Since the DB record + // is already saved and resolveRecord() always checks/prefers DB data, there is + // no need to block the API response on DHT propagation. Metrics are still + // collected via the detached promise chain (catch + then). const publishStart = process.hrtime.bigint(); - let publishOutcome = 'success'; - try { - await this.delegatedRouting.publish(dto.ipnsName, recordBytes); - } catch (error) { - result = 'delegated_error'; - publishOutcome = 'error'; - this.logger.warn( - `Delegated routing publish failed for ${dto.ipnsName}, DB record saved: ${error instanceof Error ? error.message : String(error)}` - ); - } finally { - const publishElapsed = Number(process.hrtime.bigint() - publishStart) / 1e9; - this.metricsService.ipnsPublishDuration.observe( - { outcome: publishOutcome }, - publishElapsed - ); - } + this.delegatedRouting + .publish(dto.ipnsName, recordBytes) + .catch((error) => { + this.logger.warn( + `Delegated routing publish failed for ${dto.ipnsName}, DB record saved: ${error instanceof Error ? error.message : String(error)}` + ); + return 'error' as const; + }) + .then((outcome) => { + const publishElapsed = Number(process.hrtime.bigint() - publishStart) / 1e9; + this.metricsService.ipnsPublishDuration.observe( + { outcome: outcome === 'error' ? 'error' : 'success' }, + publishElapsed + ); + }) + .catch((error) => { + this.logger.error( + `Failed to record IPNS publish metrics for ${dto.ipnsName}: ${error instanceof Error ? error.message : String(error)}` + ); + }); return { success: true,