Skip to content

Commit d4f94e4

Browse files
committed
Merge branch 'main' of http://github.com/kemsguy7/molevolvr2.0 into network_button_fix
2 parents 04fa265 + 1e9e71c commit d4f94e4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+2655
-266
lines changed

.env.TEMPLATE

+19-1
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,29 @@
1111
# specified environment
1212
DEFAULT_ENV=dev
1313

14-
# if 0, doesn't open a browser to the frontend web app on a normal stack launch
14+
# registry in which to store the images (uses dockerhub if unspecified)
15+
REGISTRY_PREFIX="us-central1-docker.pkg.dev/cuhealthai-foundations/jravilab-public"
16+
17+
# if 0, doesn't open a browser to the frontend webapp on a normal stack launch
1518
DO_OPEN_BROWSER=1
1619

1720
# database (postgres)
1821
POSTGRES_USER=molevolvr
1922
POSTGRES_PASSWORD=
2023
POSTGRES_DB=molevolvr
2124
POSTGRES_HOST=db-${DEFAULT_ENV}
25+
26+
# slurm accounting database (mariadb)
27+
MARIADB_ROOT_PASSWORD=
28+
MARIADB_USER=slurmdbd
29+
MARIADB_PASSWORD=
30+
MARIADB_DATABASE=slurm_acct_db
31+
MARIADB_HOST=accounting-${DEFAULT_ENV}
32+
MARIADB_PORT=3306
33+
34+
# slurm-specific vars
35+
CLUSTER_NAME=molevolvr-${DEFAULT_ENV}
36+
SLURM_MASTER=master-${DEFAULT_ENV} # who's running slurmctld
37+
SLURM_DBD_HOST=master-${DEFAULT_ENV} # who's running slurmdbd
38+
SLURM_WORKER=worker-${DEFAULT_ENV} # who's running slurmd
39+
SLURM_CPUS=10 # how many cpus to allocate on the worker node

backend/README.md

+112-22
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,119 @@
11
# MolEvolvR Backend
22

