maelstrom-worker 0.14.0

The Maelstrom worker. This process executes jobs as directed by the broker.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! Code for the worker binary.

pub mod config;
pub mod local_worker;

mod artifact_fetcher;
mod dispatcher;
mod dispatcher_adapter;
mod executor;
mod layer_fs;
mod manifest_digest_cache;
mod types;

use anyhow::{anyhow, bail, Context as _, Error, Result};
use artifact_fetcher::{GitHubArtifactFetcher, TcpArtifactFetcher};
use config::Config;
use dispatcher::{Dispatcher, Message};
use dispatcher_adapter::DispatcherAdapter;
use executor::{MountDir, TmpfsDir};
use maelstrom_base::proto::Hello;
use maelstrom_github::GitHubClient;
use maelstrom_layer_fs::BlobDir;
use maelstrom_linux::{self as linux, Signal};
use maelstrom_util::{
    broker_connection::{
        BrokerConnectionFactory, BrokerReadConnection as _, BrokerWriteConnection as _,
        GitHubQueueBrokerConnectionFactory, TcpBrokerConnectionFactory,
    },
    cache::{self, fs::std::Fs as StdFs, TempFileFactory},
    config::common::{CacheSize, ClusterCommunicationStrategy, InlineLimit, Slots},
    process::TERMINATION_SIGNALS,
    root::RootBuf,
    sync::EventSender,
};
use num::integer;
use slog::{debug, error, info, o, Logger};
use std::future::Future;
use tokio::{
    signal::unix::{self as signal, SignalKind},
    sync::mpsc,
    task::{self, JoinHandle, JoinSet},
};
use types::{BrokerSocketOutgoingSender, Cache, DispatcherReceiver, DispatcherSender};

const MAX_PENDING_LAYERS_BUILDS: usize = 10;
const MAX_ARTIFACT_FETCHES: usize = 1;

pub fn main(config: Config, log: Logger) -> Result<()> {
    info!(log, "started"; "config" => ?config);
    let err = match config.cluster_communication_strategy {
        ClusterCommunicationStrategy::Tcp => {
            let Some(broker) = config.broker else {
                bail!(
                    "because config value `cluster-communication-strategy` is set to `tcp`, \
                    config value `broker` must be set via `--broker` command-line option, \
                    `MAELSTROM_WORKER_BROKER` or `MAELSTROM_BROKER` environment variables, or \
                    `broker` key in config file"
                );
            };
            main_inner(TcpBrokerConnectionFactory::new(broker, &log), config, &log).unwrap_err()
        }
        ClusterCommunicationStrategy::GitHub => {
            let Some(token) = &config.github_actions_token else {
                bail!(
                    "because config value `cluster-communication-strategy` is set to `github`, \
                    config value `github-actions-token` must be set via `--github-actions-token` \
                    command-line option, `MAELSTROM_WORKER_GITHUB_ACTIONS_TOKEN` or \
                    `MAELSTROM_GITHUB_ACTIONS_TOKEN` environment variables, or \
                    `github-actions-token` key in config file"
                );
            };
            let Some(url) = &config.github_actions_url else {
                bail!(
                    "because config value `cluster-communication-strategy` is set to `github`, \
                    config value `github-actions-url` must be set via `--github-actions-url` \
                    command-line option, `MAELSTROM_WORKER_GITHUB_ACTIONS_URL` or \
                    `MAELSTROM_GITHUB_ACTIONS_URL` environment variables, or \
                    `github-actions-url` key in config file"
                );
            };
            main_inner(
                GitHubQueueBrokerConnectionFactory::new(&log, token.clone(), url.clone())?,
                config,
                &log,
            )
            .unwrap_err()
        }
    };
    info!(log, "exiting");
    Err(err)
}

/// The main function for the worker. This should be called on a task of its own. It will return
/// when a signal is received or when one of the worker tasks completes because of an error.
#[tokio::main]
async fn main_inner(
    broker_connection_factory: impl BrokerConnectionFactory,
    config: Config,
    log: &Logger,
) -> Result<()> {
    check_open_file_limit(log, config.slots, 0)?;

    let hello = Hello::Worker {
        slots: config.slots.into_inner().into(),
    };
    let (read_stream, write_stream) = broker_connection_factory.connect(&hello).await?;

    let (dispatcher_sender, dispatcher_receiver) = mpsc::unbounded_channel();
    let (broker_socket_outgoing_sender, broker_socket_outgoing_receiver) =
        mpsc::unbounded_channel();

    let mut join_set = JoinSet::new();

    join_set.spawn(read_stream.read_messages(
        dispatcher_sender.clone(),
        log.new(o!("task" => "reader")),
        dispatcher::Message::Broker,
    ));

    let writer_log = log.new(o!("task" => "writer"));
    join_set.spawn(write_stream.write_messages(broker_socket_outgoing_receiver, writer_log));

    let tasks = start_dispatcher_task(
        config,
        dispatcher_receiver,
        dispatcher_sender.clone(),
        broker_socket_outgoing_sender,
        log,
        join_set,
    )
    .context("starting dispatcher task")?;

    tasks.run_to_completion().await
}

