hammerkit 2.0.0-next.5 → 2.0.0-next.7

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.
Files changed (38) hide show
  1. package/dist/cache/explain.js +7 -5
  2. package/dist/dashboard/assets/graph-canvas-DLioOiRN.css +1 -0
  3. package/dist/dashboard/assets/graph-canvas-HmkW_-XJ.js +8 -0
  4. package/dist/dashboard/assets/index-gIBDC_m8.css +2 -0
  5. package/dist/dashboard/assets/index-vE22S5E_.js +17 -0
  6. package/dist/dashboard/index.html +2 -2
  7. package/dist/executer/docker-task.js +3 -0
  8. package/dist/executer/execute-work-task.js +3 -1
  9. package/dist/executer/local-task.js +3 -0
  10. package/dist/executer/scheduler/enqueue-next.js +6 -1
  11. package/dist/executer/scheduler/state-key.js +71 -34
  12. package/dist/executer/watch-loop.js +18 -5
  13. package/dist/file/get-file-context.js +5 -1
  14. package/dist/kubernetes/claim-layout.js +10 -1
  15. package/dist/kubernetes/ensure-persistent-data.js +84 -10
  16. package/dist/kubernetes/follow-pod-logs.js +124 -27
  17. package/dist/kubernetes/network-policy.js +4 -4
  18. package/dist/kubernetes/restore-kubernetes-data.js +21 -3
  19. package/dist/kubernetes/store-kubernetes-data.js +13 -2
  20. package/dist/kubernetes/volumes.js +34 -7
  21. package/dist/optimizer/calculate-checksum.js +46 -3
  22. package/dist/optimizer/get-work-cache-stats.js +36 -24
  23. package/dist/planner/work-item-executions.js +27 -0
  24. package/dist/planner/work-runtime-kubernetes.js +33 -38
  25. package/dist/server/capacity.js +63 -0
  26. package/dist/server/dashboard.js +2 -2
  27. package/dist/server/executor.js +10 -0
  28. package/dist/server/overview.js +42 -9
  29. package/dist/server/plan.js +16 -3
  30. package/dist/server/policy.js +9 -3
  31. package/dist/server/run-worker.js +37 -4
  32. package/dist/server/runs.js +49 -4
  33. package/dist/server/server-config.js +4 -1
  34. package/dist/server/server.js +10 -7
  35. package/dist/utils/limit.js +30 -0
  36. package/package.json +1 -1
  37. package/dist/dashboard/assets/index-D51NA6-h.js +0 -14
  38. package/dist/dashboard/assets/index-mmvC6u9O.css +0 -2
@@ -6,6 +6,11 @@ const readline_1 = require("readline");
6
6
  const log_1 = require("../log");
7
7
  // what is read of an instance of a container that already ended
8
8
  const MAX_ENDED_LOG_BYTES = 1024 * 1024;
9
+ // how often in a row the log of one instance is read again after its reading broke off at once
10
+ const MAX_READS = 20;
11
+ // `timestamps: true` puts the time a line was written before it (RFC3339 with up to nine
12
+ // digits of a second), which says where a log that broke off got to
13
+ const TIMESTAMPED = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z(?: (.*))?$/;
9
14
  /**
10
15
  * Follows the log of a container in the pods of a selector, as `docker attach` does for a
11
16
  * service on Docker: the instance that runs as it writes, and an instance that ended before
@@ -14,7 +19,12 @@ const MAX_ENDED_LOG_BYTES = 1024 * 1024;
14
19
  */
15
20
  function followPodLogs(instance, namespace, labelSelector, container, onLine, options = {}) {
16
21
  const seen = new Set();
17
- const following = [];
22
+ const positions = new Map();
23
+ // the logs followed now, resolved when they end
24
+ const streams = new Map();
25
+ const following = new Map();
26
+ // the instances that ended, by the last look
27
+ const ended = new Set();
18
28
  const reading = new Set();
19
29
  let stopped = false;
20
30
  let failed = false;
@@ -34,28 +44,91 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
34
44
  const tracked = promise.catch(fail).finally(() => reading.delete(tracked));
35
45
  reading.add(tracked);
36
46
  };
