Skip to content

Commit

Permalink
479 mirai lockfile@main (#1263)
Browse files Browse the repository at this point in the history
#1276

@pawelru @m7pr 
This is a `mirai` alternative. Package seems to address the biggest
issues reported during a research:
- `mirai` has a native support of `ExtendedTask`
- `mirai` is not being killed when `runApp` is executed (like `callr`
does)
- `mirai` by default opens a deamon in parallel R session without a need
to handle the `future::plan`.
- `mirai` has only one dependency in the whole dependency tree.

#### Disadvantages so far: 
- ~~we need to pass and set `options`, system vars, working directory
and `.libPaths` shikokuchuo/mirai#122

#### How does it work:
- lockfile creation is invoked in `init` before application starts. This
prevents to start the process each time when a new shiny session starts.
Process is invoked as a promise and eventually `teal_app.lock` will be
created
- When shiny session starts `download lockfile` button is hidden by
default. If promise is eventually resolved and lockfile is created then
download button is shown.
- alternatively, app developer can pre-compute lockfile and provide its
path in `teal.renv.lockfile` option. In such case `renv::snapshot` will
be skipped and user lockfile will be used in an app.

#### Logs and notifications
Logs are printed for app developer while notifications are presented to
the app user:
1. When app uses precomputed file:
- log in init: `Lockfile set using option "teal.renv.lockfile" -
skipping automatic creation.`
- no notification to the app user.
2. When app automatically determines snapshot:
- log in init: `Lockfile creation started based on { getwd() }.`
- log If lockfile created: `Lockfile {path} containing { n-pkgs }
packages created{ with errors or warnings }.`
- notification if lockfile created: `Lockfile available to download`
- log if lockfile not created: `Lockfile creation failed.` 
- notification if lockfile not created: `Lockfile creation failed.`

---------

Signed-off-by: Marcin <[email protected]>
Signed-off-by: Pawel Rucki <[email protected]>
Co-authored-by: m7pr <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Marcin <[email protected]>
Co-authored-by: Pawel Rucki <[email protected]>
Co-authored-by: 27856297+dependabot-preview[bot]@users.noreply.github.com <27856297+dependabot-preview[bot]@users.noreply.github.com>
Co-authored-by: Aleksander Chlebowski <[email protected]>
  • Loading branch information
7 people authored Sep 23, 2024
1 parent 19cda80 commit 1b4bb50
Show file tree
Hide file tree
Showing 12 changed files with 336 additions and 206 deletions.
3 changes: 1 addition & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@ repos:
additional_dependencies:
- davidgohel/flextable # Error: package 'flextable' is not available
- davidgohel/gdtools # for flextable
- mirai
- checkmate
- future
- jsonlite
- lifecycle
- logger
- magrittr
- methods
- promises
- renv
- rlang
- shiny
Expand Down
12 changes: 6 additions & 6 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,10 @@ Depends:
teal.slice (>= 0.5.1.9009)
Imports:
checkmate (>= 2.1.0),
future (>= 1.33.2),
jsonlite,
lifecycle (>= 0.2.0),
logger (>= 0.2.0),
methods,
promises (>= 1.3.0),
renv (>= 1.0.7),
rlang (>= 1.0.0),
shinyjs,
stats,
Expand All @@ -59,8 +56,10 @@ Imports:
Suggests:
bslib,
knitr (>= 1.42),
mirai (>= 1.1.1),
MultiAssayExperiment,
R6,
renv (>= 1.0.7),
rmarkdown (>= 2.23),
rvest,
shinytest2,
Expand All @@ -74,8 +73,9 @@ RdMacros:
lifecycle
Config/Needs/verdepcheck: rstudio/shiny, insightsengineering/teal.data,
insightsengineering/teal.slice, mllg/checkmate,
HenrikBengtsson/future, jeroen/jsonlite, r-lib/lifecycle,
daroczig/logger, rstudio/promises, rstudio/renv, r-lib/rlang,
jeroen/jsonlite, r-lib/lifecycle,
daroczig/logger, shikokuchuo/mirai, shikokuchuo/nanonext,
rstudio/renv, r-lib/rlang,
daattali/shinyjs, insightsengineering/teal.code,
insightsengineering/teal.logger, insightsengineering/teal.reporter,
insightsengineering/teal.widgets, rstudio/bslib, yihui/knitr,
Expand Down Expand Up @@ -106,6 +106,7 @@ Collate:
'module_snapshot_manager.R'
'module_teal.R'
'module_teal_data.R'
'module_teal_lockfile.R'
'module_teal_with_splash.R'
'module_transform_data.R'
'reporter_previewer_module.R'
Expand All @@ -116,7 +117,6 @@ Collate:
'teal_data_module-eval_code.R'
'teal_data_module-within.R'
'teal_data_utils.R'
'teal_lockfile.R'
'teal_reporter.R'
'teal_slices-store.R'
'teal_slices.R'
Expand Down
3 changes: 0 additions & 3 deletions R/init.R
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,6 @@ init <- function(data,
# log
teal.logger::log_system_info()

# invoke lockfile creation
teal_lockfile()

# argument transformations
## `modules` - landing module
landing <- extract_module(modules, "teal_module_landing")
Expand Down
6 changes: 3 additions & 3 deletions R/module_teal.R
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ ui_teal <- function(id,
footer,
teal.widgets::verbatim_popup_ui(ns("sessionInfo"), "Session Info", type = "link"),
br(),
downloadLink(ns("lockFile"), "Download .lock file"),
ui_teal_lockfile(ns("lockfile")),
textOutput(ns("identifier"))
)
)
Expand All @@ -156,6 +156,8 @@ srv_teal <- function(id, data, modules, filter = teal_slices()) {
moduleServer(id, function(input, output, session) {
logger::log_debug("srv_teal initializing.")

srv_teal_lockfile("lockfile")

output$identifier <- renderText(
paste0("Pid:", Sys.getpid(), " Token:", substr(session$token, 25, 32))
)
Expand All @@ -166,8 +168,6 @@ srv_teal <- function(id, data, modules, filter = teal_slices()) {
title = "SessionInfo"
)

output$lockFile <- teal_lockfile_downloadhandler()

# `JavaScript` code
run_js_files(files = "init.js")

Expand Down
201 changes: 201 additions & 0 deletions R/module_teal_lockfile.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#' Generate lockfile for application's environment reproducibility
#'
#' @param lockfile_path (`character`) path to the lockfile.
#'
#' @section Different ways of creating lockfile:
#' `teal` leverages [renv::snapshot()], which offers multiple methods for lockfile creation.
#'
#' - **Working directory lockfile**: `teal`, by default, will create an `implicit` type lockfile that uses
#' `renv::dependencies()` to detect all R packages in the current project's working directory.
#' - **`DESCRIPTION`-based lockfile**: To generate a lockfile based on a `DESCRIPTION` file in your working
#' directory, set `renv::settings$snapshot.type("explicit")`. The naming convention for `type` follows
#' `renv::snapshot()`. For the `"explicit"` type, refer to `renv::settings$package.dependency.fields()` for the
#' `DESCRIPTION` fields included in the lockfile.
#' - **Custom files-based lockfile**: To specify custom files as the basis for the lockfile, set
#' `renv::settings$snapshot.type("custom")` and configure the `renv.snapshot.filter` option.
#'
#' @section lockfile usage:
#' After creating the lockfile, you can restore the application's environment using `renv::restore()`.
#'
#' @seealso [renv::snapshot()], [renv::restore()].
#'
#' @return `NULL`
#'
#' @name module_teal_lockfile
#' @rdname module_teal_lockfile
#'
#' @keywords internal
NULL

#' @rdname module_teal_lockfile
ui_teal_lockfile <- function(id) {
ns <- NS(id)
shiny::tagList(
tags$span("", id = ns("lockFileStatus")),
shinyjs::disabled(downloadLink(ns("lockFileLink"), "Download lockfile"))
)
}

#' @rdname module_teal_lockfile
srv_teal_lockfile <- function(id) {
moduleServer(id, function(input, output, session) {
logger::log_debug("Initialize srv_teal_lockfile.")
enable_lockfile_download <- function() {
shinyjs::html("lockFileStatus", "Application lockfile ready.")
shinyjs::hide("lockFileStatus", anim = TRUE)
shinyjs::enable("lockFileLink")
output$lockFileLink <- shiny::downloadHandler(
filename = function() {
"renv.lock"
},
content = function(file) {
file.copy(lockfile_path, file)
file
},
contentType = "application/json"
)
}
disable_lockfile_download <- function() {
warning("Lockfile creation failed.", call. = FALSE)
shinyjs::html("lockFileStatus", "Lockfile creation failed.")
shinyjs::hide("lockFileLink")
}

shiny::onStop(function() {
if (file.exists(lockfile_path) && !shiny::isRunning()) {
logger::log_debug("Removing lockfile after shutting down the app")
file.remove(lockfile_path)
}
})

lockfile_path <- "teal_app.lock"
mode <- getOption("teal.lockfile.mode", default = "")

if (!(mode %in% c("auto", "enabled", "disabled"))) {
stop("'teal.lockfile.mode' option can only be one of \"auto\", \"disabled\" or \"disabled\". ")
}

if (mode == "disabled") {
logger::log_debug("'teal.lockfile.mode' option is set to 'disabled'. Hiding lockfile download button.")
shinyjs::hide("lockFileLink")
return(NULL)
}

if (file.exists(lockfile_path)) {
logger::log_debug("Lockfile has already been created for this app - skipping automatic creation.")
enable_lockfile_download()
return(NULL)
}

if (mode == "auto" && .is_disabled_lockfile_scenario()) {
logger::log_debug(
"Automatic lockfile creation disabled. Execution scenario satisfies teal:::.is_disabled_lockfile_scenario()."
)
shinyjs::hide("lockFileLink")
return(NULL)
}

if (!.is_lockfile_deps_installed()) {
warning("Automatic lockfile creation disabled. `mirai` and `renv` packages must be installed.")
shinyjs::hide("lockFileLink")
return(NULL)
}

# - Will be run only if the lockfile doesn't exist (see the if-s above)
# - We render to the tempfile because the process might last after session is closed and we don't
# want to make a "teal_app.renv" then. This is why we copy only during active session.
process <- .teal_lockfile_process_invoke(lockfile_path)
observeEvent(process$status(), {
if (process$status() %in% c("initial", "running")) {
shinyjs::html("lockFileStatus", "Creating lockfile...")
} else if (process$status() == "success") {
result <- process$result()
if (any(grepl("Lockfile written to", result$out))) {
logger::log_debug("Lockfile containing { length(result$res$Packages) } packages created.")
if (any(grepl("(WARNING|ERROR):", result$out))) {
warning("Lockfile created with warning(s) or error(s):", call. = FALSE)
for (i in result$out) {
warning(i, call. = FALSE)
}
}
enable_lockfile_download()
} else {
disable_lockfile_download()
}
} else if (process$status() == "error") {
disable_lockfile_download()
}
})

NULL
})
}

utils::globalVariables(c("opts", "sysenv", "libpaths", "wd", "lockfilepath", "run")) # needed for mirai call
#' @rdname module_teal_lockfile
.teal_lockfile_process_invoke <- function(lockfile_path) {
mirai_obj <- NULL
process <- shiny::ExtendedTask$new(function() {
m <- mirai::mirai(
{
options(opts)
do.call(Sys.setenv, sysenv)
.libPaths(libpaths)
setwd(wd)
run(lockfile_path = lockfile_path)
},
run = .renv_snapshot,
lockfile_path = lockfile_path,
opts = options(),
libpaths = .libPaths(),
sysenv = as.list(Sys.getenv()),
wd = getwd()
)
mirai_obj <<- m
m
})

shiny::onStop(function() {
if (mirai::unresolved(mirai_obj)) {
logger::log_debug("Terminating a running lockfile process...")
mirai::stop_mirai(mirai_obj) # this doesn't stop running - renv will be created even if session is closed
}
})

suppressWarnings({ # 'package:stats' may not be available when loading
process$invoke()
})

logger::log_debug("Lockfile creation started based on { getwd() }.")

process
}

#' @rdname module_teal_lockfile
.renv_snapshot <- function(lockfile_path) {
out <- utils::capture.output(
res <- renv::snapshot(
lockfile = lockfile_path,
prompt = FALSE,
force = TRUE,
type = renv::settings$snapshot.type() # see the section "Different ways of creating lockfile" above here
)
)

list(out = out, res = res)
}

#' @rdname module_teal_lockfile
.is_lockfile_deps_installed <- function() {
requireNamespace("mirai", quietly = TRUE) && requireNamespace("renv", quietly = TRUE)
}

#' @rdname module_teal_lockfile
.is_disabled_lockfile_scenario <- function() {
identical(Sys.getenv("CALLR_IS_RUNNING"), "true") || # inside callr process
identical(Sys.getenv("TESTTHAT"), "true") || # inside devtools::test
!identical(Sys.getenv("QUARTO_PROJECT_ROOT"), "") || # inside Quarto process
(
("CheckExEnv" %in% search()) || any(c("_R_CHECK_TIMINGS_", "_R_CHECK_LICENSE_") %in% names(Sys.getenv()))
) # inside R CMD CHECK
}
Loading

0 comments on commit 1b4bb50

Please sign in to comment.