/// Check if the open file limit is high enough to fit our estimate of how many files we need.
pub fn check_open_file_limit(log: &Logger, slots: Slots, extra: u64) -> Result<()> {
    let limit = linux::getrlimit(linux::RlimitResource::NoFile)?;
    let estimate = open_file_max(slots) + extra;
    debug!(log, "checking open file limit"; "limit" => ?limit.current, "estimate" => estimate);
    if limit.current < estimate {
        let estimate = round_to_multiple(estimate, 1024);
        bail!("Open file limit is too low. Increase limit by running `ulimit -n {estimate}`");
    }
    Ok(())
}

/// For the number of slots, what is the maximum number of files we will open. This attempts to
/// come up with a number by doing some math, but nothing is guaranteeing the result.
fn open_file_max(slots: Slots) -> u64 {
    let existing_open_files: u64 = 3 /* stdout, stdin, stderr */;
    let per_slot_estimate: u64 = 6 /* unix socket, FUSE connection, (stdout, stderr) * 2 */ +
        maelstrom_fuse::MAX_PENDING as u64 /* each FUSE request opens a file */;
    existing_open_files
        + (maelstrom_layer_fs::READER_CACHE_SIZE * 2) // 1 for socket, 1 for the file
        + MAX_ARTIFACT_FETCHES as u64
        + per_slot_estimate * u16::from(slots) as u64
        + (MAX_PENDING_LAYERS_BUILDS * maelstrom_layer_fs::LAYER_BUILDING_FILE_MAX) as u64
}

fn round_to_multiple(n: u64, k: u64) -> u64 {
    integer::div_ceil(n, k) * k
}

/// Return a future that will wait for a signal to arrive, then return an error.
fn signal_handler_terminate(
    signal: Signal,
    log: &Logger,
) -> Result<impl Future<Output = Result<()>>> {
    let mut handler = signal::signal(SignalKind::from_raw(signal.as_c_int()))
        .with_context(|| "registering signal handler for {signal}")?;
    let log = log.clone();
    Ok(async move {
        handler.recv().await;
        error!(log, "received {signal}");
        Err(anyhow!("signal {signal}"))
    })
}

/// Return a future that will just log and ignore the given signal.
fn signal_handler_ignore(signal: Signal, log: &Logger) -> Result<impl Future<Output = Result<()>>> {
    let mut handler = signal::signal(SignalKind::from_raw(signal.as_c_int()))
        .with_context(|| "registering signal handler for {signal}")?;
    let log = log.clone();
    Ok(async move {
        loop {
            handler.recv().await;
            debug!(log, "received {signal}; ignoring");
        }
    })
}