37
- const readEnded = async (pod, previous) => {
47
+ const positionOf = (id) => {
48
+ let position = positions.get(id);
49
+ if (!position) {
50
+ position = { at: null, since: null, count: 0, reads: 0 };
51
+ positions.set(id, position);
52
+ }
53
+ return position;
54
+ };
55
+ // a reader of the log of one instance from where it got to: the lines read before, again
56
+ // at the start of a log read since their second, are passed over
57
+ const reader = (position) => {
58
+ const from = position.at;
59
+ const skip = position.count;
60
+ let atFrom = 0;
61
+ return (raw) => {
62
+ const match = TIMESTAMPED.exec(raw);
63
+ if (!match) {
64
+ onLine(raw);
65
+ return;
66
+ }
67
+ const at = `${match[1]}.${(match[2] ?? '').padEnd(9, '0')}`;
68
+ if (from !== null && at <= from) {
69
+ if (at < from || ++atFrom <= skip) {
70
+ return;
71
+ }
72
+ }
73
+ if (at === position.at) {
74
+ position.count++;
75
+ }
76
+ else {
77
+ position.at = at;
78
+ position.since = `${match[1]}Z`;
79
+ position.count = 1;
80
+ }
81
+ onLine(match[3] ?? '');
82
+ };
83
+ };
84
+ const readEnded = async (pod, id, previous) => {
85
+ const position = positionOf(id);
86
+ position.reads++;
38
87
  const log = await instance.coreApi.readNamespacedPodLog({
39
88
  name: pod,
40
89
  namespace,
41
90
  container,
42
91
  previous,
92
+ timestamps: true,
43
93
  limitBytes: MAX_ENDED_LOG_BYTES,
94
+ // this call takes no sinceTime: a minute more than since then, for clocks that differ
95
+ ...(position.since
96
+ ? { sinceSeconds: Math.max(1, Math.ceil((Date.now() - Date.parse(position.since)) / 1000) + 60) }
97
+ : {}),
44
98
  });
99
+ const read = reader(position);
45
100
  for (const line of String(log ?? '').split(/\r?\n/)) {
46
101
  if (line.length > 0) {
47
- onLine(line);
102
+ read(line);
48
103
  }
49
104
  }
50
105
  };
51
- const follow = async (pod) => {
106
+ const follow = async (pod, id) => {
107
+ const position = positionOf(id);
108
+ position.reads++;
52
109
  const output = new stream_1.PassThrough();
53
- let ended = false;
54
- // the instance ended or the request broke: a restarted one is looked for at once
110
+ const started = Date.now();
111
+ let over = false;
112
+ let done = () => undefined;
113
+ const stream = new Promise((resolve) => (done = resolve));
114
+ streams.set(id, stream);
115
+ // the instance ended or the request broke: it is looked at again at once, a restarted
116
+ // one followed, and what this one wrote after the break read from where it got to
55
117
  const end = () => {
56
- if (!ended) {
57
- ended = true;
118
+ if (!over) {
119
+ over = true;
58
120
  streaming--;
121
+ // a follow that lasted is cut now and then (a quiet stretch, a proxy): only reads
122
+ // that break off at once count against the limit
123
+ if (Date.now() - started >= 10000) {
124
+ position.reads = 0;
125
+ }
126
+ streams.delete(id);
127
+ following.delete(id);
128
+ done();
129
+ if (!stopped && position.reads < MAX_READS) {
130
+ seen.delete(id);
131
+ }
59
132
  wake();
60
133
  }
61
134
  };
@@ -64,11 +137,15 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
64
137
  end();
65
138
  }));
66
139
  output.on('end', end);
67
- (0, readline_1.createInterface)({ input: output }).on('line', (line) => onLine(line));
140
+ (0, readline_1.createInterface)({ input: output }).on('line', reader(position));
68
141
  streaming++;
69
142
  let controller;
70
143
  try {
71
- controller = await instance.log.log(namespace, pod, container, output, { follow: true });
144
+ controller = await instance.log.log(namespace, pod, container, output, {
145
+ follow: true,
146
+ timestamps: true,
147
+ ...(position.since ? { sinceTime: position.since } : {}),
148
+ });
72
149
  }
73
150
  catch (e) {
74
151
  end();
@@ -78,7 +155,7 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
78
155
  controller.abort();
79
156
  }
80
157
  else {
81
- following.push(controller);
158
+ following.set(id, controller);
82
159
  }
83
160
  };
