Skip to content

Commit b2d3dec

Browse files
committed
feat(database): enhance updateColumnsByIdWithoutHooks with heartbeat write logic and add tests for SQL-expression rejection
1 parent 56847d3 commit b2d3dec

7 files changed

Lines changed: 60 additions & 24 deletions

File tree

App/FeatureSet/Telemetry/Jobs/ProbeIngest/ProcessProbeIngest.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,14 @@ export async function processIncomingEmailFromQueue(
119119
onlyCheckForIncomingEmailReceivedAt: false,
120120
};
121121

122-
// Update monitor with last email received time
123-
await MonitorService.updateOneById({
122+
/*
123+
* Update monitor with last email received time. Heartbeat write:
124+
* single-statement UPDATE, no hooks and no `version` bump. These columns
125+
* trigger no onUpdateSuccess work and Monitor enables no update
126+
* workflow/realtime/audit, so nothing is lost. See
127+
* ServiceService.updateLastSeen.
128+
*/
129+
await MonitorService.updateColumnsByIdWithoutHooks({
124130
id: new ObjectID(monitor._id.toString()),
125131
data: {
126132
incomingEmailMonitorLastEmailReceivedAt: now,
@@ -130,9 +136,6 @@ export async function processIncomingEmailFromQueue(
130136
>,
131137
incomingEmailMonitorHeartbeatCheckedAt: now,
132138
},
133-
props: {
134-
isRoot: true,
135-
},
136139
});
137140

138141
/*

App/FeatureSet/Workers/Index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,6 @@ import "./Jobs/EnterpriseLicense/ReportUserCount";
193193

194194
import AnalyticsTableManagement from "./Utils/AnalyticsDatabase/TableManegement";
195195
import RunDatabaseMigrations from "./Utils/DataMigration";
196-
import { RunDatabaseMigrationsOnBoot } from "Common/Server/EnvironmentConfig";
197196
import JobDictionary from "./Utils/JobDictionary";
198197
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
199198
import Queue, { QueueJob, QueueName } from "Common/Server/Infrastructure/Queue";
@@ -204,6 +203,7 @@ import {
204203
DisableQueueWorkers,
205204
EnableQueueDashboard,
206205
QueueDashboardSecret,
206+
RunDatabaseMigrationsOnBoot,
207207
} from "Common/Server/EnvironmentConfig";
208208
import { WORKER_CONCURRENCY } from "./Config";
209209
import MetricsAPI from "./API/Metrics";

App/Migrate.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import RunDatabaseMigrations from "./FeatureSet/Workers/Utils/DataMigration";
88
import AnalyticsTableManagement from "./FeatureSet/Workers/Utils/AnalyticsDatabase/TableManegement";
99
import logger from "Common/Server/Utils/Logger";
10+
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
1011

1112
/*
1213
* One-shot migration runner. Runs the SAME schema + data migrations the app
@@ -22,7 +23,7 @@ import logger from "Common/Server/Utils/Logger";
2223

2324
const APP_NAME: string = "migrate";
2425

25-
const migrate = async (): Promise<void> => {
26+
const migrate: PromiseVoidFunction = async (): Promise<void> => {
2627
logger.debug(
2728
`${APP_NAME}: connecting to Postgres (applies schema migrations)`,
2829
);

Common/Server/Services/DatabaseService.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ import {
6969
} from "typeorm";
7070
import { ColumnMetadata } from "typeorm/metadata/ColumnMetadata";
7171
import { EntityMetadata } from "typeorm/metadata/EntityMetadata";
72-
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
7372
import { ObjectLiteral } from "typeorm/common/ObjectLiteral";
7473
import { FindWhere } from "../../Types/BaseDatabase/Query";
7574
import Realtime from "../Utils/Realtime";
@@ -2005,7 +2004,7 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
20052004
@CaptureSpan()
20062005
public async updateColumnsByIdWithoutHooks(input: {
20072006
id: ObjectID;
2008-
data: QueryDeepPartialEntity<TBaseModel>;
2007+
data: PartialEntity<TBaseModel>;
20092008
}): Promise<void> {
20102009
if (!input.id) {
20112010
throw new BadDataException("id is required");
@@ -2028,6 +2027,17 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
20282027
`updateColumnsByIdWithoutHooks: unknown column "${propertyName}" on "${metadata.tableName}"`,
20292028
);
20302029
}
2030+
/*
2031+
* PartialEntity permits `() => string` SQL-expression values, but this
2032+
* raw bind path can only parameterize literals (and the save()-based
2033+
* updateOneById it replaces doesn't support expressions either). Fail
2034+
* loudly rather than binding a function object.
2035+
*/
2036+
if (typeof value === "function") {
2037+
throw new BadDataException(
2038+
`updateColumnsByIdWithoutHooks: SQL-expression values are not supported (column "${propertyName}"); pass a literal value.`,
2039+
);
2040+
}
20312041
/*
20322042
* Run the value through the driver's persist path so column
20332043
* transformers (e.g. ObjectID <-> uuid), JSON stringification, and

Common/Server/Utils/Monitor/MonitorResource.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -316,17 +316,21 @@ export default class MonitorResourceUtil {
316316
* the monitor to flap between Online and Offline every minute.
317317
*/
318318
if (!serverMonitorResponse.onlyCheckRequestReceivedAt) {
319-
await MonitorService.updateOneById({
319+
/*
320+
* Heartbeat write: single-statement UPDATE, no hooks and no
321+
* `version` bump. Monitor enables no update workflow/realtime/
322+
* audit and these heartbeat columns trigger no onUpdateSuccess
323+
* work, so nothing is lost; it also skips the pre-SELECT that
324+
* would reload the large serverMonitorResponse jsonb row. See
325+
* ServiceService.updateLastSeen.
326+
*/
327+
await MonitorService.updateColumnsByIdWithoutHooks({
320328
id: monitor.id!,
321329
data: {
322330
serverMonitorRequestReceivedAt:
323331
serverMonitorResponse.requestReceivedAt!,
324332
serverMonitorResponse,
325333
},
326-
props: {
327-
isRoot: true,
328-
ignoreHooks: true,
329-
},
330334
});
331335

332336
logger.debug(
@@ -344,7 +348,13 @@ export default class MonitorResourceUtil {
344348
`${dataToProcess.monitorId.toString()} - Incoming request received at ${incomingMonitorRequest.incomingRequestReceivedAt}`,
345349
);
346350

347-
await MonitorService.updateOneById({
351+
/*
352+
* Heartbeat write: single-statement UPDATE, no hooks and no
353+
* `version` bump; skips the pre-SELECT of the large
354+
* incomingMonitorRequest jsonb row. See
355+
* ServiceService.updateLastSeen.
356+
*/
357+
await MonitorService.updateColumnsByIdWithoutHooks({
348358
id: monitor.id!,
349359
data: {
350360
incomingRequestMonitorHeartbeatCheckedAt:
@@ -353,10 +363,6 @@ export default class MonitorResourceUtil {
353363
JSON.stringify(incomingMonitorRequest),
354364
) as IncomingMonitorRequest,
355365
} as any,
356-
props: {
357-
isRoot: true,
358-
ignoreHooks: true,
359-
},
360366
});
361367

362368
logger.debug(

Common/Server/Utils/Telemetry/EntityRegistry.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,17 @@ export async function reconcileByNaturalKey<
271271
});
272272

273273
if (existing) {
274-
// Throttled bump of lastSeenAt (+ any caller-supplied merge fields).
275-
await data.service.updateOneById({
274+
/*
275+
* Throttled bump of lastSeenAt (+ any caller-supplied merge fields).
276+
* Heartbeat write: single-statement UPDATE, no hooks and no `version`
277+
* bump (TelemetryEntity/Relationship enable no update workflow/realtime/
278+
* audit). buildBump returns only plain values — lastSeenAt plus, at most,
279+
* the descriptiveAttributes / labels JSON columns — which the primitive
280+
* persists via the driver transformer path. See ServiceService.updateLastSeen.
281+
*/
282+
await data.service.updateColumnsByIdWithoutHooks({
276283
id: existing.id!,
277284
data: buildBump(existing),
278-
props: { isRoot: true },
279285
});
280286
return;
281287
}
@@ -306,10 +312,9 @@ export async function reconcileByNaturalKey<
306312
logger.debug(
307313
`EntityRegistry: create raced for ${data.describe} (concurrent insert); bumping lastSeenAt on the winning row.`,
308314
);
309-
await data.service.updateOneById({
315+
await data.service.updateColumnsByIdWithoutHooks({
310316
id: winner.id!,
311317
data: buildBump(winner),
312-
props: { isRoot: true },
313318
});
314319
return;
315320
}

Common/Tests/Server/Services/UpdateColumnsByIdWithoutHooks.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,17 @@ describe("DatabaseService.updateColumnsByIdWithoutHooks", () => {
128128
).rejects.toThrow(/unknown column "notAColumn"/);
129129
});
130130

131+
test("rejects SQL-expression (function) values it cannot bind", async () => {
132+
const query: jest.Mock = mockRepository();
133+
await expect(
134+
ServiceService.updateColumnsByIdWithoutHooks({
135+
id: ObjectID.generate(),
136+
data: { lastSeenAt: (() => "NOW()") as never } as never,
137+
}),
138+
).rejects.toThrow(/SQL-expression values are not supported/);
139+
expect(query).not.toHaveBeenCalled();
140+
});
141+
131142
test("does not issue a query when there are no columns to update", async () => {
132143
const query: jest.Mock = mockRepository();
133144
await ServiceService.updateColumnsByIdWithoutHooks({

0 commit comments

Comments
 (0)