fn start_dispatcher_task(
    config: Config,
    dispatcher_receiver: DispatcherReceiver,
    dispatcher_sender: DispatcherSender,
    broker_socket_outgoing_sender: BrokerSocketOutgoingSender,
    log: &Logger,
    tasks: JoinSet<Result<()>>,
) -> Result<Tasks> {
    let dispatcher_sender_clone = dispatcher_sender.clone();
    let max_simultaneous_fetches = u32::try_from(MAX_ARTIFACT_FETCHES)
        .unwrap()
        .try_into()
        .unwrap();
    let broker_sender = move |msg| broker_socket_outgoing_sender.send(msg);

    match config.cluster_communication_strategy {
        ClusterCommunicationStrategy::Tcp => {
            let artifact_fetcher_factory = move |temp_file_factory| {
                TcpArtifactFetcher::new(
                    max_simultaneous_fetches,
                    dispatcher_sender_clone,
                    config.broker.unwrap(),
                    log.clone(),
                    temp_file_factory,
                )
            };
            start_dispatcher_task_common(
                artifact_fetcher_factory,
                broker_sender,
                config.cache_size,
                config.cache_root,
                dispatcher_receiver,
                dispatcher_sender,
                None,
                config.inline_limit,
                log,
                true, /* log_initial_cache_message_at_info */
                config.slots,
                tasks,
            )
        }
        ClusterCommunicationStrategy::GitHub => {
            let github_client = GitHubClient::new(
                config.github_actions_token.as_ref().unwrap(),
                config.github_actions_url.as_ref().unwrap().clone(),
            )?;
            let artifact_fetcher_factory = move |temp_file_factory| {
                GitHubArtifactFetcher::new(
                    max_simultaneous_fetches,
                    github_client,
                    dispatcher_sender_clone,
                    log.clone(),
                    temp_file_factory,
                )
            };
            start_dispatcher_task_common(
                artifact_fetcher_factory,
                broker_sender,
                config.cache_size,
                config.cache_root,
                dispatcher_receiver,
                dispatcher_sender,
                None,
                config.inline_limit,
                log,
                true, /* log_initial_cache_message_at_info */
                config.slots,
                tasks,
            )
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn start_dispatcher_task_common<
    ArtifactFetcherT: dispatcher::ArtifactFetcher + Send + 'static,
    ArtifactFetcherFactoryT: FnOnce(TempFileFactory<StdFs>) -> ArtifactFetcherT,
    BrokerSenderT: dispatcher::BrokerSender + Send + 'static,
>(
    artifact_fetcher_factory: ArtifactFetcherFactoryT,
    broker_sender: BrokerSenderT,
    cache_size: CacheSize,
    cache_root: RootBuf<config::CacheDir>,
    mut dispatcher_receiver: DispatcherReceiver,
    dispatcher_sender: DispatcherSender,
    done: Option<EventSender>,
    inline_limit: InlineLimit,
    log: &Logger,
    log_initial_cache_message_at_info: bool,
    slots: Slots,
    mut tasks: JoinSet<Result<()>>,
) -> Result<Tasks> {
    // Register signal handlers. There are a few things to note.
    //
    // First, we want to do this before we start executing any jobs. Therefore, it's important to
    // register the handler on this task, even if we start another task to monitor the signal.
    //
    // Second, we ignore SIGPIPE, but do so in a tricky way. It's possible that we may get a TCP
    // reset which would generate a SIGPIPE. We don't want that killing the process. So, we need to
    // ignore SIGPIPE so that an EPIPE will be returned from the socket write, and we will then
    // gracefully clean up. However, we have to be careful how we register the signal handler. If
    // we set the disposition to SIG_IGN, this would be inherited by all of our children processes,
    // and we'd then have remember to explicitly reset to SIG_DFL. But if we set up an actual
    // handler, the signal disposition will be set back to the default when we call clone with
    // CLONE_CLEAR_SIGHAND.
    //
    // Third, we don't ignore SIGTSTP, SIGTIN, or SIGTOU, as their default disposition stops the
    // process instead of killing it. If the user wants to do this, then they should be able to do
    // it. Jobs will continue in the background just fine, but we won't schedule any new ones.
    for signal in TERMINATION_SIGNALS {
        tasks.spawn(signal_handler_terminate(signal, log)?);
    }
    tasks.spawn(signal_handler_ignore(Signal::PIPE, log)?);

    let log = log.new(o!("task" => "dispatcher"));

    let (cache, temp_file_factory) = Cache::new(
        StdFs,
        cache_root.join::<cache::CacheDir>("artifacts"),
        cache_size,
        log.clone(),
        log_initial_cache_message_at_info,
    )
    .context("creating cache")?;

    let artifact_fetcher = artifact_fetcher_factory(temp_file_factory.clone());

    let dispatcher_adapter = DispatcherAdapter::new(
        dispatcher_sender.clone(),
        inline_limit,
        log,
        cache_root.join::<MountDir>("mount"),
        cache_root.join::<TmpfsDir>("upper"),
        cache.root().join::<BlobDir>("sha256/blob"),
        temp_file_factory,
    )
    .context("creating dispatcher adapter")?;

    let mut dispatcher = Dispatcher::new(
        dispatcher_adapter,
        artifact_fetcher,
        broker_sender,
        cache,
        slots,
    );

    let dispatcher_task = task::spawn(async move {
        loop {
            let msg = dispatcher_receiver
                .recv()
                .await
                .expect("all senders should never be closed");
            if let Err(err) = dispatcher.receive_message(msg) {
                drop(done);
                break err;
            }
        }
    });

    Ok(Tasks::new(dispatcher_task, dispatcher_sender, tasks))
}

// The tasks in the JoinSet are all of the supporting tasks. If these complete with Ok(()), that
// means they ran out of work to do. This should only happen at shutdown time, like when the
// channel a task is reading from or writing to is closed. When we see these return Ok(()) results,
// we just ignore them, since we expect the root cause to surface somewhere else.
//
// When a task in the JoinSet completes with Err(_), then we need to tell the dispatcher to
// cleanly shut down. We do that by sending it a ShutDown message with the error returned
// from the task. It's okay to send multiple ShutDown messages: the dispatcher will ignore
// all but the first.
pub struct Tasks {
    dispatcher: JoinHandle<Error>,
    dispatcher_sender: DispatcherSender,
    other_tasks_monitor: JoinHandle<()>,
}

impl Tasks {
    fn new(
        dispatcher: JoinHandle<Error>,
        dispatcher_sender: DispatcherSender,
        mut other_tasks: JoinSet<Result<()>>,
    ) -> Self {
        let dispatcher_sender_clone = dispatcher_sender.clone();
        let other_tasks_monitor = task::spawn(async move {
            while let Some(result) = other_tasks.join_next().await {
                match result.context("joining worker task") {
                    Err(join_error) => {
                        let _ = dispatcher_sender_clone.send(Message::ShutDown(join_error));
                        break;
                    }
                    Ok(Err(error)) => {
                        let _ = dispatcher_sender_clone.send(Message::ShutDown(error));
                        break;
                    }
                    Ok(Ok(())) => {}
                }
            }
        });
        Self {
            dispatcher,
            dispatcher_sender,
            other_tasks_monitor,
        }
    }

    pub async fn run_to_completion(self) -> Result<()> {
        let result = Err(self.dispatcher.await?);
        self.other_tasks_monitor.abort();
        result
    }

    pub async fn shut_down(self, error: Error) -> Result<()> {
        let _ = self.dispatcher_sender.send(Message::ShutDown(error));
        self.run_to_completion().await
    }
}