84
161
  const look = (pod) => {
@@ -91,22 +168,28 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
91
168
  // an instance that ended before this one, not seen while it ran: its lines go first
92
169
  let before = Promise.resolve();
93
170
  const last = status.lastState?.terminated?.containerID;
171
+ const current = status.containerID;
172
+ if (last) {
173
+ ended.add(last);
174
+ }
175
+ if (current && status.state?.terminated) {
176
+ ended.add(current);
177
+ }
94
178
  if (last && !seen.has(last)) {
95
179
  seen.add(last);
96
- before = readEnded(name, true).catch(fail);
180
+ before = readEnded(name, last, true).catch(fail);
97
181
  track(before);
98
182
  }
99
- const current = status.containerID;
100
183
  if (!current || seen.has(current)) {
101
184
  return;
102
185
  }
103
186
  if (status.state?.running) {
104
187
  seen.add(current);
105
- track(before.then(() => follow(name)));
188
+ track(before.then(() => follow(name, current)));
106
189
  }
107
190
  else if (status.state?.terminated) {
108
191
  seen.add(current);
109
- track(before.then(() => readEnded(name, false)));
192
+ track(before.then(() => readEnded(name, current, false)));
110
193
  }
111
194
  };
112
195
  const poll = async () => {
@@ -157,26 +240,40 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
157
240
  async settle() {
158
241
  await polling;
159
242
  await poll();
160
- // what follows a running instance never ends by itself
161
- const ended = [...reading];
162
- let wait;
163
- await Promise.race([
164
- Promise.all(ended),
165
- new Promise((resolve) => {
166
- wait = setTimeout(resolve, 5000);
167
- wait.unref?.();
168
- }),
169
- ]);
170
- clearTimeout(wait);
243
+ // what follows an instance that ended ends by itself once it has read all there is
244
+ const endedStreams = () => [...streams].filter(([id]) => ended.has(id));
245
+ await within(Promise.all([...reading, ...endedStreams().map(([, stream]) => stream)]), 5000);
246
+ // one still going after that is cut, and read from where it got to
247
+ const cut = endedStreams();
248
+ if (cut.length > 0) {
249
+ for (const [id] of cut) {
250
+ following.get(id)?.abort();
251
+ }
252
+ await within(Promise.all(cut.map(([, stream]) => stream)), 1000);
253
+ await poll();
254
+ await within(Promise.all([...reading]), 5000);
255
+ }
171
256
  },
172
257
  stop() {
173
258
  stopped = true;
174
259
  clearTimeout(timer);
175
- for (const controller of following.splice(0)) {
260
+ for (const controller of following.values()) {
176
261
  controller.abort();
177
262
  }
263
+ following.clear();
178
264
  },
179
265
  };
180
266
  }
181
267
  exports.followPodLogs = followPodLogs;
268
+ async function within(promise, ms) {
269
+ let wait;
270
+ await Promise.race([
271
+ promise,
272
+ new Promise((resolve) => {
273
+ wait = setTimeout(resolve, ms);
274
+ wait.unref?.();
275
+ }),
276
+ ]);
277
+ clearTimeout(wait);
278
+ }
182
279
  //# sourceMappingURL=follow-pod-logs.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.removeSandboxProxies = exports.getProxyReadinessScript = exports.removeAllKubernetesItemNetworks = exports.releaseKubernetesItemNetwork = exports.awaitPodsEnded = exports.removeKubernetesItemNetwork = exports.ensureKubernetesItemNetwork = exports.buildProxyPolicy = exports.buildNetworkPolicy = exports.getNetworkPolicyName = exports.getSandboxProxy = exports.getProxyName = exports.getNetworkLabels = exports.NETWORK_PROXY = exports.NETWORK_LIMITED = exports.NETWORK_LABEL = exports.RUN_LABEL = void 0;
3
+ exports.removeSandboxProxies = exports.getProxyReadinessScript = exports.removeAllKubernetesItemNetworks = exports.releaseKubernetesItemNetwork = exports.awaitPodsEnded = exports.removeKubernetesItemNetwork = exports.ensureKubernetesItemNetwork = exports.buildProxyPolicy = exports.buildNetworkPolicy = exports.getNetworkPolicyName = exports.getSandboxProxy = exports.getProxyName = exports.getNetworkLabels = exports.NETWORK_PROXY = exports.NETWORK_LIMITED = exports.NETWORK_LABEL = exports.PROXY_OF_SANDBOX_LABEL = exports.RUN_LABEL = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const stream_1 = require("stream");
6
6
  const readline_1 = require("readline");
