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

엣지 생성, 삭제 api로 작업 위임 #51

Merged
merged 10 commits into from
Jan 21, 2025
Merged
12 changes: 6 additions & 6 deletions apps/backend/src/edge/edge.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,24 @@ describe('EdgeController', () => {
});

describe('deleteEdge', () => {
it('id에 해당하는 엣지를 찾아 삭제한다.', async () => {
const id = 2;
it('fromNode, toNode에 해당하는 엣지를 찾아 삭제한다.', async () => {
const expectedResponse = {
message: EdgeResponseMessage.EDGE_DELETED,
};

const result = await controller.deleteEdge(id);
jest.spyOn(edgeService, 'deleteEdge').mockResolvedValue(undefined);
const result = await controller.deleteEdge(1, 3);

expect(edgeService.deleteEdge).toHaveBeenCalledWith(id);
expect(edgeService.deleteEdge).toHaveBeenCalledWith(1, 3);
expect(result).toEqual(expectedResponse);
});

it('id에 해당하는 엣지가 존재하지 않으면 NodeNotFoundException을 throw한다.', async () => {
it('fromNode, toNode에 해당하는 엣지가 존재하지 않으면 NodeNotFoundException을 throw한다.', async () => {
jest
.spyOn(edgeService, 'deleteEdge')
.mockRejectedValue(new EdgeNotFoundException());

await expect(controller.deleteEdge(1)).rejects.toThrow(
await expect(controller.deleteEdge(1, 3)).rejects.toThrow(
EdgeNotFoundException,
);
});
Expand Down
9 changes: 5 additions & 4 deletions apps/backend/src/edge/edge.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ export class EdgeController {

@ApiResponse({ type: MessageResponseDto })
@ApiOperation({ summary: '엣지를 삭제합니다.' })
@Delete('/:id')
@Delete('/:fromNode/:toNode') // URL 경로에서 fromNode와 toNode를 추출
@HttpCode(HttpStatus.OK)
async deleteEdge(
@Param('id', ParseIntPipe) id: number,
): Promise<{ message: string }> {
await this.edgeService.deleteEdge(id);
@Param('fromNode', ParseIntPipe) fromNode: number,
@Param('toNode', ParseIntPipe) toNode: number,
) {
await this.edgeService.deleteEdge(fromNode, toNode);

return {
message: EdgeResponseMessage.EDGE_DELETED,
Expand Down
30 changes: 16 additions & 14 deletions apps/backend/src/edge/edge.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ describe('EdgeService', () => {
useValue: {
create: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
remove: jest.fn(),
findOneBy: jest.fn(),
findOne: jest.fn(),
findEdgesByWorkspace: jest.fn(),
},
},
Expand All @@ -35,6 +36,7 @@ describe('EdgeService', () => {
useValue: {
save: jest.fn(),
findOneBy: jest.fn(),
findOne: jest.fn(),
},
},
{
Expand Down Expand Up @@ -86,41 +88,41 @@ describe('EdgeService', () => {
id: 1,
fromNode: fromNode,
toNode: toNode,
workspace: null,
} as Edge;

jest
.spyOn(nodeRepository, 'findOneBy')
.mockResolvedValueOnce(fromNode) // 첫 번째 호출: fromNode
.mockResolvedValueOnce(toNode); // 두 번째 호출: toNode
jest.spyOn(nodeRepository, 'findOne').mockResolvedValueOnce(fromNode); // 첫 번째 호출: fromNode
jest.spyOn(nodeRepository, 'findOneBy').mockResolvedValueOnce(toNode); // 두 번째 호출: toNode
jest.spyOn(edgeRepository, 'save').mockResolvedValue(edge);

const result = await service.createEdge(dto);

expect(result).toEqual(edge);
expect(edgeRepository.save).toHaveBeenCalledTimes(1);
expect(nodeRepository.findOneBy).toHaveBeenCalledTimes(2);
expect(nodeRepository.findOne).toHaveBeenCalledTimes(1);
expect(nodeRepository.findOneBy).toHaveBeenCalledTimes(1);
});
});

describe('deleteEdge', () => {
it('엣지를 성공적으로 삭제한다.', async () => {
jest
.spyOn(edgeRepository, 'delete')
.spyOn(edgeRepository, 'remove')
.mockResolvedValue({ affected: true } as any);
jest.spyOn(edgeRepository, 'findOneBy').mockResolvedValue(new Edge());
jest.spyOn(edgeRepository, 'findOne').mockResolvedValue(new Edge());

await service.deleteEdge(1);
await service.deleteEdge(1, 3);

expect(edgeRepository.delete).toHaveBeenCalledWith(1);
expect(edgeRepository.remove).toHaveBeenCalledTimes(1);
});

it('삭제할 엣지가 존재하지 않으면 EdgeNotFoundException을 throw한다.', async () => {
jest
.spyOn(edgeRepository, 'delete')
.mockResolvedValue({ affected: false } as any);
await expect(service.deleteEdge(1)).rejects.toThrow(
jest.spyOn(edgeRepository, 'findOne').mockResolvedValue(undefined);

await expect(service.deleteEdge(1, 3)).rejects.toThrow(
EdgeNotFoundException,
);
expect(edgeRepository.remove).toHaveBeenCalledTimes(0);
});
});

Expand Down
30 changes: 23 additions & 7 deletions apps/backend/src/edge/edge.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,43 @@ export class EdgeService {
const { fromNode, toNode } = dto;

// 출발 노드를 조회한다.
const existingFromNode = await this.nodeRepository.findOneBy({
id: fromNode,
const existingFromNode = await this.nodeRepository.findOne({
where: { id: fromNode },
relations: ['workspace'], // workspace 관계를 포함
});

// 도착 노드를 조회한다.
const existingToNode = await this.nodeRepository.findOneBy({ id: toNode });

// 엣지를 생성한다.
return await this.edgeRepository.save({
fromNode: existingFromNode,
toNode: existingToNode,
workspace: existingFromNode.workspace,
});
}

async deleteEdge(id: number): Promise<void> {
// 엣지를 삭제한다
const deleteResult = await this.edgeRepository.delete(id);
async deleteEdge(fromNode: number, toNode: number): Promise<void> {
// fromNode와 toNode로 매치되는 엣지를 검색

// 삭제된 엣지가 없으면 노드를 찾지 못한 것
if (!deleteResult.affected) {
// 출발 노드를 조회한다.
const existingFromNode = await this.nodeRepository.findOneBy({
id: fromNode,
});
// 도착 노드를 조회한다.
const existingToNode = await this.nodeRepository.findOneBy({ id: toNode });

const edge = await this.edgeRepository.findOne({
where: { fromNode: existingFromNode, toNode: existingToNode },
});

// 엣지가 없으면 예외를 발생시킴
if (!edge) {
throw new EdgeNotFoundException();
}

// 엣지를 삭제
await this.edgeRepository.remove(edge);
}

async findEdgeByFromNodeAndToNode(fromNodeId: number, toNodeId: number) {
Expand Down
6 changes: 0 additions & 6 deletions apps/backend/src/redis/redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ export type RedisNode = {
color?: string;
};

export type RedisEdge = {
fromNode?: number;
toNode?: number;
type?: 'add' | 'delete';
};

@Injectable()
export class RedisService {
constructor(
Expand Down
88 changes: 3 additions & 85 deletions apps/backend/src/tasks/tasks.service.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { RedisEdge, RedisPage, RedisNode } from '../redis/redis.service';
import { RedisPage, RedisNode } from '../redis/redis.service';
import { DataSource } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { Page } from '../page/page.entity';
import { Node } from '../node/node.entity';
import { Edge } from '../edge/edge.entity';
import { Inject } from '@nestjs/common';
import Redis from 'ioredis';

const REDIS_CLIENT_TOKEN = 'REDIS_CLIENT';

const releaseScript = `
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
`;

@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
Expand All @@ -35,12 +26,12 @@ export class TasksService {

const pageKeys = await this.redisClient.keys('page:*');
const nodeKeys = await this.redisClient.keys('node:*');
const edgeKeys = await this.redisClient.keys('edge:*');
// const edgeKeys = await this.redisClient.keys('edge:*');

Promise.allSettled([
...pageKeys.map(this.migratePage.bind(this)),
...nodeKeys.map(this.migrateNode.bind(this)),
...edgeKeys.map(this.migrateEdge.bind(this)),
// ...edgeKeys.map(this.migrateEdge.bind(this)),
])
.then((results) => {
const endTime = performance.now();
Expand Down Expand Up @@ -170,77 +161,4 @@ export class TasksService {
await queryRunner.release();
}
}

async migrateEdge(key: string) {
// 낙관적 락 적용
await this.redisClient.watch(key);

const data = await this.redisClient.hgetall(key);
const redisData = Object.fromEntries(
Object.entries(data).map(([field, value]) => [field, value]),
) as RedisEdge;

// 데이터 없으면 오류
if (!redisData) {
throw new Error(`redis에 ${key}에 해당하는 데이터가 없습니다.`);
}

// 트랜잭션 시작
const queryRunner = this.dataSource.createQueryRunner();
const redisRunner = this.redisClient.multi();

try {
await queryRunner.startTransaction();

// 갱신 시작
const edgeRepository = queryRunner.manager.getRepository(Edge);
const nodeRepository = queryRunner.manager.getRepository(Node);

const fromNode = await nodeRepository.findOne({
where: { id: redisData.fromNode },
relations: ['workspace'],
});

const toNode = await nodeRepository.findOne({
where: { id: redisData.toNode },
});

if (redisData.type === 'add') {
await edgeRepository.save({
fromNode,
toNode,
workspace: fromNode.workspace,
});
}

// if (redisData.type === 'delete') {
// const edge = await edgeRepository.findOne({
// where: { fromNode, toNode },
// });
// console.log(`edge 정보 `);
// console.log(edge);
// console.log(`edge content : ${edge}`);

// await edgeRepository.delete({ id: edge.id });
// }

// redis에서 데이터 삭제
redisRunner.del(key);

// 트랜잭션 커밋
await queryRunner.commitTransaction();
await redisRunner.exec();
} catch (err) {
// 실패하면 postgres는 roll back하고 redis의 값을 살린다.
this.logger.error(err.stack);
await queryRunner.rollbackTransaction();
redisRunner.discard();

// Promise.all에서 실패를 인식하기 위해 에러를 던진다.
throw err;
} finally {
// 리소스 정리
await queryRunner.release();
}
}
}
32 changes: 13 additions & 19 deletions apps/websocket/src/yjs/yjs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
prosemirrorJSONToYXmlFragment,
} from 'y-prosemirror';
import { novelEditorSchema } from './yjs.schema';
import { YMapEdge } from './yjs.type';
import type { Node } from './types/node.entity';
import type { Edge } from './types/edge.entity';
import { RedisService } from '../redis/redis.service';
Expand Down Expand Up @@ -160,7 +159,7 @@ export class YjsService

// edge의 변경 사항을 감지한다.
edgesMap.observe(async (event) => {
this.observeEdgeMap(event, edgesMap);
this.observeEdgeMap(event);
});
} catch (error) {
this.logger.error(
Expand Down Expand Up @@ -303,29 +302,24 @@ export class YjsService
}
}

private async observeEdgeMap(
event: Y.YMapEvent<unknown>,
edgesMap: Y.Map<unknown>,
) {
private async observeEdgeMap(event: Y.YMapEvent<unknown>) {
for (const [key, change] of event.changes.keys) {
const [fromNode, toNode] = key.slice(1).split('-');
// TODO: 여기서 delete 시 edge를 못찾음 (undefined로 가져옴)
const edge = edgesMap.get(key) as YMapEdge;

console.log(`change: ${change.action}`);
console.log(`fromNode: ${fromNode}, toNode: ${toNode}`);

if (change.action === 'add') {
// 연결된 노드가 없을 때만 edge 생성
await this.redisService.setFields(
`edge:${edge.source}-${edge.target}`,
{ fromNode: edge.source, toNode: edge.target, type: 'add' },
);
const edgeData = {
fromNode: parseInt(fromNode),
toNode: parseInt(toNode),
};
await axios.post(`http://backend:3000/api/edge`, edgeData);
}
if (change.action === 'delete') {
// 엣지가 존재하면 삭제
await this.redisService.setFields(`edge:${fromNode}-${toNode}`, {
fromNode,
toNode,
type: 'delete',
});
await axios.delete(
`http://backend:3000/api/edge/${fromNode}/${toNode}`,
);
}
}
}
Expand Down
Loading
Loading