hammerkit 2.0.0-next.5 → 2.0.0-next.6

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 +45 -5
  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 +3 -1
  19. package/dist/kubernetes/store-kubernetes-data.js +3 -1
  20. package/dist/kubernetes/volumes.js +6 -6
  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
  });
@@ -22,7 +22,9 @@ async function restoreKubernetesData(work, kubernetes, instance, environment, pa
22
22
  options = {}) {
23
23
  const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes, options.stateKey);
24
24
  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) => {
25
+ // a run stopped while the pod waits for room under the quota
26
+ const { signal } = environment.abortCtrl;
27
+ await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'write', signal, async (name) => {
26
28
  if (options.reset) {
27
29
  await (0, ensure_persistent_data_1.clearClaim)(instance, kubernetes, work, name);
28
30
  }
@@ -10,7 +10,9 @@ async function storeKubernetesData(work, kubernetes, instance, environment, path
10
10
  // getStateFilesDir)
11
11
  stateKey) {
12
12
  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) => {
13
+ // a run stopped while the pod waits for room under the quota
14
+ const { signal } = environment.abortCtrl;
15
+ await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'read', signal, async (name) => {
14
16
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(work.data, path)) {
15
17
  const writerStream = environment.file.createWriteStream(generatedArchive.filename);
16
18
  const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', ['tar', 'zcf', '-', '-C', generatedArchive.path, '.'], writerStream, process.stderr, null, false, (status) => {
@@ -68,15 +68,14 @@ stateKey) {
68
68
  }
69
69
  appendVolume(work, persistence, mount.containerPath, mount.localPath, mount.isFile, true, kubernetes, mounted, own);
70
70
  }
71
+ // its deps' src too, uploaded into its own claim: a container task sees them as it does on
72
+ // docker (a tsc -b of a project that references another reads the other's tsconfig)
71
73
  for (const src of work.data.src) {
72
- if (src.inherited) {
73
- continue;
74
- }
75
74
  if (workspace &&
76
75
  appendWorkspaceMount(workspace, persistence, src.absolutePath, src.absolutePath, src.isFile, false)) {
77
76
  continue;
78
77
  }
79
- appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes, mounted, own); // TODO matcher
78
+ 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
79
  }
81
80
  if (work.data.type === 'container-task') {
82
81
  for (const generate of work.data.generates) {
@@ -98,9 +97,10 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
98
97
  stateDir,
99
98
  // the folder its upload goes to, when it is not the one the path is mounted from (a
100
99
  // service that watches its files, see syncLiveFiles)
101
- uploadDir = stateDir) {
100
+ uploadDir = stateDir,
101
+ // where it goes in the claim, when not where getClaimSubPath puts it
102
+ claimPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath)) {
102
103
  const volumeName = getVolumeName(sourceWork, kubernetes);
103
- const claimPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath);
104
104
  const subPath = (stateDir ? `${stateDir}/` : '') + claimPath;
105
105
  const existVolume = persistence.volumes.some((v) => v.name === volumeName);
106
106
  if (!existVolume) {
@@ -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
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.kubernetesForwardRuntime = exports.isRolledOut = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = exports.getTaskJobContainers = exports.describeFailedJob = void 0;
6
+ exports.kubernetesForwardRuntime = exports.isRolledOut = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = exports.getTaskJobContainers = exports.describeFailedJob = exports.recordJobExecution = void 0;
7
7
  const abort_1 = require("../executer/abort");
8
8
  const kubernetes_service_1 = require("../executer/kubernetes-service");
9
9
  const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
@@ -12,6 +12,7 @@ const apply_1 = require("../kubernetes/apply");
12
12
  const ensure_kubernetes_service_exists_1 = require("../kubernetes/ensure-kubernetes-service-exists");
13
13
  const ensure_kubernetes_deployment_exists_1 = require("../kubernetes/ensure-kubernetes-deployment-exists");
14
14
  const follow_pod_logs_1 = require("../kubernetes/follow-pod-logs");
15
+ const work_item_executions_1 = require("./work-item-executions");
15
16
  const service_definition_1 = require("./service-definition");
16
17
  const ensure_namespace_1 = require("../kubernetes/ensure-namespace");
17
18
  const ensure_persistent_data_1 = require("../kubernetes/ensure-persistent-data");
@@ -62,32 +63,17 @@ async function getJobPod(instance, kubernetes, job) {
62
63
  .sort((a, b) => (a.metadata?.creationTimestamp?.toISOString() ?? '').localeCompare(b.metadata?.creationTimestamp?.toISOString() ?? ''))
63
64
  .pop();
64
65
  }
65
- // a task's log is kept to this, the run's own cap is smaller
66
- const MAX_JOB_LOG_BYTES = 4 * 1024 * 1024;
67
- // What the command printed, as the task's output: read from the cluster once the Job
68
- // has ended. Without a pod (it never started) there is nothing to say.
69
- async function writeJobLogs(instance, kubernetes, task, job, container) {
70
- try {
71
- const pod = await getJobPod(instance, kubernetes, job);
72
- if (!pod?.metadata?.name) {
73
- return;
74
- }
75
- const log = await instance.coreApi.readNamespacedPodLog({
76
- name: pod.metadata.name,
77
- namespace: kubernetes.namespace,
78
- container,
79
- limitBytes: MAX_JOB_LOG_BYTES,
80
- });
81
- for (const line of String(log ?? '').split(/\r?\n/)) {
82
- if (line.length > 0) {
83
- task.status.console('stdout', line);
84
- }
85
- }
86
- }
87
- catch (e) {
88
- task.status.write('debug', `the log of ${job} could not be read: ${(0, log_1.getErrorMessage)(e)}`);
66
+ // When the command of a Job ran, as its container says: what the task spent before it (the
67
+ // pod scheduled, the image pulled, the init containers) and after it is not the command's.
68
+ async function recordJobExecution(instance, kubernetes, task, job, container) {
69
+ const pod = await getJobPod(instance, kubernetes, job).catch(() => undefined);
70
+ const status = pod?.status?.containerStatuses?.find((s) => s.name === container);
71
+ const terminated = status?.state?.terminated ?? status?.lastState?.terminated;
72
+ if (terminated?.startedAt && terminated.finishedAt) {
73
+ (0, work_item_executions_1.recordExecution)(task, new Date(terminated.startedAt), new Date(terminated.finishedAt));
89
74
  }
90
75
  }
76
+ exports.recordJobExecution = recordJobExecution;
91
77
  // the command of a Job exited with this code, as a crash on docker or a host
92
78
  class JobCrash extends Error {
93
79
  constructor(exitCode) {
@@ -394,24 +380,33 @@ function kubernetesTaskRuntime(task, kubernetes) {
394
380
  };
395
381
  try {
396
382
  const pod = await (0, apply_1.apply)(instance, spec);
397
- if (!pod.status?.succeeded) {
398
- try {
399
- await (0, await_running_state_1.awaitJobCompletion)(instance, kubernetes, spec.metadata.name, options.abort);
400
- }
401
- catch (e) {
402
- if (!(e instanceof abort_1.AbortError)) {
403
- await writeJobLogs(instance, kubernetes, task, podName, container);
404
- const failure = await describeFailedJob(instance, kubernetes, task.name, podName, container, inJob);
405
- if (failure?.type === 'crash') {
406
- throw new JobCrash(failure.exitCode);
383
+ // what the command prints, while it prints it; the last lines once the pod ended
384
+ const logs = (0, follow_pod_logs_1.followPodLogs)(instance, kubernetes.namespace, `job-name=${podName}`, container, (line) => task.status.console('stdout', line), { onError: (text) => task.status.write('debug', text) });
385
+ try {
386
+ if (!pod.status?.succeeded) {
387
+ try {
388
+ await (0, await_running_state_1.awaitJobCompletion)(instance, kubernetes, spec.metadata.name, options.abort);
389
+ }
390
+ catch (e) {
391
+ if (!(e instanceof abort_1.AbortError)) {
392
+ await logs.settle();
393
+ await recordJobExecution(instance, kubernetes, task.data, podName, container);
394
+ const failure = await describeFailedJob(instance, kubernetes, task.name, podName, container, inJob);
395
+ if (failure?.type === 'crash') {
396
+ throw new JobCrash(failure.exitCode);
397
+ }
398
+ throw new Error(failure?.message ?? (0, log_1.getErrorMessage)(e));
407
399
  }
408
- throw new Error(failure?.message ?? (0, log_1.getErrorMessage)(e));
400
+ throw e;
409
401
  }
410
- throw e;
411
402
  }
403
+ await logs.settle();
404
+ }
405
+ finally {
406
+ logs.stop();
412
407
  }
413
- await writeJobLogs(instance, kubernetes, task, podName, container);
414
408
  task.status.write('debug', 'pod completed');
409
+ await recordJobExecution(instance, kubernetes, task.data, podName, container);
415
410
  // its init containers are done with the credentials
416
411
  await (0, job_secrets_1.removeJobCredentials)(instance, kubernetes, task);
417
412
  if (i < task.data.cmds.length) {