@@ -21,7 +21,7 @@ const resources_1 = require("./resources");
21
21
  const PROXY_LABEL = 'hammerkit.dev/proxy-for';
22
22
  const NETWORK_OF_LABEL = 'hammerkit.dev/network-of';
23
23
  // on the proxy of a task on a sandbox: the sandbox, whose pod of files it stays with
24
- const PROXY_OF_SANDBOX_LABEL = 'hammerkit.dev/proxy-of-sandbox';
24
+ exports.PROXY_OF_SANDBOX_LABEL = 'hammerkit.dev/proxy-of-sandbox';
25
25
  const PROXY_READY_TIMEOUT_MS = 60000;
26
26
  // the token of the endpoints of secret providers, in a Secret named like the proxy
27
27
  const SECRET_TOKEN_KEY = 'token';
@@ -77,7 +77,7 @@ exports.getSandboxProxy = getSandboxProxy;
77
77
  function getProxyLabels(env, item, proxy) {
78
78
  return {
79
79
  [NETWORK_OF_LABEL]: item.id(),
80
- ...(proxy.sandbox ? { [PROXY_OF_SANDBOX_LABEL]: proxy.sandbox.label } : (0, job_placement_1.getRunLabels)(env)),
80
+ ...(proxy.sandbox ? { [exports.PROXY_OF_SANDBOX_LABEL]: proxy.sandbox.label } : (0, job_placement_1.getRunLabels)(env)),
81
81
  };
82
82
  }
