Skip to content

Commit 9ef8847

Browse files
authored
RI:7799: Stream bulk delete report (#5278)
1 parent 1463160 commit 9ef8847

27 files changed

+792
-336
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Response } from 'express';
2+
import {
3+
Controller,
4+
Get,
5+
Param,
6+
Res,
7+
UsePipes,
8+
ValidationPipe,
9+
} from '@nestjs/common';
10+
import { ApiParam, ApiTags } from '@nestjs/swagger';
11+
import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator';
12+
import { BulkActionsService } from 'src/modules/bulk-actions/bulk-actions.service';
13+
import { BulkActionIdDto } from 'src/modules/bulk-actions/dto/bulk-action-id.dto';
14+
15+
@UsePipes(new ValidationPipe({ transform: true }))
16+
@ApiTags('Bulk Actions')
17+
@Controller('bulk-actions')
18+
export class BulkActionsController {
19+
constructor(private readonly service: BulkActionsService) {}
20+
21+
@ApiEndpoint({
22+
description: 'Stream bulk action report as downloadable file',
23+
statusCode: 200,
24+
})
25+
@ApiParam({
26+
name: 'id',
27+
description: 'Bulk action id',
28+
type: String,
29+
})
30+
@Get(':id/report/download')
31+
async downloadReport(
32+
@Param() { id }: BulkActionIdDto,
33+
@Res() res: Response,
34+
): Promise<void> {
35+
await this.service.streamReport(id, res);
36+
}
37+
}

redisinsight/api/src/modules/bulk-actions/bulk-actions.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { BulkActionsService } from 'src/modules/bulk-actions/bulk-actions.servic
33
import { BulkActionsProvider } from 'src/modules/bulk-actions/providers/bulk-actions.provider';
44
import { BulkActionsGateway } from 'src/modules/bulk-actions/bulk-actions.gateway';
55
import { BulkActionsAnalytics } from 'src/modules/bulk-actions/bulk-actions.analytics';
6+
import { BulkActionsController } from 'src/modules/bulk-actions/bulk-actions.controller';
67
import { BulkImportController } from 'src/modules/bulk-actions/bulk-import.controller';
78
import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service';
89

910
@Module({
10-
controllers: [BulkImportController],
11+
controllers: [BulkActionsController, BulkImportController],
1112
providers: [
1213
BulkActionsGateway,
1314
BulkActionsService,

redisinsight/api/src/modules/bulk-actions/bulk-actions.service.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,70 @@ describe('BulkActionsService', () => {
110110
expect(bulkActionProvider.abortUsersBulkActions).toHaveBeenCalledTimes(1);
111111
});
112112
});
113+
114+
describe('streamReport', () => {
115+
let mockResponse: any;
116+
let mockBulkActionWithReport: any;
117+
118+
beforeEach(() => {
119+
mockResponse = {
120+
setHeader: jest.fn(),
121+
write: jest.fn(),
122+
end: jest.fn(),
123+
};
124+
125+
mockBulkActionWithReport = {
126+
setStreamingResponse: jest.fn(),
127+
isReportEnabled: jest.fn().mockReturnValue(true),
128+
};
129+
});
130+
131+
it('should throw NotFoundException when bulk action not found', async () => {
132+
bulkActionProvider.get = jest.fn().mockReturnValue(null);
133+
134+
await expect(
135+
service.streamReport('non-existent-id', mockResponse),
136+
).rejects.toThrow('Bulk action not found');
137+
});
138+
139+
it('should throw BadRequestException when report not enabled', async () => {
140+
mockBulkActionWithReport.isReportEnabled.mockReturnValue(false);
141+
bulkActionProvider.get = jest
142+
.fn()
143+
.mockReturnValue(mockBulkActionWithReport);
144+
145+
await expect(
146+
service.streamReport('bulk-action-id', mockResponse),
147+
).rejects.toThrow(
148+
'Report generation was not enabled for this bulk action',
149+
);
150+
});
151+
152+
it('should set headers and attach stream to bulk action', async () => {
153+
bulkActionProvider.get = jest
154+
.fn()
155+
.mockReturnValue(mockBulkActionWithReport);
156+
const mockTimestamp = '1733047200000'; // 2024-12-01T10:00:00.000Z
157+
const expectedFilename =
158+
'bulk-delete-report-2024-12-01T10-00-00-000Z.txt';
159+
160+
await service.streamReport(mockTimestamp, mockResponse);
161+
162+
expect(mockResponse.setHeader).toHaveBeenCalledWith(
163+
'Content-Type',
164+
'text/plain',
165+
);
166+
expect(mockResponse.setHeader).toHaveBeenCalledWith(
167+
'Content-Disposition',
168+
`attachment; filename="${expectedFilename}"`,
169+
);
170+
expect(mockResponse.setHeader).toHaveBeenCalledWith(
171+
'Transfer-Encoding',
172+
'chunked',
173+
);
174+
expect(
175+
mockBulkActionWithReport.setStreamingResponse,
176+
).toHaveBeenCalledWith(mockResponse);
177+
});
178+
});
113179
});

redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { Socket } from 'socket.io';
2-
import { Injectable } from '@nestjs/common';
2+
import { Response } from 'express';
3+
import {
4+
BadRequestException,
5+
Injectable,
6+
NotFoundException,
7+
} from '@nestjs/common';
38
import { BulkActionsProvider } from 'src/modules/bulk-actions/providers/bulk-actions.provider';
49
import { CreateBulkActionDto } from 'src/modules/bulk-actions/dto/create-bulk-action.dto';
510
import { BulkActionIdDto } from 'src/modules/bulk-actions/dto/bulk-action-id.dto';
@@ -44,4 +49,36 @@ export class BulkActionsService {
4449
disconnect(socketId: string) {
4550
this.bulkActionsProvider.abortUsersBulkActions(socketId);
4651
}
52+
53+
/**
54+
* Stream bulk action report as downloadable file
55+
* @param id Bulk action id
56+
* @param res Express response object
57+
*/
58+
async streamReport(id: string, res: Response): Promise<void> {
59+
const bulkAction = this.bulkActionsProvider.get(id);
60+
61+
if (!bulkAction) {
62+
throw new NotFoundException('Bulk action not found');
63+
}
64+
65+
if (!bulkAction.isReportEnabled()) {
66+
throw new BadRequestException(
67+
'Report generation was not enabled for this bulk action',
68+
);
69+
}
70+
71+
// Set headers for file download
72+
const timestamp = new Date(Number(id)).toISOString().replace(/[:.]/g, '-');
73+
res.setHeader('Content-Type', 'text/plain');
74+
res.setHeader(
75+
'Content-Disposition',
76+
`attachment; filename="bulk-delete-report-${timestamp}.txt"`,
77+
);
78+
res.setHeader('Transfer-Encoding', 'chunked');
79+
80+
// Attach the response stream to the bulk action
81+
// This will trigger the bulk action to start processing
82+
bulkAction.setStreamingResponse(res);
83+
}
4784
}

redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BulkActionFilter } from 'src/modules/bulk-actions/models/bulk-action-filter';
22
import { BulkActionType } from 'src/modules/bulk-actions/constants';
33
import {
4+
IsBoolean,
45
IsEnum,
56
IsNotEmpty,
67
IsNumber,
@@ -35,4 +36,9 @@ export class CreateBulkActionDto extends BulkActionIdDto {
3536
@Min(0)
3637
@Max(2147483647)
3738
db?: number;
39+
40+
@IsOptional()
41+
@IsBoolean()
42+
@Type(() => Boolean)
43+
generateReport?: boolean;
3844
}

redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ export interface IBulkActionOverview {
1515
filter: IBulkActionFilterOverview; // Note: This can be null, according to the API response
1616
progress: IBulkActionProgressOverview;
1717
summary: IBulkActionSummaryOverview;
18+
downloadUrl?: string;
19+
error?: string;
1820
}

redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export interface IBulkAction {
77
getFilter(): BulkActionFilter;
88
changeState(): void;
99
getSocket(): Socket;
10+
writeToReport(keyName: Buffer, success: boolean, error?: string): void;
1011
}

redisinsight/api/src/modules/bulk-actions/models/bulk-action-summary.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,30 @@ describe('BulkActionSummary', () => {
8181
expect(summary['errors']).toEqual(generateErrors(500));
8282
});
8383
});
84+
describe('addKeys', () => {
85+
it('should add keys', async () => {
86+
expect(summary['keys']).toEqual([]);
87+
88+
summary.addKeys([Buffer.from('key1')]);
89+
90+
expect(summary['keys']).toEqual([Buffer.from('key1')]);
91+
92+
summary.addKeys([Buffer.from('key2'), Buffer.from('key3')]);
93+
94+
expect(summary['keys']).toEqual([
95+
Buffer.from('key1'),
96+
Buffer.from('key2'),
97+
Buffer.from('key3'),
98+
]);
99+
});
100+
});
84101
describe('getOverview', () => {
85102
it('should get overview and clear errors', async () => {
86103
expect(summary['processed']).toEqual(0);
87104
expect(summary['succeed']).toEqual(0);
88105
expect(summary['failed']).toEqual(0);
89106
expect(summary['errors']).toEqual([]);
107+
expect(summary['keys']).toEqual([]);
90108

91109
summary.addProcessed(1500);
92110
summary.addSuccess(500);

0 commit comments

Comments
 (0)