3-
The backend is implemented as a RESTful API over the following entities:
4-
5-
- `User`: Represents a user of the system. At the moment logins aren't
6-
required, so all regular users are the special "Anonymous" user. Admins
7-
have individual accounts.
8-
- `Analysis`: Represents an analysis submitted by a user. Each analysis has a unique ID
9-
and is associated with a user. analyses contain the following sub-entities:
10-
- `Submission`: Represents the submission of a Analysis, e.g. the data
11-
itself as well the submission's parameters (both selected by the
3+
The backend is implemented as a RESTful API. It currently provides endpoints for
4+
just the `analysis` entity, but will be expanded to include other entities as
5+
well.
6+
7+
## Usage
8+
9+
Run the `launch_api.sh` script to start API server in a hot-reloading development mode.
10+
The server will run on port 9050, unless the env var `API_PORT` is set to another
11+
value. Once it's running, you can access it at http://localhost:9050.
12+
13+
If the env var `USE_SLURM` is equal to 1, the script will create a basic SLURM
14+
configuration and then launch `munge`, a client used to authenticate to the
15+
SLURM cluster. The template that configures the backend's connection to SLURM
16+
can be found at `./cluster_config/slurm.conf.template`.
17+
18+
The script then applies any outstanding database migrations via
19+
[atlas](https://github.com/ariga/atlas). Finally the API server is started by
20+
executing the `entrypoint.R` script via
21+
[drip](https://github.com/siegerts/drip), which restarts the server whenever
22+
there are changes to the code.
23+
24+
*(Side note: the entrypoint contains a bit of custom logic to
25+
defer actually launching the server until the port it listens on is free, since
26+
drip doesn't cleanly shut down the old instance of the server.)*
27+
28+
## Implementation
29+
30+
The backend is implemented in [Plumber](https://www.rplumber.io/index.html), a
31+
package for R that allows for the creation of RESTful APIs. The API is defined
32+
in the `api/plumber.R` file, which defines the router and some shared metadata
33+
routes. The rest of the routes are brought in from the `endpoints/` directory.
34+
35+
Currently implemented endpoints:
36+
- `POST /analyses`: Create a new analysis
37+
- `GET /analyses`: Get all analyses
38+
- `GET /analyses/:id`: Get a specific analysis by its ID
39+
- `GET /analyses/:id/status`: Get just the status field for an analysis by its ID
40+
41+
*(TBC: more comprehensive docs; see the [Swagger docs](http://localhost:9050/__docs__/) for now)*
42+
43+
## Database Schema
44+
45+
The backend uses a PostgreSQL database to store analyses. The database's schema
46+
is managed by [atlas](https://github.com/ariga/atlas); you can find the current
47+
schema definition at `./schema/schema.pg.hcl`. After changing the schema, you
48+
can create a "migration", i.e. a set of SQL statements that will bring the
49+
database up to date with the new schema, by running `./schema/makemigration.sh
50+
<reason>`; if all is well with the schema, the new migration will be put in
51+
`./schema/migrations/`.
52+
53+
Any pending migrations are applied automatically when the backend starts up, but
54+
you can manually apply new migrations by running `./schema/apply.sh`.
55+
56+
## Testing
57+
58+
You can run the tests for the backend by running the `run_tests.sh` script. The
59+
script will recursively search for all files with the pattern `test_*.R` in the
60+
`tests/` directory and run them. Tests are written using the
61+
[testthat](https://testthat.r-lib.org/) package.
62+
63+
Note that the tests currently depend on the stack's services being available, so
64+
you should run the tests from within the backend container after having started
65+
the stack normally. An easy way to do that is to execute `./run_stack.sh shell`
66+
in the repo root, which will give you an interactive shell in the backend
67+
container. Eventually, we'll have them run in their own environment, which the
68+
`run_tests.sh` script will likely orchestrate.
69+
70+
## Implementation Details
71+
72+
### Domain Entities
73+
74+
*NOTE: the backend is as of now a work in progress, so expect this to change.*
75+
76+
The backend includes, or will include, the following entities:
77+
78+
- `User`: Represents a user of the system. At the moment logins aren't required,
79+
so all regular users are the special "Anonymous" user. Admins have individual
80+
accounts.
81+
- `Analysis`: Represents an analysis submitted by a user. Each analysis has a
82+
unique ID and is associated with a user. analyses contain the following
83+
sub-entities:
84+
- `AnalysisSubmission`: Represents the submission of a Analysis, e.g. the
85+
data itself as well the submission's parameters (both selected by the
1286
user and supplied by the system).
13-
- `AnalysisStatus`: Represents the status of a Analysis. Each Analysis has a status
14-
associated with it, which is updated as the Analysis proceeds through its
15-
processing stages.
87+
- `AnalysisStatus`: Represents the status of a Analysis. Each Analysis has a
88+
status associated with it, which is updated as the Analysis proceeds through
89+
its processing stages.
1690
- `AnalysisResult`: Represents the result of a Analysis.
17-
- `Cluster`: Represents the status of the overall cluster, including
18-
how many analyses have been completed, how many are in the queue,
19-
and other statistics related to the processing of analyses.
91+
- `Queue`: Represents the status of processing analyses, including how many
92+
analyses have been completed, how many are in the queue, and other statistics.
93+
- `System`: Represents the system as a whole, including the version of the
94+
backend, the version of the frontend, and other metadata about the system.
95+
Includes runtime statistics about the execution environment as well, such as RAM
96+
and CPU usage. Includes cluster information, too, such as node uptime and
97+
health.
2098

21-
## Implementation
99+
### Job Processing
100+
101+
*NOTE: we use the term "job" here to indicate any asynchronous task that the
102+
backend needs to perform outside of the request-response cycle. It's not related
103+
to the app domain's terminology of a "job" (i.e. an analysis).*
22104

23-
The backend is implemented in Plumber, a package for R that allows for the
24-
creation of RESTful APIs. The API is defined in the `api/router.R` file, which
25-
contains the endpoints for the API. Supporting files are found in
26-
`api/resources/`.
105+
The backend makes use of
106+
[future.batchtools](https://future.batchtools.futureverse.org/), an extension
107+
that adds [futures](https://future.futureverse.org/) support to
108+
[batchtools](https://mllg.github.io/batchtools/index.html), a package for
109+
processing asynchronous jobs. The package provides support for many
110+
job-processing systems, including
111+
[SLURM](https://slurm.schedmd.com/documentation.html); more details on
112+
alternative systems can be found in the [`batchtools` package
113+
documentation](https://mllg.github.io/batchtools/articles/batchtools.html).
27114

28-
The API is then run using the `launch_api.R` file, which starts the Plumber
29-
server.
115+
In our case, we use SLURM; `batchtools` basically wraps SLURM's `sbatch` command
116+
and handles producing a job script for an R callable, submitting the script to
117+
the cluster for execution, and collecting the results to be returned to R. The
118+
template for the job submission script can be found at
119+
`./cluster_config/slurm.tmpl`.

backend/api/cluster.R

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# contains shared state for interacting with the job dispatch system
2+
3+
box::use(
4+
batchtools[makeRegistry],
5+
future.batchtools[...],
6+
future[plan, future, value]
7+
)
8+
9+
.on_load <- function(ns) {
10+
options(future.cache.path = "/opt/shared-jobs/.future", future.delete = TRUE)
11+
12+
# create a registry
13+
dir.create("/opt/shared-jobs/jobs-scratch", recursive = TRUE, showWarnings = FALSE)
14+
# reg <- makeRegistry(file.dir = NA, work.dir = "/opt/shared-jobs/jobs-scratch")
15+
# call plan()
16+
plan(
17+
batchtools_slurm,
18+
template = "/app/cluster_config/slurm.tmpl",
19+
resources = list(nodes = 1, cpus = 1, walltime=2700, ncpus=1, memory=1000)
20+
)
21+
}
22+
23+
#' Takes in a block of code and runs it asynchronously, returning the future
24+
#' @param callable a function that will be run asynchronously in a slurm job
25+
#' @param work.dir the directory to run the code in, which should be visible to worker nodes
26+
#' @return a future object representing the asynchronous job
27+
dispatch <- function(callable, work.dir="/opt/shared-jobs/jobs-scratch") {
28+
# ensure we run jobs in a place where slurm nodes can access them, too
29+
setwd(work.dir)
30+
future(callable())
31+
}
32+
33+
box::export(dispatch)

backend/api/dispatch/submit.R

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
box::use(
2+
analyses = api/models/analyses,
3+
api/cluster[dispatch]
4+
)
5+
6+
#' Dispatch an analysis, i.e. create a record for it in the database and submit
7+
#' it to the cluster for processing
8+
#' @param name the name of the analysis
9+
#' @param type the type of the analysis
10+
#' @return the id of the new analysis
11+
dispatchAnalysis <- function(name, type) {
12+
# create the analysis record
13+
analysis_id <- analyses$db_submit_analysis(name, type)
14+
15+
# print to the error log that we're dispatching this
16+
cat("Dispatching analysis", analysis_id, "\n")
17+
18+
# dispatch the analysis async (to slurm, or wherever)
19+
promise <- dispatch(function() {
20+
21+
tryCatch({
22+
# do the analysis
23+
analyses$db_update_analysis_status(analysis_id, "analyzing")
24+
25+
# FIXME: implement calls to the molevolvr package to perform the
26+
# analysis. we may fire off additional 'dispatch()' calls if we
27+
# need to parallelize things.
28+
29+
# --- begin testing section which should be removed ---
30+
31+
# for now, just do a "task"
32+
Sys.sleep(1) # pretend we're doing something
33+
34+
# if type is "break", raise an error to test the handler
35+
if (type == "break") {
36+
stop("test error")
37+
}
38+
39+
# --- end testing section ---
40+
41+
# finalize when we're done
42+
analyses$db_update_analysis_status(analysis_id, "complete")
43+
}, error = function(e) {
44+
# on error, log the error and update the status
45+
analyses$db_update_analysis_status(analysis_id, "error", reason=e$message)
46+
cat("Error in analysis ", analysis_id, ": ", e$message, "\n")
47+
flush()
48+
})
49+
50+
cat("Analysis", analysis_id, " completed\n")
51+
})
52+
53+
return(analysis_id)
54+
}
55+
56+
box::export(dispatchAnalysis)

backend/api/endpoints/analyses.R

+39-15
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# endpoints for submitting and checking information about analyses.
22
# included by the router aggregator in ./plumber.R; all these endpoints are
3-
# prefixed with /analysis/ by the aggregator.
3+
# prefixed with /analyses/ by the aggregator.
44

55
box::use(
66
analyses = api/models/analyses,
7+
api/dispatch/submit[dispatchAnalysis],
8+
api/helpers/responses[api_404_if_empty],
79
tibble[tibble],
8-
dplyr[select, any_of, mutate],
10+
dplyr[select, any_of, mutate, pull],
911
dbplyr[`%>%`]
1012
)
1113

@@ -18,6 +20,10 @@ box::use(
1820
analysis_list <- function() {
1921
result <- analyses$db_get_analyses()
2022

23+
# NOTE: this is 'postprocessing' is required when jsonlite's force param is
24+
# FALSE, because it can't figure out how to serialize the types otherwise.
25+
# while we just set force=TRUE now, i don't know all the implications of that
26+
# choice, so i'll leave this code here in case we need it.
2127
# postprocess types in the result
2228
# result <- result %>%
2329
# mutate(
@@ -32,30 +38,48 @@ analysis_list <- function() {
3238
#* @tag Analyses
3339
#* @serializer jsonExt
3440
#* @get /<id:str>/status
35-
analysis_status <- function(id) {
36-
result <- analyses$db_get_analysis_by_id(id)
37-
result$status
41+
#* @response 404 error_message="Analysis with id '...' not found"
42+
analysis_status <- function(id, res) {
43+
api_404_if_empty(
44+
analyses$db_get_analysis_by_id(id) %>% pull(status), res,
45+
error_message=paste0("Analysis with id '", id, "' not found")
46+
)
3847
}
3948

4049

4150
#* Query the database for an analysis's complete information.
4251
#* @tag Analyses
43-
#* @serializer jsonExt
52+
#* @serializer jsonExt list(auto_unbox=TRUE)
4453
#* @get /<id:str>
45-
analysis_by_id <- function(id){
46-
result <- analyses$db_get_analysis_by_id(id)
47-
# result is a tibble with one row, so just
48-
# return that row rather than the entire tibble
49-
result
54+
#* @response 200 schema=analysis
55+
#* @response 404 error_message="Analysis with id '...' not found"
56+
analysis_by_id <- function(id, res) {
57+
# below we return the analysis object; we have to unbox it again
58+
# because auto_unbox only unboxes length-1 lists and vectors, not
59+
# dataframes
60+
api_404_if_empty(
61+
jsonlite::unbox(analyses$db_get_analysis_by_id(id)),
62+
res, error_message=paste0("Analysis with id '", id, "' not found")
63+
)
5064
}
5165

5266
#* Submit a new MolEvolvR analysis, returning the analysis ID
5367
#* @tag Analyses
5468
#* @serializer jsonExt
5569
#* @post /
70+
#* @param name:str A friendly name for the analysis chosen by the user
71+
#* @param type:str Type of the analysis (e.g., "FASTA")
5672
analysis_submit <- function(name, type) {
57-
# submit the analysis
58-
result <- analyses$db_submit_analysis(name, type)
59-
# the result is a scalar in a vector, so just return the scalar
60-
# result[[1]]
73+
# submits the analysis, which handles:
74+
# - inserting the analysis into the database
75+
# - dispatching the analysis to the cluster
76+
# - returning the analysis ID
77+
analysis_id <- dispatchAnalysis(name, type)
78+
79+
# NOTE: unboxing (again?) gets it return a single string rather than a list
80+
# with a string in it. while it works, it's a hack, and i should figure out
81+
# how to make the serializer do this for me.
82+
return(
83+
jsonlite::unbox(analyses$db_get_analysis_by_id(analysis_id))
84+
)
6185
}

backend/api/support/custom_serializers.R backend/api/helpers/custom_serializers.R

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
box::use(
66
plumber[register_serializer, serializer_content_type],
7-
api/support/string_helpers[inline_str_list]
7+
api/helpers/string_helpers[inline_str_list]
88
)
99

1010
#' Register custom serializers, e.g. for JSON with specific defaults

backend/api/helpers/responses.R

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#' Helpers for returning error responses from the API
2+
3+
api_404_if_empty <- function(result, res, error_message="Not found") {
4+
if (isTRUE(nrow(result) == 0 || is.null(result) || length(result) == 0)) {
5+
cat("Returning 404\n")
6+
res$status <- 404
7+
return(error_message)
8+
}
9+
10+
return(result)
11+
}
12+
13+
box::export(api_404_if_empty)
File renamed without changes.

0 commit comments

Comments
 (0)