83
83
  function getNetworkPolicyName(item, scope) {
@@ -543,7 +543,7 @@ async function removeSandboxProxies(instance, namespace, of, keep = () => false)
543
543
  const deployments = await instance.appsApi.listNamespacedDeployment({
544
544
  namespace,
545
545
  labelSelector: [
546
- `${PROXY_OF_SANDBOX_LABEL}=${of.sandbox}`,
546
+ `${exports.PROXY_OF_SANDBOX_LABEL}=${of.sandbox}`,
547
547
  ...(of.item ? [`${NETWORK_OF_LABEL}=${of.item.id()}`] : []),
548
548
  ].join(','),
549
549
  });
@@ -1,11 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.restoreKubernetesData = exports.getRestoreTarget = void 0;
3
+ exports.restoreKubernetesData = exports.getRestoreTarget = exports.RESTORE_FILE = void 0;
4
4
  const event_cache_1 = require("../executer/event-cache");
5
5
  const log_1 = require("../log");
6
6
  const volumes_1 = require("./volumes");
7
7
  const ensure_persistent_data_1 = require("./ensure-persistent-data");
8
8
  const claim_layout_1 = require("./claim-layout");
9
+ // Unpacks the archive of an output that is a file to `$1`: by its content, since the name in
10
+ // the archive is the file's and the one in the claim may carry a hash (see getClaimSubPath)
11
+ exports.RESTORE_FILE = 'mkdir -p "$(dirname "$1")" && rm -rf "$1" && tar xzf - -O > "$1"';
9
12
  // Where the upload pod (which mounts the work's claim at /dev/hammerkit/<id>) unpacks the
10
13
  // archive of an output: the output's place in the claim, which its mount reads
11
14
  function getRestoreTarget(work, path,
@@ -22,7 +25,16 @@ async function restoreKubernetesData(work, kubernetes, instance, environment, pa
22
25
  options = {}) {
23
26
  const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes, options.stateKey);
24
27
  const stateDir = kubernetes.server?.serviceRun ? (0, volumes_1.getItemFilesDir)(work, options.stateKey) : undefined;
25
- await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'write', async (name) => {
28
+ // an output that is a file: its archive holds it alone (see storeKubernetesData), and it
29
+ // takes the place of whatever is at its path in the claim
30
+ const files = new Set(work.data.type === 'container-task'
31
+ ? work.data.generates
32
+ .filter((generate) => generate.isFile && !generate.inherited)
33
+ .map((generate) => generate.path)
34
+ : []);
35
+ // a run stopped while the pod waits for room under the quota
36
+ const { signal } = environment.abortCtrl;
37
+ await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'write', signal, async (name) => {
26
38
  if (options.reset) {
27
39
  await (0, ensure_persistent_data_1.clearClaim)(instance, kubernetes, work, name);
28
40
  }
@@ -37,7 +49,13 @@ options = {}) {
37
49
  // a claim made for this run (a server removes the claims of a run when it
38
50
  // ends) does not have the directory of the output yet
39
51
  let failure = null;
40
- const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', ['sh', '-c', 'mkdir -p "$1" && tar xzf - -C "$1"', 'sh', targetPath], process.stdout, process.stderr, environment.file.readStream(generatedArchive.filename), false, (status) => {
52
+ const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', [
53
+ 'sh',
54
+ '-c',
55
+ files.has(generatedArchive.path) ? exports.RESTORE_FILE : 'mkdir -p "$1" && tar xzf - -C "$1"',
56
+ 'sh',
57
+ targetPath,
58
+ ], process.stdout, process.stderr, environment.file.readStream(generatedArchive.filename), false, (status) => {
41
59
  work.status.write('debug', `upload ${generatedArchive.filename} to ${name}: ${status.status}`);
42
60
  if (status.status !== 'Success') {
43
61
  failure = status.message ?? status.reason ?? 'unknown';
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.storeKubernetesData = void 0;
4
+ const path_1 = require("path");
4
5
  const log_1 = require("../log");
5
6
  const event_cache_1 = require("../executer/event-cache");
6
7
  const volumes_1 = require("./volumes");
@@ -10,10 +11,20 @@ async function storeKubernetesData(work, kubernetes, instance, environment, path
10
11
  // getStateFilesDir)
11
12
  stateKey) {
12
13
  const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes, stateKey);
13
- await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'read', async (name) => {
14
+ // a run stopped while the pod waits for room under the quota
15
+ const { signal } = environment.abortCtrl;
16
+ // an output that is a file is mounted as one: its archive holds it by its name, as on Docker
17
+ const files = new Set(work.data.type === 'container-task'
18
+ ? work.data.generates
19
+ .filter((generate) => generate.isFile && !generate.inherited)
20
+ .map((generate) => generate.path)
21
+ : []);
22
+ await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'read', signal, async (name) => {
14
23
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(work.data, path)) {
15
24
  const writerStream = environment.file.createWriteStream(generatedArchive.filename);
16
- const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', ['tar', 'zcf', '-', '-C', generatedArchive.path, '.'], writerStream, process.stderr, null, false, (status) => {
25
+ const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', files.has(generatedArchive.path)
26
+ ? ['tar', 'zcf', '-', '-C', path_1.posix.dirname(generatedArchive.path), path_1.posix.basename(generatedArchive.path)]
27
+ : ['tar', 'zcf', '-', '-C', generatedArchive.path, '.'], writerStream, process.stderr, null, false, (status) => {
17
28
  work.status.write('debug', `archive ${generatedArchive.path} of ${name}: ${status}`);
18
29
  writerStream.close();
19
30
  });
@@ -59,6 +59,7 @@ stateKey) {
59
59
  sources: [],
60
60
  mounts: [],
61
61
  initCommands: [],
62
+ files: [],
62
63
  };
63
64
  const workspace = kubernetes.server?.workspace;
64
65
  for (const mount of work.data.mounts) {
@@ -68,20 +69,23 @@ stateKey) {
68
69
  }
69
70
  appendVolume(work, persistence, mount.containerPath, mount.localPath, mount.isFile, true, kubernetes, mounted, own);
70
71
  }
72
+ // its deps' src too, uploaded into its own claim: a container task sees them as it does on
73
+ // docker (a tsc -b of a project that references another reads the other's tsconfig)
71
74
  for (const src of work.data.src) {
72
- if (src.inherited) {
73
- continue;
74
- }
75
75
  if (workspace &&
76
76
  appendWorkspaceMount(workspace, persistence, src.absolutePath, src.absolutePath, src.isFile, false)) {
77
77
  continue;
78
78
  }
79
- appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes, mounted, own); // TODO matcher
79
+ appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes, mounted, own, src.inherited ? (0, claim_layout_1.getInheritedSourceSubPath)(work, src.absolutePath) : undefined); // TODO matcher
80
80
  }
81
81
  if (work.data.type === 'container-task') {
82
82
  for (const generate of work.data.generates) {
83
- appendVolume(generate.inherited ?? work, persistence, generate.path, null, false, !!generate.inherited, kubernetes, dirOf(generate.inherited ?? work));
83
+ const subPath = appendVolume(generate.inherited ?? work, persistence, generate.path, null, generate.isFile, !!generate.inherited, kubernetes, dirOf(generate.inherited ?? work));
84
+ if (generate.isFile && !generate.inherited) {
85
+ persistence.files.push(subPath);
86
+ }
84
87
  }
88
+ persistence.files.push(...getNestedMountPlaces(persistence, getVolumeName(work, kubernetes)));
85
89
  }
86
90
  else {
87
91
  for (const volume of work.data.volumes) {
@@ -93,14 +97,36 @@ stateKey) {
93
97
  return persistence;
94
98
  }
95
99
  exports.getKubernetesPersistence = getKubernetesPersistence;
100
+ // The places in the claim `claim` of the paths mounted inside a folder that is mounted read
101
+ // only from it: an output of a task inside a folder of its src (pkg/coverage/lcov.info with a
102
+ // src pkg). The container runtime makes the place of a mount where it isn't there, which it
103
+ // can't in a folder mounted read only, and the pod fails to start (exit 128). Each is in the
104
+ // claim's folder of the read-only path, a folder ending in `/`, or a file.
105
+ function getNestedMountPlaces(persistence, claim) {
106
+ const own = persistence.mounts.filter((m) => !m.workspace);
107
+ const folders = own.filter((m) => m.mount.name === claim && m.mount.readOnly && m.cwd === m.mount.mountPath && m.mount.subPath);
108
+ const places = [];
109
+ for (const mount of own) {
110
+ const parent = folders
111
+ .filter((folder) => folder !== mount && mount.mount.mountPath.startsWith(`${folder.mount.mountPath}/`))
112
+ .sort((a, b) => b.mount.mountPath.length - a.mount.mountPath.length)[0];
113
+ if (!parent) {
114
+ continue;
115
+ }
116
+ const place = `${parent.mount.subPath}/${mount.mount.mountPath.slice(parent.mount.mountPath.length + 1)}`;
117
+ places.push(mount.cwd === mount.mount.mountPath ? `${place}/` : place);
118
+ }
119
+ return places;
120
+ }
96
121
  function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes,
97
122
  // the folder of the state of `sourceWork` the path is in, see getStateFilesDir
98
123
  stateDir,
99
124
  // the folder its upload goes to, when it is not the one the path is mounted from (a
100
125
  // service that watches its files, see syncLiveFiles)
101
- uploadDir = stateDir) {
126
+ uploadDir = stateDir,
127
+ // where it goes in the claim, when not where getClaimSubPath puts it
128
+ claimPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath)) {
102
129
  const volumeName = getVolumeName(sourceWork, kubernetes);
103
- const claimPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath);
104
130
  const subPath = (stateDir ? `${stateDir}/` : '') + claimPath;
105
131
  const existVolume = persistence.volumes.some((v) => v.name === volumeName);
106
132
  if (!existVolume) {
@@ -134,6 +160,7 @@ uploadDir = stateDir) {
134
160
  stateKey: '', // TODO
135
161
  });
