Skip to content
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

feat: calculate usage incrementally #436

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions migrations/20241120002037-create-usages-table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict';

const tableName = 'usages';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable(tableName, {
id: {
type: Sequelize.UUID,
primaryKey: true,
defaultValue: Sequelize.UUIDV4,
},
user_id: {
type: Sequelize.UUID,
allowNull: false,
},
delta: {
type: Sequelize.BIGINT,
allowNull: false,
defaultValue: 0,
},
period: {
type: Sequelize.DATEONLY,
allowNull: false,
},
type: {
type: Sequelize.STRING,
allowNull: false,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
});
},

async down(queryInterface) {
await queryInterface.dropTable(tableName);
},
};
17 changes: 17 additions & 0 deletions migrations/20241120002050-create-usages-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use strict';

const indexName = 'usage_user_type_period_index';

module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(
`CREATE INDEX CONCURRENTLY ${indexName} ON usages (user_id, type, period)`,
);
},

async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(
`DROP INDEX CONCURRENTLY ${indexName}`,
);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE OR REPLACE PROCEDURE calculate_last_day_usage()
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at)
SELECT
uuid_generate_v4() AS id,
u.uuid::uuid AS user_id,
SUM(
CASE
WHEN f.status = 'DELETED' AND date_trunc('day', f.created_at) = date_trunc('day', f.updated_at) THEN 0
WHEN f.status = 'DELETED' THEN -f.size
ELSE f.size
END
) AS delta,
CURRENT_DATE - INTERVAL '1 day' AS period,
'monthly' AS type,
NOW() AS created_at,
NOW() AS updated_at
FROM
files f
JOIN users u ON u.id = f.user_id
LEFT JOIN (
SELECT user_id, MAX(period) AS last_period
FROM public.usages WHERE type = 'monthly'
GROUP BY user_id
) mru
ON u.uuid::uuid = mru.user_id::uuid
WHERE
(mru.last_period IS NOT NULL AND mru.last_period != CURRENT_DATE - INTERVAL '1 day')
AND (
(f.status != 'DELETED' AND f.created_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond')
OR
(f.status = 'DELETED' AND f.updated_at BETWEEN CURRENT_DATE - INTERVAL '1 day' AND CURRENT_DATE - INTERVAL '1 millisecond')
)
GROUP BY
u.uuid;
END;
$$;
`);
},

async down(queryInterface) {
await queryInterface.sequelize.query(`
DROP PROCEDURE IF EXISTS calculate_last_day_usage;
`);
},
};
60 changes: 60 additions & 0 deletions migrations/20241120010831-create-yearly-usage-procedure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE OR REPLACE PROCEDURE generate_yearly_usage()
LANGUAGE plpgsql
AS $$
BEGIN
WITH monthly_rows AS (
SELECT
user_id,
SUM(delta) AS delta,
date_trunc('year', CURRENT_DATE) - INTERVAL '1 year' AS period
FROM
public.usages
WHERE
period >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year'
AND period < date_trunc('year', CURRENT_DATE)
AND type IN ('monthly', 'daily')
GROUP BY
user_id
)
INSERT INTO
public.usages (
id,
user_id,
delta,
period,
type,
created_at,
updated_at
)
SELECT
uuid_generate_v4(),
user_id,
delta,
period,
'yearly' AS type,
NOW() AS created_at,
NOW() AS updated_at
FROM
monthly_rows;
DELETE FROM public.usages
WHERE
period >= date_trunc('year', CURRENT_DATE) - INTERVAL '1 year'
AND period < date_trunc('year', CURRENT_DATE)
AND type IN ('monthly', 'daily');
END;
$$;
`);
},

async down(queryInterface) {
await queryInterface.sequelize.query(`
DROP PROCEDURE IF EXISTS generate_yearly_usage;
`);
},
};
2 changes: 2 additions & 0 deletions src/modules/file/file.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { SharingModule } from '../sharing/sharing.module';
import { WorkspacesModule } from '../workspaces/workspaces.module';
import { UserModule } from '../user/user.module';
import { NotificationModule } from '../../externals/notifications/notifications.module';
import { UsageModule } from '../usage/usage.module';

@Module({
imports: [
Expand All @@ -28,6 +29,7 @@ import { NotificationModule } from '../../externals/notifications/notifications.
CryptoModule,
UserModule,
NotificationModule,
UsageModule,
],
controllers: [FileController],
providers: [SequelizeFileRepository, FileUseCases],
Expand Down
51 changes: 51 additions & 0 deletions src/modules/file/file.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export interface FileRepository {
userId: User['id'],
fileIds: FileAttributes['fileId'][],
): Promise<File[]>;
sumFileSizesSinceDate(
userId: FileAttributes['userId'],
sinceDate: Date,
): Promise<number>;
}

@Injectable()
Expand Down Expand Up @@ -631,6 +635,53 @@ export class SequelizeFileRepository implements FileRepository {
});
}

