From e27bbae3aeaaffc37a0a36890b9cf7c73ec99118 Mon Sep 17 00:00:00 2001 From: Overtorment Date: Wed, 22 Jul 2026 10:01:19 +0100 Subject: [PATCH] FIX: crash on duplicate insert --- src/controller/GroundController.ts | 8 +++++- src/index.ts | 10 ++++++- src/tests/GroundController.test.ts | 43 ++++++++++++++++++++++++++++++ src/worker-sender.ts | 8 +++++- 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/controller/GroundController.ts b/src/controller/GroundController.ts index 4b5115c..85d0677 100644 --- a/src/controller/GroundController.ts +++ b/src/controller/GroundController.ts @@ -297,7 +297,13 @@ export class GroundController { tokenConfig = new TokenConfiguration(); tokenConfig.token = body.token; tokenConfig.os = body.os; - await this.tokenConfigurationRepository.save(tokenConfig); + try { + await this.tokenConfigurationRepository.save(tokenConfig); + } catch (error) { + if (error?.code !== "ER_DUP_ENTRY") throw error; + // lost a create race: a concurrent request already inserted this (token, os); use that row + tokenConfig = await this.tokenConfigurationRepository.findOneBy({ token: body.token, os: body.os }); + } } const config: paths["/getTokenConfiguration"]["post"]["responses"]["200"]["content"]["application/json"] = { diff --git a/src/index.ts b/src/index.ts index 787b97b..7c002b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,7 +125,15 @@ dataSource (app as any)[route.method](route.route, (req: Request, res: Response, next: Function) => { const result = new (route.controller as any)(c)[route.action](req, res, next); if (result instanceof Promise) { - result.then((result) => (result !== null && result !== undefined ? res.send(result) : undefined)); + result + .then((result) => (result !== null && result !== undefined ? res.send(result) : undefined)) + .catch((error) => { + // without this catch a rejected handler promise becomes an unhandled rejection and kills the process + console.error(`error handling ${route.route}:`, error); + if (!res.headersSent) { + res.status(500).send(""); + } + }); } else if (result !== null && result !== undefined) { res.json(result); } diff --git a/src/tests/GroundController.test.ts b/src/tests/GroundController.test.ts index ecb6775..096b353 100644 --- a/src/tests/GroundController.test.ts +++ b/src/tests/GroundController.test.ts @@ -439,5 +439,48 @@ describe("GroundController", () => { app_version: "1.0.0", }); }); + + it("should return existing configuration when concurrent request created it first (ER_DUP_ENTRY)", async () => { + const concurrentlyCreatedConfig = { + level_all: true, + level_transactions: false, + level_price: true, + level_news: false, + level_tips: true, + redacted: false, + lang: "es", + app_version: "2.0.0", + }; + const duplicateError: any = new Error("Duplicate entry 'test-token:ios' for key 'IDX_40ed4dd221d0ff99647ddfa6ec'"); + duplicateError.code = "ER_DUP_ENTRY"; + duplicateError.errno = 1062; + + mockRepository.findOneBy.mockResolvedValueOnce(null).mockResolvedValueOnce(concurrentlyCreatedConfig); + mockRepository.save.mockRejectedValueOnce(duplicateError); + + const result = await groundController.getTokenConfiguration(mockRequest, mockResponse, mockNext); + + expect(mockRepository.findOneBy).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + level_all: true, + level_transactions: false, + level_price: true, + level_news: false, + level_tips: true, + redacted: false, + lang: "es", + app_version: "2.0.0", + }); + }); + + it("should rethrow non-duplicate save errors", async () => { + const dbError: any = new Error("Connection lost"); + dbError.code = "PROTOCOL_CONNECTION_LOST"; + + mockRepository.findOneBy.mockResolvedValue(null); + mockRepository.save.mockRejectedValueOnce(dbError); + + await expect(groundController.getTokenConfiguration(mockRequest, mockResponse, mockNext)).rejects.toThrow("Connection lost"); + }); }); }); diff --git a/src/worker-sender.ts b/src/worker-sender.ts index c4b89cc..b597b97 100644 --- a/src/worker-sender.ts +++ b/src/worker-sender.ts @@ -75,7 +75,13 @@ dataSource tokenConfig = new TokenConfiguration(); tokenConfig.os = payload.os; tokenConfig.token = payload.token; - await tokenConfigurationRepository.save(tokenConfig); + try { + await tokenConfigurationRepository.save(tokenConfig); + } catch (error) { + if (error?.code !== "ER_DUP_ENTRY") throw error; + // lost a create race with the API or another worker; use the existing row + tokenConfig = await tokenConfigurationRepository.findOneBy({ os: payload.os, token: payload.token }); + } } let unsubscribed = false;