136
162
  }
163
+ return subPath;
137
164
  }
138
165
  exports.appendVolume = appendVolume;
139
166
  /**
@@ -1,17 +1,60 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.calculateChecksum = void 0;
3
+ exports.calculateChecksum = exports.racyWindow = void 0;
4
4
  const crypto_1 = require("crypto");
5
+ // The checksums a process computed, by the file context and path: every task of a run hashes
6
+ // the sources of the tasks it depends on too, so most files are asked for many times
7
+ const known = new WeakMap();
8
+ // A file written again soon after it was hashed, with the same size, keeps its times on a
9
+ // file system whose timestamps are coarser than that: a checksum of a file changed within
10
+ // the window before it was hashed is not kept. Whole seconds are taken for a file system
11
+ // that has no more (FAT, ext3, HFS+), else the window is short.
12
+ function racyWindow(stats) {
13
+ return stats.lastModified % 1000 === 0 && stats.identity.changed % 1000 === 0 ? 2000 : 50;
14
+ }
15
+ exports.racyWindow = racyWindow;
5
16
  // Hashes the raw bytes: decoding to text first would map every invalid UTF-8
6
17
  // sequence to U+FFFD, so two different binary files could share a checksum.
7
18
  // For UTF-8 text the result is unchanged. sha256, which nobody can make two files share:
8
19
  // a cache hit is also what a verified check counts (check-key.ts).
9
- async function calculateChecksum(environment, path) {
20
+ //
21
+ // With the `stats` of the file, the checksum is reused while the file stays the same file:
22
+ // the same modification and change time, size and inode. A write changes its times, a
23
+ // replacement (a checkout, a rename over it) its inode.
24
+ async function calculateChecksum(environment, path, stats) {
25
+ const identity = stats?.identity;
26
+ if (!identity) {
27
+ return hashFile(environment, path);
28
+ }
29
+ let files = known.get(environment.file);
30
+ if (!files) {
31
+ files = new Map();
32
+ known.set(environment.file, files);
33
+ }
34
+ const cached = files.get(path);
35
+ if (cached &&
36
+ cached.lastModified === stats.lastModified &&
37
+ cached.size === identity.size &&
38
+ cached.changed === identity.changed &&
39
+ cached.inode === identity.inode) {
40
+ return cached.checksum;
41
+ }
42
+ const hashedAt = Date.now();
43
+ const checksum = await hashFile(environment, path);
44
+ if (hashedAt - Math.max(stats.lastModified, identity.changed) > racyWindow({ ...stats, identity })) {
45
+ files.set(path, { lastModified: stats.lastModified, ...identity, checksum });
46
+ }
47
+ else {
48
+ files.delete(path);
49
+ }
50
+ return checksum;
51
+ }
52
+ exports.calculateChecksum = calculateChecksum;
53
+ async function hashFile(environment, path) {
10
54
  const hash = (0, crypto_1.createHash)('sha256');
11
55
  for await (const chunk of environment.file.readStream(path)) {
12
56
  hash.update(chunk);
13
57
  }
14
58
  return hash.digest('hex');
15
59
  }
16
- exports.calculateChecksum = calculateChecksum;
17
60
  //# sourceMappingURL=calculate-checksum.js.map
@@ -5,55 +5,67 @@ const calculate_checksum_1 = require("./calculate-checksum");
5
5
  const path_1 = require("path");
6
6
  const crypto_1 = require("crypto");
7
7
  const promises_1 = require("fs/promises");
8
+ const limit_1 = require("../utils/limit");
8
9
  function isExcluded(path, excluded) {
9
10
  return excluded.some((excludedPath) => path === excludedPath || path.startsWith(excludedPath + path_1.sep));
10
11
  }
11
- async function addWorkCacheStats(result, cwd, path, matcher, context, root, excluded) {
12
+ // What the walk does at once: a stat, a listing or a checksum of a file each. Awaited one
13
+ // after the other, a walk of a thousand files spent most of its time waiting for the disk.
14
+ const WALK_CONCURRENCY = 32;
15
+ async function addWorkCacheStats(result, cwd, path, matcher, context, root, excluded, limit = (0, limit_1.createLimit)(WALK_CONCURRENCY)) {
12
16
  if (isExcluded(path, excluded)) {
13
17
  return 0;
14
18
  }
19
+ // the slot is held for what this path needs of the disk, not while its children are walked
20
+ const visited = await limit(() => visitPath(cwd, path, matcher, context, root));
21
+ if (visited.type === 'file') {
22
+ // '/'-separated on every OS, so a Windows host keys a container task like Linux
23
+ result.files[visited.key] = { lastModified: visited.lastModified, checksum: visited.checksum };
24
+ return 1;
25
+ }
26
+ if (visited.type === 'directory') {
27
+ const counts = await Promise.all(visited.files.map((file) => addWorkCacheStats(result, cwd, (0, path_1.join)(path, file), matcher, context, false, excluded, limit)));
28
+ return counts.reduce((sum, count) => sum + count, 0);
29
+ }
30
+ return 0;
31
+ }
32
+ async function visitPath(cwd, path, matcher, context, root) {
33
+ const key = (0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep);
15
34
  // On a server the files were written by a commit or a task: a link is a source as it is,
16
35
  // its target, and never followed. A `**` would walk `x -> /` through the server's disk.
17
36
  if (context.hosted) {
18
37
  const link = await (0, promises_1.lstat)(path).catch(() => undefined);
19
38
  if (link?.isSymbolicLink()) {
20
39
  if (!matcher(path)) {
21
- return 0;
40
+ return { type: 'none' };
22
41
  }
23
42
  const checksum = (0, crypto_1.createHash)('sha256')
24
43
  .update(`link:${await (0, promises_1.readlink)(path)}`)
25
44
  .digest('hex');
26
- result.files[(0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep)] = { lastModified: link.mtimeMs, checksum };
27
- return 1;
45
+ return { type: 'file', key, lastModified: link.mtimeMs, checksum };
28
46
  }
29
47
  }
30
48
  const exists = await context.file.exists(path);
31
49
  if (!exists) {
32
- return 0;
50
+ return { type: 'none' };
33
51
  }
34
52
  const stats = await context.file.stats(path);
35
53
  if (stats.type === 'file') {
36
54
  if (!matcher(path)) {
37
- return 0;
55
+ return { type: 'none' };
38
56
  }
39
- const checksum = await (0, calculate_checksum_1.calculateChecksum)(context, path);
40
- // '/'-separated on every OS, so a Windows host keys a container task like Linux
41
- result.files[(0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep)] = { lastModified: stats.lastModified, checksum };
42
- return 1;
57
+ const checksum = await (0, calculate_checksum_1.calculateChecksum)(context, path, stats);
58
+ return { type: 'file', key, lastModified: stats.lastModified, checksum };
43
59
  }
44
60
  else if (stats.type === 'directory') {
45
61
  // A glob such as src/**/*.ts never matches a directory itself, so a
