Skip to main content

picodata_plugin/
background.rs

1//! Background container for long live jobs.
2//! Background container guarantees job liveness across plugin life cycle.
3
4use crate::internal::ffi;
5#[allow(unused_imports)]
6use crate::plugin::interface::PicoContext;
7use crate::plugin::interface::ServiceId;
8use crate::util::tarantool_error_to_box_error;
9use std::cell::Cell;
10use std::rc::Rc;
11use std::time::Duration;
12use tarantool::error::BoxError;
13use tarantool::error::TarantoolErrorCode;
14use tarantool::fiber;
15use tarantool::fiber::FiberId;
16use tarantool::fiber::{Channel, RecvError};
17use tarantool::util::IntoClones;
18
19/// Same as [`PicoContext::register_job`].
20#[track_caller]
21pub fn register_job<F>(service_id: &ServiceId, job: F) -> Result<(), BoxError>
22where
23    F: FnOnce(CancellationToken) + 'static,
24{
25    let loc = std::panic::Location::caller();
26    let tag = format!("{}:{}", loc.file(), loc.line());
27
28    register_tagged_job(service_id, job, &tag)
29}
30
31/// Same as [`PicoContext::register_tagged_job`].
32pub fn register_tagged_job<F>(service_id: &ServiceId, job: F, tag: &str) -> Result<(), BoxError>
33where
34    F: FnOnce(CancellationToken) + 'static,
35{
36    let (token, handle) = CancellationToken::new();
37    let finish_event = Rc::clone(&handle.finish_event);
38
39    let fiber_id = fiber::Builder::new()
40        .name(tag)
41        .func(move || {
42            job(token);
43            finish_event.signal();
44        })
45        .start_non_joinable()
46        .map_err(tarantool_error_to_box_error)?;
47
48    let plugin = &service_id.plugin;
49    let service = &service_id.service;
50    let version = &service_id.version;
51
52    let token = FfiBackgroundJobCancellationToken::new(
53        fiber_id,
54        handle.cancel_channel,
55        handle.finish_event,
56    );
57    register_background_job_cancellation_token(plugin, service, version, tag, token)?;
58
59    Ok(())
60}
61
62fn register_background_job_cancellation_token(
63    plugin: &str,
64    service: &str,
65    version: &str,
66    job_tag: &str,
67    token: FfiBackgroundJobCancellationToken,
68) -> Result<(), BoxError> {
69    // SAFETY: safe as long as picodata version is compatible
70    let rc = unsafe {
71        ffi::pico_ffi_background_register_job_cancellation_token(
72            plugin.into(),
73            service.into(),
74            version.into(),
75            job_tag.into(),
76            token,
77        )
78    };
79
80    if rc != 0 {
81        return Err(BoxError::last());
82    }
83
84    Ok(())
85}
86
87////////////////////////////////////////////////////////////////////////////////
88// cancel_jobs_by_tag
89////////////////////////////////////////////////////////////////////////////////
90
91/// Outcome of the request to cancel a set of background jobs.
92#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Hash)]
93#[repr(C)]
94pub struct JobCancellationResult {
95    /// Attempted to cancel this many background jobs.
96    pub n_total: u32,
97    /// This many jobs didn't finish in time.
98    pub n_timeouts: u32,
99}
100
101impl JobCancellationResult {
102    #[inline(always)]
103    pub fn new(n_total: u32, n_timeouts: u32) -> Self {
104        Self {
105            n_total,
106            n_timeouts,
107        }
108    }
109}
110
111/// Same as [`PicoContext::cancel_tagged_jobs`].
112#[inline(always)]
113pub fn cancel_jobs_by_tag(
114    service_id: &ServiceId,
115    job_tag: &str,
116    timeout: Duration,
117) -> Result<JobCancellationResult, BoxError> {
118    cancel_background_jobs_by_tag_inner(
119        &service_id.plugin,
120        &service_id.service,
121        &service_id.version,
122        job_tag,
123        timeout,
124    )
125}
126
127/// Same as [`PicoContext::cancel_tagged_jobs`].
128pub fn cancel_background_jobs_by_tag_inner(
129    plugin: &str,
130    service: &str,
131    version: &str,
132    job_tag: &str,
133    timeout: Duration,
134) -> Result<JobCancellationResult, BoxError> {
135    let mut result = JobCancellationResult::default();
136    // SAFETY: safe as long as picodata version is compatible
137    let rc = unsafe {
138        ffi::pico_ffi_background_cancel_jobs_by_tag(
139            plugin.into(),
140            service.into(),
141            version.into(),
142            job_tag.into(),
143            timeout.as_secs_f64(),
144            &mut result,
145        )
146    };
147
148    if rc != 0 {
149        return Err(BoxError::last());
150    }
151
152    Ok(result)
153}
154
155#[derive(thiserror::Error, Debug)]
156pub enum Error {
157    #[error("Some of jobs are not fully completed, expected: {0}, completed: {1}")]
158    PartialCompleted(usize, usize),
159    #[error("timeout")]
160    CancellationTimeout,
161}
162
163////////////////////////////////////////////////////////////////////////////////
164// CancellationToken
165////////////////////////////////////////////////////////////////////////////////
166
167/// A token which can be used to signal a cancellation request to the job.
168#[non_exhaustive]
169#[derive(Debug)]
170pub struct CancellationToken {
171    cancel_channel: Channel<()>,
172}
173
174impl CancellationToken {
175    /// Create a cancellation token and cancellation token handle pair.
176    /// User should use cancellation token for graceful shutdown their job.
177    /// Cancellation token handle used by `picodata` for sending cancel signal to a user job.
178    pub fn new() -> (CancellationToken, CancellationTokenHandle) {
179        let (cancel_tx, cancel_rx) = Channel::new(1).into_clones();
180        (
181            CancellationToken {
182                cancel_channel: cancel_rx,
183            },
184            CancellationTokenHandle {
185                cancel_channel: cancel_tx,
186                finish_event: Rc::new(OnceEvent::new()),
187            },
188        )
189    }
190
191    /// Wait until token are canceled.
192    ///
193    /// # Arguments
194    ///
195    /// * `timeout`: if cancellation does not occur within the specified timeout - return error
196    ///
197    /// # Errors
198    ///
199    /// Return `Ok` if token is canceled or [`Error::CancellationTimeout`] if timeout is reached.
200    pub fn wait_timeout(&self, timeout: Duration) -> Result<(), Error> {
201        match self.cancel_channel.recv_timeout(timeout) {
202            Err(RecvError::Timeout) => Err(Error::CancellationTimeout),
203            _ => Ok(()),
204        }
205    }
206}
207
208#[derive(Debug)]
209pub struct CancellationTokenHandle {
210    cancel_channel: Channel<()>,
211    finish_event: Rc<OnceEvent>,
212}
213
214impl CancellationTokenHandle {
215    /// Cancel related job and return a finish event.
216    /// Caller should wait on the finish event to make sure the job
217    /// is completed successfully (graceful shutdown occurred).
218    pub fn cancel(self) -> Rc<OnceEvent> {
219        let Self {
220            cancel_channel,
221            finish_event,
222        } = self;
223        _ = cancel_channel.send(());
224        finish_event
225    }
226}
227
228////////////////////////////////////////////////////////////////////////////////
229// set_background_jobs_shutdown_timeout
230////////////////////////////////////////////////////////////////////////////////
231
232/// In case when jobs were canceled by `picodata` use this function to determine
233/// a shutdown timeout - time duration that `picodata` uses to ensure that all
234/// jobs gracefully end.
235///
236/// By default, 5-second timeout are used.
237///
238/// Consider using [`PicoContext::set_jobs_shutdown_timeout`] instead
239pub fn set_jobs_shutdown_timeout(plugin: &str, service: &str, version: &str, timeout: Duration) {
240    // SAFETY: safe as long as picodata version is compatible
241    let rc = unsafe {
242        ffi::pico_ffi_background_set_jobs_shutdown_timeout(
243            plugin.into(),
244            service.into(),
245            version.into(),
246            timeout.as_secs_f64(),
247        )
248    };
249    debug_assert!(
250        rc == 0,
251        "return code is only for future compatibility at the moment"
252    );
253}
254
255////////////////////////////////////////////////////////////////////////////////
256// OnceEvent
257////////////////////////////////////////////////////////////////////////////////
258
259/// A one-time event that can be signaled once and waited on by multiple fibers.
260/// Late waiters (after signal) return immediately.
261#[derive(Debug)]
262pub struct OnceEvent {
263    finished: Cell<bool>,
264    cond: fiber::Cond,
265}
266
267impl OnceEvent {
268    fn new() -> Self {
269        Self {
270            finished: Cell::new(false),
271            cond: fiber::Cond::new(),
272        }
273    }
274
275    /// Signal that the job has finished. Wakes all waiting fibers.
276    pub fn signal(&self) {
277        self.finished.set(true);
278        self.cond.broadcast();
279    }
280
281    #[inline]
282    pub fn is_finished(&self) -> bool {
283        self.finished.get()
284    }
285
286    pub fn wait_timeout(&self, timeout: Duration) {
287        self.cond.wait_timeout(timeout);
288    }
289}
290
291////////////////////////////////////////////////////////////////////////////////
292// CancellationCallbackState
293////////////////////////////////////////////////////////////////////////////////
294
295#[derive(Debug)]
296pub struct CancellationCallbackState {
297    cancel_channel: Channel<()>,
298    finish_event: Rc<OnceEvent>,
299    status: Cell<CancellationCallbackStatus>,
300}
301
302#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
303pub enum CancellationCallbackStatus {
304    #[default]
305    Initial = 0,
306    JobCancelled = 1,
307    JobFinished = 2,
308}
309
310impl CancellationCallbackState {
311    fn new(cancel_channel: Channel<()>, finish_event: Rc<OnceEvent>) -> Self {
312        Self {
313            cancel_channel,
314            finish_event,
315            status: Cell::new(CancellationCallbackStatus::Initial),
316        }
317    }
318
319    fn cancellation_callback(&self, action: u64, timeout: Duration) -> Result<(), BoxError> {
320        use CancellationCallbackStatus::*;
321        let next_status = action;
322
323        if next_status == JobCancelled as u64 {
324            // Cancellation is idempotent.
325            if self.status.get() == JobCancelled || self.status.get() == JobFinished {
326                return Ok(());
327            }
328            debug_assert_eq!(self.status.get(), Initial);
329            _ = self.cancel_channel.send(());
330
331            self.status.set(JobCancelled);
332        } else if next_status == JobFinished as u64 {
333            if self.status.get() != JobCancelled && self.status.get() != JobFinished {
334                return Err(BoxError::new(
335                    TarantoolErrorCode::IllegalParams,
336                    "cannot wait for job that wasn't cancelled",
337                ));
338            }
339
340            let deadline = fiber::clock().saturating_add(timeout);
341
342            loop {
343                if self.finish_event.is_finished() {
344                    self.status.set(JobFinished);
345                    return Ok(());
346                }
347
348                let remaining = deadline.duration_since(fiber::clock());
349                if remaining.is_zero() {
350                    return Err(BoxError::new(TarantoolErrorCode::Timeout, "timeout"));
351                }
352
353                self.finish_event.wait_timeout(remaining);
354            }
355        } else {
356            return Err(BoxError::new(
357                TarantoolErrorCode::IllegalParams,
358                format!("unexpected action: {action}"),
359            ));
360        }
361
362        Ok(())
363    }
364}
365
366////////////////////////////////////////////////////////////////////////////////
367// ffi wrappers
368////////////////////////////////////////////////////////////////////////////////
369
370/// **For internal use**.
371#[repr(C)]
372pub struct FfiBackgroundJobCancellationToken {
373    /// This is just a way to do arbitrarily complex things across ABI boundary
374    /// without introducing a large amount of FFI-safe wrappers. This will
375    /// always be [`CancellationCallbackState::cancellation_callback`].
376    callback: extern "C-unwind" fn(data: *const Self, action: u64, timeout: f64) -> i32,
377    drop: extern "C-unwind" fn(*mut Self),
378
379    /// The pointer to the closure object.
380    closure_pointer: *mut (),
381
382    /// This is the background job fiber which will be cancelled by this token.
383    pub fiber_id: FiberId,
384}
385
386impl Drop for FfiBackgroundJobCancellationToken {
387    #[inline(always)]
388    fn drop(&mut self) {
389        (self.drop)(self)
390    }
391}
392
393impl FfiBackgroundJobCancellationToken {
394    fn new(fiber_id: FiberId, cancel_channel: Channel<()>, finish_event: Rc<OnceEvent>) -> Self {
395        let callback_state = CancellationCallbackState::new(cancel_channel, finish_event);
396        let callback = move |action, timeout| {
397            let res = callback_state.cancellation_callback(action, timeout);
398            if let Err(e) = res {
399                e.set_last();
400                return -1;
401            }
402
403            0
404        };
405
406        Self::new_inner(fiber_id, callback)
407    }
408
409    /// This function is needed, because we need this `F` type parameter so that
410    /// we can specialize the `callback` and `drop` with it inside this function.
411    /// If rust supported something like `type F = type_of(callback);` we
412    /// wouldn't need this additional function and would just write this code in
413    /// the [`Self::new`] above.
414    // FIXME: just define an explicit extern "C" fn for cancellation_callback?
415    fn new_inner<F>(fiber_id: FiberId, f: F) -> Self
416    where
417        F: FnMut(u64, Duration) -> i32,
418    {
419        let closure = Box::new(f);
420        let closure_pointer: *mut F = Box::into_raw(closure);
421
422        Self {
423            callback: Self::trampoline::<F>,
424            drop: Self::drop_handler::<F>,
425            closure_pointer: closure_pointer.cast(),
426
427            fiber_id,
428        }
429    }
430
431    /// An ABI-safe wrapper which calls the rust closure stored in `handler`.
432    ///
433    /// The result of the closure is copied onto the fiber's region allocation
434    /// and the pointer to that allocation is written into `output`.
435    extern "C-unwind" fn trampoline<F>(data: *const Self, action: u64, timeout: f64) -> i32
436    where
437        F: FnMut(u64, Duration) -> i32,
438    {
439        // This is safe. To verify see `register_rpc_handler` above.
440        let closure_pointer: *mut F = unsafe { (*data).closure_pointer.cast::<F>() };
441        let closure = unsafe { &mut *closure_pointer };
442
443        closure(action, Duration::from_secs_f64(timeout))
444    }
445
446    extern "C-unwind" fn drop_handler<F>(handler: *mut Self) {
447        unsafe {
448            let closure_pointer: *mut F = (*handler).closure_pointer.cast::<F>();
449            let closure = Box::from_raw(closure_pointer);
450            drop(closure);
451
452            if cfg!(debug_assertions) {
453                // Overwrite the pointer with garbage so that we fail loudly is case of a bug
454                (*handler).closure_pointer = 0xcccccccccccccccc_u64 as _;
455            }
456        }
457    }
458
459    /// The error is returned via [`BoxError::set_last`].
460    #[inline(always)]
461    pub fn cancel_job(&self) {
462        let rc = (self.callback)(self, CancellationCallbackStatus::JobCancelled as _, 0.0);
463        debug_assert!(rc == 0);
464    }
465
466    /// The error is returned via [`BoxError::set_last`].
467    #[inline(always)]
468    #[allow(clippy::result_unit_err)]
469    pub fn wait_job_finished(&self, timeout: Duration) -> Result<(), ()> {
470        let rc = (self.callback)(
471            self,
472            CancellationCallbackStatus::JobFinished as _,
473            timeout.as_secs_f64(),
474        );
475        if rc == -1 {
476            // Actual error is passed through tarantool. Can't return BoxError
477            // here, because tarantool-module version may be different in picodata.
478            return Err(());
479        }
480
481        Ok(())
482    }
483}