async sumFileSizesSinceDate(
userId: FileAttributes['userId'],
sinceDate: Date,
untilDate?: Date,
): Promise<number> {
const timeCondition = {
[Op.gte]: sinceDate,
...(untilDate ? { [Op.lte]: untilDate } : null),
};

const result = await this.fileModel.findAll({
attributes: [
[
Sequelize.literal(`
SUM(
CASE
WHEN status = 'DELETED' AND date_trunc('day', created_at) = date_trunc('day', updated_at) THEN 0
WHEN status = 'DELETED' THEN -size
ELSE size
END
)
`),
'total',
],
],
where: {
userId,
[Op.or]: [
{
status: {
[Op.ne]: 'DELETED',
},
createdAt: timeCondition,
},
{
status: 'DELETED',
updatedAt: timeCondition,
},
],
},
raw: true,
logging: console.log,
});

return Number(result[0]['total']) as unknown as number;
}

async getFilesWhoseFolderIdDoesNotExist(
userId: File['userId'],
): Promise<number> {
Expand Down
67 changes: 67 additions & 0 deletions src/modules/file/file.usecase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { SharingService } from '../sharing/sharing.service';
import { SharingItemType } from '../sharing/sharing.domain';
import { CreateFileDto } from './dto/create-file.dto';
import { UpdateFileMetaDto } from './dto/update-file-meta.dto';
import { UsageUseCases } from '../usage/usage.usecase';

const fileId = '6295c99a241bb000083f1c6a';
const userId = 1;
Expand All @@ -48,6 +49,8 @@ describe('FileUseCases', () => {
let sharingService: SharingService;
let bridgeService: BridgeService;
let cryptoService: CryptoService;
let usageUsecases: UsageUseCases;
let networkService: BridgeService;

const userMocked = User.build({
id: 1,
Expand Down Expand Up @@ -94,6 +97,8 @@ describe('FileUseCases', () => {
bridgeService = module.get<BridgeService>(BridgeService);
cryptoService = module.get<CryptoService>(CryptoService);
sharingService = module.get<SharingService>(SharingService);
usageUsecases = module.get<UsageUseCases>(UsageUseCases);
networkService = module.get<BridgeService>(BridgeService);
});

afterEach(() => {
Expand Down Expand Up @@ -1114,4 +1119,66 @@ describe('FileUseCases', () => {
);
});
});

describe('replaceFile', () => {
const originalFile = newFile({
attributes: {
size: BigInt(500),
status: FileStatus.EXISTS,
},
});

const newFileData = {
fileId: v4(),
size: BigInt(1000),
};

it('When the file does not exist, it should throw', async () => {
jest.spyOn(fileRepository, 'findByUuid').mockResolvedValueOnce(null);

await expect(
service.replaceFile(userMocked, originalFile.uuid, newFileData),
).rejects.toThrow(NotFoundException);
});

it('When the file was deleted or trashed, it should throw', async () => {
const trashedFile = newFile({
attributes: { ...originalFile, status: FileStatus.DELETED },
});

jest
.spyOn(fileRepository, 'findByUuid')
.mockResolvedValueOnce(trashedFile);

await expect(
service.replaceFile(userMocked, originalFile.uuid, newFileData),
).rejects.toThrow(NotFoundException);
});

it('When the file exists and is valid, it updates the file data correctly', async () => {
jest
.spyOn(fileRepository, 'findByUuid')
.mockResolvedValueOnce(originalFile);
jest.spyOn(networkService, 'deleteFile').mockResolvedValueOnce(null);
jest.spyOn(fileRepository, 'updateByUuidAndUserId');
jest
.spyOn(usageUsecases, 'addDailyUsageChangeOnFileSizeChange')
.mockResolvedValueOnce(null);

await service.replaceFile(userMocked, originalFile.uuid, newFileData);

expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith(
originalFile.uuid,
userMocked.id,
newFileData,
);
expect(
usageUsecases.addDailyUsageChangeOnFileSizeChange,
).toHaveBeenCalledWith(
userMocked,
originalFile,
expect.objectContaining(newFileData),
);
});
});
});
16 changes: 14 additions & 2 deletions src/modules/file/file.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { Folder } from '../folder/folder.domain';
import { getPathFileData } from '../../lib/path';
import { isStringEmpty } from '../../lib/validators';
import { FileModel } from './file.model';
import { UsageUseCases } from '../usage/usage.usecase';

export type SortParamsFile = Array<[SortableFileAttributes, 'ASC' | 'DESC']>;

Expand All @@ -50,6 +51,7 @@ export class FileUseCases {
private sharingUsecases: SharingService,
private network: BridgeService,
private cryptoService: CryptoService,
private usageUsecases: UsageUseCases,
) {}

getByUuid(uuid: FileAttributes['uuid']): Promise<File> {
Expand Down Expand Up @@ -570,7 +572,7 @@ export class FileUseCases {
): Promise<FileDto> {
const file = await this.fileRepository.findByUuid(fileUuid, user.id);

if (!file) {
if (!file || file?.status != FileStatus.EXISTS) {
throw new NotFoundException(`File ${fileUuid} not found`);
}

Expand All @@ -581,7 +583,17 @@ export class FileUseCases {
fileId,
size,
});
await this.network.deleteFile(user, bucket, oldFileId);

const newFile = File.build({ ...file, size, fileId });

await Promise.all([
this.network.deleteFile(user, bucket, oldFileId),
this.usageUsecases.addDailyUsageChangeOnFileSizeChange(
user,
file,
newFile,
),
]);

return {
...file.toJSON(),
Expand Down
Loading
Loading