46
62
  // directory is walked when files inside it could match.
47
63
  if (!root && !matcher(path, true)) {
48
- return 0;
49
- }
50
- let matched = 0;
51
- for (const file of await context.file.listFiles(path)) {
52
- matched += await addWorkCacheStats(result, cwd, (0, path_1.join)(path, file), matcher, context, false, excluded);
64
+ return { type: 'none' };
53
65
  }
54
- return matched;
66
+ return { type: 'directory', files: await context.file.listFiles(path) };
55
67
  }
56
- return 0;
68
+ return { type: 'none' };
57
69
  }
58
70
  async function getWorkCacheStats(work, environment) {
59
71
  return (await collectSourceStats(work, environment, [])).stats;
@@ -66,14 +78,14 @@ async function collectSourceStats(work, environment, excluded) {
66
78
  created: new Date(),
67
79
  files: {},
68
80
  };
69
- const unmatched = [];
70
- for (const src of work.src) {
81
+ // one limit for all its srcs, which are walked at once: an inherited src is often a large
82
+ // part of what a task hashes
83
+ const limit = (0, limit_1.createLimit)(WALK_CONCURRENCY);
84
+ const matches = await Promise.all(work.src.map((src) => {
71
85
  const matcher = (file, partial) => src.matcher(file, work.cwd, partial);
72
- const matched = await addWorkCacheStats(result, work.cwd, src.absolutePath, matcher, environment, true, excluded);
73
- if (matched === 0 && !src.inherited && !isExcluded(src.absolutePath, excluded)) {
74
- unmatched.push(src);
75
- }
76
- }
86
+ return addWorkCacheStats(result, work.cwd, src.absolutePath, matcher, environment, true, excluded, limit);
87
+ }));
88
+ const unmatched = work.src.filter((src, index) => matches[index] === 0 && !src.inherited && !isExcluded(src.absolutePath, excluded));
77
89
  return { stats: result, unmatched };
78
90
  }
79
91
  // The source stats of a task or service, leaving out everything its (transitive)
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readExecutions = exports.clearExecutions = exports.recordExecution = void 0;
4
+ // kept by the task, not its item: the runtime and the scheduler hold copies of the item
5
+ const executions = new WeakMap();
6
+ function recordExecution(task, startedAt, finishedAt) {
7
+ if (Number.isNaN(startedAt.getTime()) || Number.isNaN(finishedAt.getTime()) || finishedAt < startedAt) {
8
+ return;
9
+ }
10
+ let list = executions.get(task);
11
+ if (!list) {
12
+ list = [];
13
+ executions.set(task, list);
14
+ }
15
+ list.push({ startedAt, finishedAt });
16
+ }
17
+ exports.recordExecution = recordExecution;
18
+ // a task that runs again (watch) starts with none
19
+ function clearExecutions(task) {
20
+ executions.delete(task);
21
+ }
22
+ exports.clearExecutions = clearExecutions;
23
+ function readExecutions(task) {
24
+ return executions.get(task) ?? [];
25
+ }
26
+ exports.readExecutions = readExecutions;
27
+ //# sourceMappingURL=work-item-executions.js.map