querysub 0.509.0 → 0.511.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,29 +66,37 @@ export type MachineInfo = {
66
66
  };
67
67
 
68
68
 
69
+ export type ServiceParameters = {
70
+ /** MUST be unique, and clean enough to be used as the screen/tmux name, and folder name */
71
+ key: string;
72
+
73
+ /** There can be duplicate machine IDs which just means we apply multiple times on that machine, as `${key}-${index}-dply`, each with their own screen and folder. Part of the parameters so machine changes are released on the same schedule as everything else (but they never restart already-running instances, see restartParametersKey). Optional, as already deployed configs predate the field (treat missing as []). */
74
+ machineIds?: string[];
75
+
76
+ repoUrl: string;
77
+ gitRef: string;
78
+ command: string;
79
+ /** Allows forcing an update */
80
+ poke?: number;
81
+
82
+ /** Not set by default, so we can setup the configuration before deploying it (or so we can undeploy easily without deleting it) */
83
+ deploy?: boolean;
84
+
85
+ /** How long the new and old instances run simultaneously during a release. The new instances start at releaseTime - overlapTime, and the old instances are shut down at releaseTime. */
86
+ overlapTime?: number;
87
+
88
+ /** When these parameters go live. Until then machines keep running oldParameters. Absent (or in the past) means live now. */
89
+ releaseTime?: number;
90
+ };
91
+
69
92
  export type ServiceConfig = {
70
93
  /** Just a random id to manage the service */
71
94
  serviceId: string;
72
95
 
73
- /** There can be duplicate machine IDs which just means we apply multiple times on that machine, as `${key}-${index}-dply`, each with their own screen and folder */
74
- machineIds: string[];
75
96
  /** When parameters update, we restart it (and when info updates, we do not) */
76
- parameters: {
77
- /** MUST be unique, and clean enough to be used as the screen/tmux name, and folder name */
78
- key: string;
79
-
80
- repoUrl: string;
81
- gitRef: string;
82
- command: string;
83
- /** Allows forcing an update */
84
- poke?: number;
85
-
86
- /** Not set by default, so we can setup the configuration before deploying it (or so we can undeploy easily without deleting it) */
87
- deploy?: boolean;
88
-
89
- /** How long the new and old instances run simultaneously during a switchover. The new instances start at switchTime - overlapTime, and the old instances are shut down at switchTime. This is the default; each switchover can override it. */
90
- overlapTime?: number;
91
- };
97
+ parameters: ServiceParameters;
98
+ /** What machines keep running until parameters.releaseTime passes. Set automatically on every update (never by callers). */
99
+ oldParameters?: ServiceParameters;
92
100
  info: {
93
101
  title: string;
94
102
  notes: string;
@@ -96,25 +104,49 @@ export type ServiceConfig = {
96
104
  };
97
105
  };
98
106
 
99
- export const DEFAULT_OVERLAP_TIME = timeInMinute;
107
+ export const DEFAULT_OVERLAP_TIME = timeInMinute * 10;
108
+
109
+ /** The parameters machines should actually be running right now. */
110
+ export function getLiveServiceParameters(config: ServiceConfig): ServiceParameters {
111
+ let releaseTime = config.parameters.releaseTime;
112
+ if (releaseTime && Date.now() < releaseTime && config.oldParameters) {
113
+ return config.oldParameters;
114
+ }
115
+ return config.parameters;
116
+ }
117
+ /** The full config as it is currently live (which parameters field that is depends on the current time). */
118
+ export function getLiveServiceConfig(config: ServiceConfig): ServiceConfig {
119
+ return { ...config, parameters: getLiveServiceParameters(config), oldParameters: undefined };
120
+ }
121
+ export function stripReleaseState(config: ServiceConfig): ServiceConfig {
122
+ return { ...config, oldParameters: undefined, parameters: { ...config.parameters, releaseTime: undefined } };
123
+ }
124
+ /** The parts of the parameters that a running instance actually depends on. machineIds / releaseTime / overlapTime don't change what an instance runs, so changes to only them must never restart already-running instances (ex, adding a machine must not restart the existing machines). */
125
+ export function restartParametersKey(parameters: ServiceParameters): string {
126
+ return JSON.stringify({ ...parameters, machineIds: undefined, releaseTime: undefined, overlapTime: undefined });
127
+ }
128
+ function getConfigMachineIds(config: ServiceConfig): string[] {
129
+ return [...new Set([...(config.parameters.machineIds || []), ...(config.oldParameters?.machineIds || [])])];
130
+ }
131
+ // Migrates records that stored machineIds at the top level of ServiceConfig (it now lives in parameters)
132
+ function normalizeServiceConfig(config: ServiceConfig): ServiceConfig {
133
+ let legacyMachineIds = (config as unknown as { machineIds?: string[] }).machineIds;
134
+ if (legacyMachineIds) {
135
+ config.parameters.machineIds = config.parameters.machineIds || legacyMachineIds;
136
+ if (config.oldParameters) {
137
+ config.oldParameters.machineIds = config.oldParameters.machineIds || legacyMachineIds;
138
+ }
139
+ delete (config as unknown as { machineIds?: string[] }).machineIds;
140
+ }
141
+ return config;
142
+ }
100
143
 
101
- export type ServiceSwitchover = {
102
- serviceId: string;
103
- /** When the old instances are shut down and the pending config becomes live */
104
- switchTime: number;
105
- /** The new instances start at switchTime - overlapTime */
106
- overlapTime: number;
107
- scheduledAt: number;
108
- };
109
144
  export type MachineConfig = {
110
145
  machineId: string;
111
146
  disabled: boolean;
112
147
  };
113
148
  export const machineInfos = archiveJSONT<MachineInfo>(() => nestArchives("machines/machine-heartbeats/", getArchivesBackblaze(getDomain())));
114
149
  export const serviceConfigs = archiveJSONT<ServiceConfig>(() => nestArchives("machines/service-configs/", getArchivesBackblaze(getDomain())));
115
- // Edits in the UI save here (still on disk), and only become live when a switchover is deployed / scheduled
116
- export const pendingServiceConfigs = archiveJSONT<ServiceConfig>(() => nestArchives("machines/service-configs-pending/", getArchivesBackblaze(getDomain())));
117
- export const serviceSwitchovers = archiveJSONT<ServiceSwitchover>(() => nestArchives("machines/service-switchovers/", getArchivesBackblaze(getDomain())));
118
150
  export const machineConfigs = archiveJSONT<MachineConfig>(() => nestArchives("machines/machine-configs/", getArchivesBackblaze(getDomain())));
119
151
 
120
152
  export type LaunchRecord = {
@@ -234,7 +266,8 @@ export class MachineServiceControllerBase {
234
266
  ])];
235
267
  }
236
268
  public async getServiceConfig(serviceId: string): Promise<ServiceConfig | undefined> {
237
- return await serviceConfigs.get(serviceId);
269
+ let config = await serviceConfigs.get(serviceId);
270
+ return config && normalizeServiceConfig(config);
238
271
  }
239
272
 
240
273
  public async getMachineConfigList() {
@@ -278,14 +311,14 @@ export class MachineServiceControllerBase {
278
311
  throw new Error(`Service ${serviceId} already exists`);
279
312
  }
280
313
  await serviceConfigs.set(serviceId, config);
281
- await this.notifyMachines(config.machineIds, []);
314
+ void this.notifyMachines(getConfigMachineIds(config), []);
282
315
  }
283
316
 
284
317
  public async setServiceConfigs(configs: ServiceConfig[]) {
285
318
  let newMachines = new Set<string>();
286
319
  let oldMachines = new Set<string>();
287
320
  let usedKeys = new Set<string>();
288
- for (let config of configs) {
321
+ await Promise.all(configs.map(async config => {
289
322
  if (usedKeys.has(config.parameters.key)) {
290
323
  console.warn(`Duplicate key: ${JSON.stringify(config.parameters.key)}. Not allowed, as this will break things! We are unduplicating the key.`);
291
324
  config.parameters.key += `-${Math.random().toString().slice(2)}`;
@@ -299,19 +332,34 @@ export class MachineServiceControllerBase {
299
332
  if (!serviceConfig) {
300
333
  throw new Error(`Service ${serviceId} does not exist`);
301
334
  }
335
+ normalizeServiceConfig(serviceConfig);
336
+
337
+ // The old parameters are whatever is live right now: the previous parameters if their release time passed, else the previous old parameters (an unreleased update was updated again, and machines just keep running what they were running).
338
+ if (config.parameters.releaseTime && Date.now() < config.parameters.releaseTime) {
339
+ let liveParameters = getLiveServiceParameters(serviceConfig);
340
+ if (JSON.stringify({ ...liveParameters, releaseTime: undefined }) === JSON.stringify({ ...config.parameters, releaseTime: undefined })) {
341
+ // The parameters aren't changing, so there is nothing to release (machineIds / info changes apply immediately, only parameters wait for the release time)
342
+ config.parameters.releaseTime = undefined;
343
+ config.oldParameters = undefined;
344
+ } else {
345
+ config.oldParameters = liveParameters;
346
+ }
347
+ } else {
348
+ config.oldParameters = undefined;
349
+ }
302
350
 
303
351
  await serviceConfigs.set(serviceId, config);
304
352
  // Only notify we were or are deployed. If it's not deployed, this will be ignored anyways.
305
353
  if (config.parameters.deploy || serviceConfig.parameters.deploy) {
306
- for (let machineId of config.machineIds) {
354
+ for (let machineId of getConfigMachineIds(config)) {
307
355
  newMachines.add(machineId);
308
356
  }
309
- for (let machineId of serviceConfig.machineIds) {
357
+ for (let machineId of getConfigMachineIds(serviceConfig)) {
310
358
  oldMachines.add(machineId);
311
359
  }
312
360
  }
313
- }
314
- await this.notifyMachines([...newMachines], [...oldMachines]);
361
+ }));
362
+ void this.notifyMachines([...newMachines], [...oldMachines]);
315
363
  }
316
364
 
317
365
  public async deleteServiceConfig(serviceId: string) {
@@ -323,62 +371,22 @@ export class MachineServiceControllerBase {
323
371
 
324
372
  // Remove the service config
325
373
  await serviceConfigs.delete(serviceId);
326
- await pendingServiceConfigs.delete(serviceId);
327
- await serviceSwitchovers.delete(serviceId);
328
- await this.notifyMachines([], serviceConfig.machineIds);
374
+ void this.notifyMachines([], getConfigMachineIds(normalizeServiceConfig(serviceConfig)));
329
375
  }
330
376
 
331
- /** Saves edits WITHOUT deploying them. They only take effect once a switchover is scheduled (scheduleSwitchover). */
332
- public async setPendingServiceConfigs(configs: ServiceConfig[]) {
333
- for (let config of configs) {
334
- let serviceId = config.serviceId;
335
- config.info.lastUpdatedTime = Date.now();
336
- let serviceConfig = await serviceConfigs.get(serviceId);
337
- if (!serviceConfig) {
338
- throw new Error(`Service ${serviceId} does not exist`);
339
- }
340
- await pendingServiceConfigs.set(serviceId, config);
377
+ /** Cancels parameters that are scheduled to deploy (releaseTime in the future), reverting to what is currently live. Returns the cancelled config, so the caller can keep the edits (ex, back in the editor). */
378
+ public async cancelScheduledDeploy(serviceId: string): Promise<ServiceConfig> {
379
+ let record = await serviceConfigs.get(serviceId);
380
+ if (!record) {
381
+ throw new Error(`Service ${serviceId} does not exist`);
341
382
  }
342
- }
343
- public async getPendingServiceConfig(serviceId: string): Promise<ServiceConfig | undefined> {
344
- return await pendingServiceConfigs.get(serviceId);
345
- }
346
- public async discardPendingServiceConfig(serviceId: string) {
347
- await pendingServiceConfigs.delete(serviceId);
348
- await serviceSwitchovers.delete(serviceId);
349
- }
350
-
351
- /** Deploys the pending config: the new instances start at switchTime - overlapTime, the old instances are told they will shut down at switchTime, and at switchTime the pending config becomes live. */
352
- public async scheduleSwitchover(config: {
353
- serviceId: string;
354
- switchTime: number;
355
- overlapTime: number;
356
- }) {
357
- let pending = await pendingServiceConfigs.get(config.serviceId);
358
- if (!pending) {
359
- throw new Error(`No pending config for service ${config.serviceId}. Save changes first, then deploy them.`);
360
- }
361
- let live = await serviceConfigs.get(config.serviceId);
362
- if (!live) {
363
- throw new Error(`Service ${config.serviceId} does not exist`);
364
- }
365
- await serviceSwitchovers.set(config.serviceId, {
366
- serviceId: config.serviceId,
367
- switchTime: config.switchTime,
368
- overlapTime: config.overlapTime,
369
- scheduledAt: Date.now(),
370
- });
371
- await this.notifyMachines([...new Set([...live.machineIds, ...pending.machineIds])], []);
372
- }
373
- public async cancelSwitchover(serviceId: string) {
374
- let live = await serviceConfigs.get(serviceId);
375
- await serviceSwitchovers.delete(serviceId);
376
- if (live) {
377
- await this.notifyMachines(live.machineIds, []);
383
+ normalizeServiceConfig(record);
384
+ if (!record.parameters.releaseTime || Date.now() >= record.parameters.releaseTime || !record.oldParameters) {
385
+ throw new Error(`Service ${serviceId} has no scheduled deploy to cancel (it may have already gone live)`);
378
386
  }
379
- }
380
- public async getServiceSwitchover(serviceId: string): Promise<ServiceSwitchover | undefined> {
381
- return await serviceSwitchovers.get(serviceId);
387
+ await serviceConfigs.set(serviceId, { ...record, parameters: record.oldParameters, oldParameters: undefined });
388
+ void this.notifyMachines(getConfigMachineIds(record), []);
389
+ return stripReleaseState(record);
382
390
  }
383
391
  public async getServiceConfigType(): Promise<string> {
384
392
  return extractType(module, "ServiceConfig");
@@ -552,10 +560,14 @@ export async function getEffectiveServiceConfigs(): Promise<ServiceConfig[]> {
552
560
  }
553
561
  }
554
562
 
555
- return configs.map(config => ({
556
- ...config,
557
- machineIds: config.machineIds.filter(machineId => !disabledMachineIds.has(machineId)),
558
- }));
563
+ return configs.map(config => {
564
+ normalizeServiceConfig(config);
565
+ config.parameters.machineIds = (config.parameters.machineIds || []).filter(machineId => !disabledMachineIds.has(machineId));
566
+ if (config.oldParameters) {
567
+ config.oldParameters.machineIds = (config.oldParameters.machineIds || []).filter(machineId => !disabledMachineIds.has(machineId));
568
+ }
569
+ return config;
570
+ });
559
571
  }
560
572
 
561
573
  let deployWatchers = new Set<DeployProgress>();
@@ -607,12 +619,7 @@ export const MachineServiceController = getSyncedController(
607
619
  setMachineConfig: {},
608
620
  addServiceConfig: {},
609
621
  setServiceConfigs: {},
610
- setPendingServiceConfigs: {},
611
- getPendingServiceConfig: {},
612
- discardPendingServiceConfig: {},
613
- scheduleSwitchover: {},
614
- cancelSwitchover: {},
615
- getServiceSwitchover: {},
622
+ cancelScheduledDeploy: {},
616
623
  getServiceConfigType: {},
617
624
  deleteServiceConfig: {},
618
625
  getGitInfo: {},
@@ -637,11 +644,8 @@ export const MachineServiceController = getSyncedController(
637
644
  setMachineConfig: ["MachineConfig", "MachineConfigList"],
638
645
  addServiceConfig: ["ServiceConfig", "ServiceConfigList"],
639
646
  setServiceConfigs: ["ServiceConfig"],
640
- setPendingServiceConfigs: ["PendingServiceConfig"],
641
- discardPendingServiceConfig: ["PendingServiceConfig", "ServiceSwitchover"],
642
- scheduleSwitchover: ["ServiceSwitchover"],
643
- cancelSwitchover: ["ServiceSwitchover"],
644
- deleteServiceConfig: ["ServiceConfig", "ServiceConfigList", "PendingServiceConfig", "ServiceSwitchover"],
647
+ cancelScheduledDeploy: ["ServiceConfig"],
648
+ deleteServiceConfig: ["ServiceConfig", "ServiceConfigList"],
645
649
  commitPushService: ["gitInfo", "ServiceConfig"],
646
650
  commitPushAndPublishQuerysub: ["gitInfo", "ServiceConfig"],
647
651
  deployFunctions: ["gitInfo"],
@@ -653,8 +657,6 @@ export const MachineServiceController = getSyncedController(
653
657
  getMachineConfig: ["MachineConfig"],
654
658
  getServiceList: ["ServiceConfigList"],
655
659
  getServiceConfig: ["ServiceConfig"],
656
- getPendingServiceConfig: ["PendingServiceConfig"],
657
- getServiceSwitchover: ["ServiceSwitchover"],
658
660
  getGitInfo: ["gitInfo"],
659
661
  getPendingFunctions: ["gitInfo"],
660
662
  getLiveFunctions: ["gitInfo"],
@@ -141,7 +141,7 @@ export class LogDatumRenderer extends qreact.Component<{
141
141
  <div>{formatDateTime(datum.time)}</div>
142
142
  </div>
143
143
  {datum.__machineId && <MachineThreadInfo machineId={datum.__machineId} threadId={datum.__threadId} />}
144
- <span className={css.ellipsis.flexFillWidth.colorhsl(0, 50, 50).boldStyle}>{mainMessage}</span>
144
+ <span className={css.ellipsis.flexFillWidth.colorhsl(0, 50, 50).boldStyle} title={mainMessage}>{mainMessage}</span>
145
145
  </div>
146
146
 
147
147
  {!this.props.inlineMode && this.state.expanded && (
@@ -34,11 +34,12 @@ let nextSeqNum = Math.floor(Date.now() / 1000);
34
34
  export class TypedConfigEditor extends qreact.Component<TypedConfigEditorProps> {
35
35
  state = t.state({
36
36
  editorReady: t.type(false),
37
- currentValue: t.type<any>(undefined)
38
37
  });
39
38
 
40
39
  private editor: any = null;
41
40
  private model: any = null;
41
+ // The last value either typed into the editor or pushed into it (as 4-space JSON). A plain field (NOT state), so comparing/updating it can never trigger re-renders.
42
+ private lastKnownValueStr = "";
42
43
  private uniqueId = nextSeqNum++;
43
44
 
44
45
  private typeName = `Type${this.uniqueId}`;
@@ -83,10 +84,9 @@ export class TypedConfigEditor extends qreact.Component<TypedConfigEditorProps>
83
84
  );
84
85
  }
85
86
 
86
- private updateEditorValue(value: any): void {
87
+ private updateEditorContent(valueStr: string): void {
87
88
  if (!this.model) return;
88
89
 
89
- const valueStr = JSON.stringify(value, null, 4);
90
90
  const content = `const ${this.valueName}: ${this.typeName} = ${valueStr}`;
91
91
 
92
92
  // Only update if content has changed to avoid cursor jumping
@@ -96,15 +96,7 @@ export class TypedConfigEditor extends qreact.Component<TypedConfigEditorProps>
96
96
  }
97
97
 
98
98
  componentDidMount(): void {
99
- this.state.currentValue = this.props.value;
100
- }
101
-
102
- componentDidUpdate(prevProps: TypedConfigEditorProps): void {
103
- // Update editor if value changed from props
104
- if (this.props.value !== prevProps.value && this.props.value !== this.state.currentValue) {
105
- this.state.currentValue = this.props.value;
106
- this.updateEditorValue(this.props.value);
107
- }
99
+ this.lastKnownValueStr = JSON.stringify(this.props.value ?? null, null, 4);
108
100
  }
109
101
 
110
102
  componentWillUnmount(): void {
@@ -121,6 +113,15 @@ export class TypedConfigEditor extends qreact.Component<TypedConfigEditorProps>
121
113
  let valueName = this.valueName;
122
114
  let typeDefinition = `type ${typeName} = ${this.props.typeDefinition}`;
123
115
 
116
+ // qreact has no componentDidUpdate, so external value changes (ex, the parent discarding edits) are detected here and pushed into the editor. Changes the user typed round-trip through onValueChange into lastKnownValueStr, so they compare equal and never clobber the editor.
117
+ let propsValueStr = JSON.stringify(this.props.value ?? null, null, 4);
118
+ if (this.state.editorReady && this.lastKnownValueStr !== propsValueStr) {
119
+ this.lastKnownValueStr = propsValueStr;
120
+ Querysub.onCommitFinished(() => {
121
+ this.updateEditorContent(propsValueStr);
122
+ });
123
+ }
124
+
124
125
  return <div
125
126
  key={"Editor-" + this.uniqueId}
126
127
  ref2={async element => {
@@ -149,7 +150,7 @@ export class TypedConfigEditor extends qreact.Component<TypedConfigEditorProps>
149
150
  this.model = window.monaco.editor.getModel(configUri);
150
151
  if (this.model) {
151
152
  // Update existing model content
152
- this.updateEditorValue(propsValue);
153
+ this.updateEditorContent(valueStr);
153
154
  } else {
154
155
  // Create new model
155
156
  this.model = window.monaco.editor.createModel(
@@ -176,9 +177,7 @@ export class TypedConfigEditor extends qreact.Component<TypedConfigEditorProps>
176
177
  try {
177
178
  // Try to parse the JSON
178
179
  const parsedValue = JSON.parse(configMatch[1]);
179
- Querysub.localCommit(() => {
180
- this.state.currentValue = parsedValue;
181
- });
180
+ this.lastKnownValueStr = JSON.stringify(parsedValue, null, 4);
182
181
 
183
182
  Querysub.commit(() => {
184
183
  this.props.onValueChange?.(parsedValue);
@@ -189,7 +188,7 @@ export class TypedConfigEditor extends qreact.Component<TypedConfigEditorProps>
189
188
  }
190
189
  });
191
190
 
192
- Querysub.localCommit(() => {
191
+ Querysub.commit(() => {
193
192
  this.state.editorReady = true;
194
193
  });
195
194
  });
package/src/misc.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { canHaveChildren } from "socket-function/src/types";
2
2
  import { delay } from "socket-function/src/batching";
3
+ import { timeInDay, timeInSecond } from "socket-function/src/misc";
3
4
 
4
5
  // TIMING: About 20MB/s
5
6
  export function createRandomText(count: number): string {
@@ -197,6 +198,35 @@ export function maybeUndefined<T>(value: T): T | undefined {
197
198
  return value;
198
199
  }
199
200
 
201
+ const PRECISE_TIMEOUT_MAX_SLEEP = timeInDay;
202
+ const PRECISE_TIMEOUT_MIN_SLEEP = timeInSecond;
203
+ /** Fires the callback at the given absolute time. A single setTimeout drifts over long waits (and overflows past ~24.8 days), so this re-arms, sleeping 90% of the remaining time each wake (re-reading the clock), and so hits the target within about a second even over multi-day waits. Returns a cancel function. */
204
+ export function setPreciseTimeout(config: {
205
+ time: number;
206
+ callback: () => void;
207
+ }): () => void {
208
+ let timer: ReturnType<typeof setTimeout> | undefined;
209
+ let cancelled = false;
210
+ const arm = () => {
211
+ if (cancelled) return;
212
+ let remaining = config.time - Date.now();
213
+ if (remaining <= 0) {
214
+ timer = undefined;
215
+ config.callback();
216
+ return;
217
+ }
218
+ timer = setTimeout(arm, Math.min(Math.max(remaining * 0.9, PRECISE_TIMEOUT_MIN_SLEEP), PRECISE_TIMEOUT_MAX_SLEEP));
219
+ };
220
+ arm();
221
+ return () => {
222
+ cancelled = true;
223
+ if (timer) {
224
+ clearTimeout(timer);
225
+ timer = undefined;
226
+ }
227
+ };
228
+ }
229
+
200
230
  /** Runs `callback` for every item, but staggers the calls evenly across `totalTime` instead of
201
231
  * firing them all at once, and awaits all of them before returning. Used inside a poll loop
202
232
  * (with totalTime = the poll interval) so fanning out to many nodes doesn't spike — otherwise