Version 26.8.23
-
vignettes/creating-a-task.Rmd.origno longer carries an emptyif (requireNamespace("magrittr", ...)) { }block. Droppinglibrary(magrittr)left the guard behind with nothing in it. The.origfile ships in the tarball and_PRECOMPILER.Rregenerates the vignette from it.
Version 26.8.22
The package drops
magrittr. Every%>%is now the base pipe|>, andmagrittris gone fromDESCRIPTION.The rewrite is a relocation, not an edit. Each
%>%call was transformed the way R’s parser transforms|>, and the resulting tree was required to match the tree parsed from the rewritten file. A file whose trees disagreed was left untouched and converted by hand instead.%>%is no longer exported.R/x_rexport.Rexisted only to re-export it and is deleted, and the “Imported Functions” section of_pkgdown.ymlgoes with it. Code that relied onlibrary(cs9)for the pipe must use|>or attachmagrittritself.vignettes/creating-a-task.Rmd.origandvignettes/file-layout.Rmd.origare converted too._PRECOMPILER.Rregenerates the shipped.Rmdfrom them, so converting only the.Rmdwould have been undone on the next precompile.The version runs one day ahead of the calendar on purpose. r-universe already published today’s number from a different commit, and one version must never name two trees.
Version 26.8.21
-
R/zzz_imports.Rnamescallr,csutil,later,pbmcapplyandprogressin one plain function, which clears the declared-but-unused note.R CMD checkdoes not walk thepublic = list(...)of anR6Class, so apkg::fn()call inside an R6 method is invisible to its dependency scan. - All five were real.
progresslooked dead, because it is never written asprogress::anywhere in this package. It is required at load time all the same:.onLoadcallsprogressr::handler_progress(), and that function’s body callsprogress::progress_bar. Removing it made the package fail to install. - The version goes to 26.8.21 rather than reusing 26.8.20, which r-universe has already published from a different tree. One version number must not name two.
Version 26.8.20
New Features
-
run_task_detached()andinst/scripts/run-task.shrun one task in a process that outlives the R session which started it. This is the non-interactive counterpart torun_task_sequentially_as_callr_bg_using_load_all(). Use that one in Positron, where a person watches a live console, and this one when nobody is watching: a script, a cron entry, or an agent driving a machine over SSH. The two are not interchangeable.TaskJobpassessupervise = TRUE, socallrkills the task when the parent R process exits, and it streams output throughlater::later(), which needs an event loop thatRscriptdoes not drive. - The script writes the task’s exit code to a status file, and writes it only when the task ends. Wait on that file, never on the log. A log cannot separate “finished cleanly” from “died at line 4”, so a caller that reads the tail of a log is guessing. The absence of the file means the task is still running, never that it is done. If the process is killed hard, no status is written at all, and the lock file still holds the pid, so
kill -0separates “running” from “died without reporting”. - The script refuses to start a task that is already running from the same package, and exits 3. Two concurrent runs of one import write the same rows twice.
- The log opens with the task name, the implementation package and its version, the git branch and commit of the package directory when it is a repository, and whether that tree was clean. A log that reports a duration alone cannot answer which code produced it, and an implementation package copied out of its checkout has no commit to read afterwards.
- The script takes the package directory and the surveillance system expression as options, so it serves any cs9 implementation package. It defaults to the working directory and to
global$ss. - The runner counts error lines in the log and writes the count beside the status file, because exit code 0 does not mean the work succeeded. Measured on 2026-08-17:
norsyss_data_import_consultationsexited 0 with 212 rejectedCOPYstatements, one per partition for each of two weeks whose rows already existed.load_data_infile.db_postgresrunspsqlthroughsystem2()and never reads its exit status, so a rejectedCOPYdoes not reach R at all. A status of 0 beside a non-zero error count is the shape that failure takes. The count is a grep and therefore a heuristic: a count above zero is worth reading, and a count of zero is not proof that a task did what it should.
Verified on a NorSySS prod workspace pod on 2026-08-17, all six paths: no arguments exits 2; a directory holding no DESCRIPTION exits 2 and names the reason; a task that does not exist writes exit code 1; norsyss_data_delete writes exit code 0 after 106 plans in 1.4 minutes; a second call while the first ran exited 3 and surfaced through run_task_detached() as an R error; and no lock file remained afterwards.
Version 26.8.19
Bug Fixes
-
Taskcallscsutil::unnest_dfs_within_list_of_fully_named_lists(). It calledsplutil::, andsplutilwas in neitherImportsnorSuggests. The two packages publish the same utilities under two names, and the NorSySS pods carrycsutilalone. Soupsert_at_end_of_each_plan = TRUEandinsert_at_end_of_each_plan = TRUEboth raisedthere is no package called 'splutil'on every pod, and nothing installed the package that would have fixed it. - There are four call sites, and they are in TWO methods.
run_sequential()holdsR/r6_Task.R:295and:301.run_parallel_plans()holds:351and:357. The parallel pair fails worse: the worker wraps its body in atryCatchwith five attempts and a five second sleep between them, so a missing package costs 25 seconds before it surfaces asError in index N. -
DESCRIPTIONdeclarescsutil,pbmcapplyandutils.pbmcapply::pbmclapply()atR/r6_Task.R:322is how every parallel task runs, so a machine without it could not run one.utilsships with R, so it is always installed, but the dependency was still undeclared. -
tests/testthat/test-plan-end-write.Rcovers all four sites, one test each. Neither feature had a test, which is why thesplutilcalls survived. The two parallel tests mockpbmcapply::pbmclapplyto run the worker in process, so nothing forks and the write is observable. Revertingcsutiltosplutilfails all four. The cs9 suite holds 387 passes, up from 368.
Development
-
Task$run()no longer callsfuture::plan(future::sequential)orforeach::registerDoSEQ(), andDESCRIPTIONno longer declaresfutureorforeach. cs9 never creates a future plan and never registers a parallelforeachbackend, so both lines reset a state that cs9 does not set. The two%dopar%blocks that would have needed them are commented out, atR/r6_Task.R:425and:523.data.table::setDTthreads()on the next line is a real reset and stays. -
26.8.18declaredfutureandforeachrather than removing the calls. That release is superseded on this point. -
Suggestsdeclaresdevtools.devtools::load_all('.')atR/r6_SurveillanceSystem.R:414andR/r6_TaskJob.R:72is generated text, inside aglue::glue()string that cs9 writes to a temporary.Rfile. cs9 then runs that file in a child R process, so the feature needsdevtoolsat run time. Process separation moves the dependency and does not remove it.Suggestsis the right category, because one optional feature needs it and the package does not. - No undeclared namespace call remains. See
CLAUDE.mdfor the sweep, and for the three matches it reports that are text rather than calls.
Version 26.8.18
Bug Fixes
-
DESCRIPTIONdeclaresforeachandfuture.Task$run()callsfuture::plan(future::sequential)andforeach::registerDoSEQ()atR/r6_Task.R:262and:263, on the path every successful run takes. Neither package was inImports, inSuggestsor inNAMESPACE. A machine without them installed therefore raisedthere is no package called 'future'at the end of every task. - The defect is as old as those two lines, and no test reached them.
26.8.17addedtests/testthat/test-fork-ordering.R, which drives the publicTask$run()to completion, so CI ran the line for the first time and went red. The development pods carry both packages, so every local suite stayed green.
Version 26.8.17
Development
-
tests/testthat/test-fork-ordering.Rpins the order thatTask$run()uses: it closes every database connection before it forks. Nothing asserted that order before. A refactor that moves the sweep reintroduces silent data corruption. - The test mocks
pbmcapply::pbmclapplyand drives the publicTask$run(). The mock reads every connection at the fork boundary and returns without forking. A test on the privaterun_sequential()proves that the helper disconnects. It does not prove that the public path calls the helper before it forks. - Two mutations of
R/r6_Task.Rgo red. The first deletes the disconnect sweep and fails 2 assertions. The second movesrun_parallel_plans()in front of the sequential setup and fails 3. -
test-fork-ordering.Rasserts the length of both connection vectors before it asserts that both are open.all(logical(0))is TRUE, so the open check would otherwise pass on an empty list. - No executable
cs9code changed in this release. The version moves because26.8.16is already published from a different tree.
Documentation
-
DBPartitionedTableExtended_v9documents its one shared connection. The object owns it, every child borrows it, and only the parent’sdisconnect()closes it. -
$nrow()and$info()document thepartitioncolumn that26.8.16added. It is character, it matchesnames(self$tables), and it is the last column, sotable_namestays at position 1 andnrowat position 2.
Version 26.8.16
Bug Fixes
-
DBPartitionedTableExtended_v9$nrow(collapse = FALSE)now returns apartitioncolumn. The column holds the partition tag of the table on that row.$info()returns the same column. A caller no longer has to recover the tag from the table name. - The tag was already known, and both methods discarded it. Each one loops over
self$partitions_randomizedand marks the matching row withkeep := TRUE. The loop variable IS the tag at that moment. Both methods now write it into the row. -
partitionischaracter, so it matchesnames(self$tables)and indexesself$tables[[...]]directly. - The change is additive, and
partitionis the LAST column.nrow(collapse = FALSE)returnstable_name,nrow,partition, in that order.info(collapse = FALSE)appendspartitionafter the columns that csdb returns, and moves nothing else. An existing caller that reads column 1 or column 2 by position therefore reads what it read before.table_nameandnrowalso keep their names, their types and their values.nrow(collapse = TRUE)still returns one number.info(collapse = TRUE)aggregates over every partition, so it has no single tag and returns nopartitioncolumn. - This removes a defect in every caller that parses a table name for the tag. The separator is not one string.
DBPartitionedTableExtended_v9$initialize()writesxxpxxfor PostgreSQL and for SQLite, andPARTITIONfor the MSSQL fallback.norsyss.cs9split the name on_PARTITION_to find the partition with the fewest rows. Production is PostgreSQL, so the split returnedNA,self$tables[[NA]]returnedNULL, and the consultations import died onattempt to apply non-function. That task therefore never ran against PostgreSQL. Measured on a NorSySS pod on 2026-08-14. - Both methods declare
partitionbefore the loop, besidekeep, so the column ischaracterfrom the start. The declaration is not what creates the column. A:=whoseimatches no row still creates it, and gives it the type of the assigned value. Measured with data.table 1.18.4 on 2026-08-14.
Development
-
tests/testthat/test-partition-safety.Rpins the new column. It asserts set equality againstnames(pt$tables), becausepartitions_randomizedshuffles the row order. It also asserts thatpt$tables[[row$partition]]$table_nameequalsrow$table_namefor every row. That second assertion is the one that catches the original defect. No test names a separator, because the point of the change is that a caller does not know one. - Three further tests in the same file pin the compatibility contract. One asserts that
table_nameis column 1 ofnrow(collapse = FALSE)and thatnrowis column 2, by position and by type. One asserts thatinfo(collapse = TRUE)returns its four aggregate columns and nopartition. One asserts thatinfo(collapse = FALSE)appendspartitionlast, against the base columns read from csdb itself.
Version 26.8.15
Bug Fixes
-
DESCRIPTIONrequirescsdb (>= 2026.8.15). That is the release whereDBConnection_v9refuses a connection another process opened.Task$run_parallel_plans()forks withpbmcapply::pbmclapplyand passes the table objects into the workers, and every partition of a partitioned table now shares one connection. A forked child that used the parent’s connection received wrong results and no error. -
csdb 2026.8.14carries the shared connection without that guard, so this floor names2026.8.15rather than the release it would otherwise pair with.
Development
- No
cs9source file changed in this release. The version moves so the raised floor reaches a distinct tree, because26.8.14is already published. -
Task$run()closes every connection inrun_sequential()before it forks, so production never reached the corrupt state. Nothing asserts that ordering.csdb 2026.8.15removes the dependency on it.
Version 26.8.14
Bug Fixes
-
DBPartitionedTableExtended_v9now opens one database connection for the whole table, not one per partition. It builds a singlecsdb::DBConnection_v9before the partition loop and passes it to everyDBTableExtended_v9. A table with 106 partitions needed 106 simultaneous connections before this change. That exceeded the NorSySS PostgreSQL limit of 100 connections on 2026-08-13. The import died withFATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute. The failure did not depend on the run size, because the partition count exceeds the budget before the first row is inserted. -
DBPartitionedTableExtended_v9$disconnect()now closes the shared connection once, at the parent. It looped over the partitions and closed each child before. Each child borrows the connection, so a child’sdisconnect()is a no-op incsdb2026.8.14. -
DBTableExtended_v9$initialize()gainsdbconnectionas its eighth and last argument. It forwards that argument tocsdb::DBTable_v9in the eighth position. The argument defaults toNULL, which keeps the previous behaviour: the object builds and owns its own connection.SurveillanceSystem_v9$add_table()names every argument it passes and passes nodbconnection, so it is unaffected. -
DESCRIPTIONrequirescsdb (>= 2026.8.14). That is the version whereDBTable_v9$initialize()acceptsdbconnection. Without this version floor, the incompatibility would fail at run time rather than at install time. -
DBPartitionedTableExtended_v9$drop_all_rows_and_then_upsert_data()now sends each row ofnewdatato the partition that its partition column names. It readself[[self$column_name_partition]]before.selfis the R6 object and holds no field with that name, so[[returnedNULL.NULL == "a"returnslogical(0), and a data.table indexed bylogical(0)holds zero rows. The method therefore randrop_all_rows()on every partition and then upserted nothing. The whole table lost every row, with no error and no warning.drop_all_rows_and_then_insert_data()readsnewdataand is unchanged. -
DBPartitionedTableExtended_v9now rejects anewdatathat it cannot route, before any method destroys a row. This is the third defect of this shape in the class in one week, and the routing fix above does not cover it.private$check_for_correct_partitions_in_data()readnewdata[[self$column_name_partition]], and[[returnsNULLfor a column thatnewdatadoes not carry.unique(NULL)isNULL,!NULL %in% xislogical(0), andsum(logical(0))is 0, so validation passed. Bothdrop_all_rows_and_then_*methods then computedNULL == i, which islogical(0). Each took zero rows for every partition, dropped all of them and wrote nothing back. The whole table lost every row, with no error and no warning. The check now rejects four things, in this order.- A
newdatathat is not adata.frame. It names the class it received. - A
newdatawith no partition column. It names the column it expected. - An
NAin the partition column. - A value that no partition covers. It names those values.
- A
- A zero-row
newdatathat carries the partition column is still accepted. Clearing every partition is a legitimate operation, andtests/testthat/test-partition-safety.Rpins it. Only aNULL, or a table without the column, is rejected. -
DBPartitionedTableExtended_v9now writes a generated partition column with a valid:=call, and it writes into a copy. Four methods heldnewdata[, .(self$column_name_partition) := part]. That form raisesLHS of := must be a symbol, or an atomic vector. Every table with a non-NULLvalue_generator_partitiontherefore failed on its first write. No test built one, which is why the defect survived. The form is nownewdata[, (self$column_name_partition) := part].:=also writes by reference. The old form therefore added a column to the caller’s own data.table, as a side effect of a write.data.table::copy()now runs first. The copy is inside thevalue_generator_partitionbranch only, and not on the common path. A NorSySS production import is memory-bound against about 20 GiB of usable pod memory, so a copy of everynewdatawould double the peak. -
DBPartitionedTableExtended_v9now handles a numeric partition value, which two separate defects broke. First,sample(x, length(x))reads a length-1 numericxas the range1:x. A single partition numbered 9 therefore became a draw from 1 to 9. Both call sites, ininsert_data()and in thepartitions_randomizedactive binding, now index by a permutation:x[sample.int(length(x))]. That form has no such special case and is a no-op below length 2. Second,[[on a list takes a numeric index as a position, soself$tables[[5L]]returned the fifth child rather than the child named “5”. In the constructor it also grew the list to five elements, three of themNULL. All 16self$tables[[...]]sites inR/r6_DBPartitionedTableExtended_v9.Rnow index byas.character(). A character partition value was never affected by either defect. -
DBPartitionedTableExtended_v9now declaresindexesas a public field, andinitialize()assigns it. The class tookindexesas a constructor argument and gave it to every child, but it held no field of that name.self$indexeswas thereforeNULL, and thedrop_indexes = names(self$indexes)default inupsert_data()and indrop_all_rows_and_then_upsert_data()evaluated toNULL. Each child received an explicitNULL, which overrode the child’s ownnames(child$indexes)default. Both defaults are now a plainNULL, so the runtime behaviour is identical and is now explicit. Nothing drops an index by default. Whether an index rebuild beats row-by-row index maintenance depends on four things: rows changed per child, index count and width, table size, and concurrency. ADROP INDEXalso briefly blocks reads and writes. That needs measurement against real batch sizes. A caller who wants the drop now passesdrop_indexes = names(pt$indexes). -
DBPartitionedTableExtended_v9$upsert_data()computedpartitions_in_usetwice from the same expression. One of the two lines is removed. Nothing observable changes. The method still writes to the partitions in the ordernewdatanames them. It is not randomised, because no transaction spans the partition loop, so each child releases its locks before the next runs. -
DBPartitionedTableExtended_v9now computes one route vector, and all four write methods route with it.private$check_for_correct_partitions_in_data()returnsas.character()of the partition column. Each write method captures that vector and indexes withroute == as.character(i), besideself$tables[[as.character(i)]]. One coercion now names the child and selects its rows. Validation usedsetdiff()on the raw values before, while the write methods used==. Those were two independent answers to one question, and they agreed. An adversarial review said a factor column made them diverge and wiped every partition. Measured against the real class on 2026-08-14, it does not.setdiff()converts a factor to character, and==against a factor compares its labels, so both sides already read the same thing. The class is refactored so that the question has one answer, not because the two answers differed. -
DBPartitionedTableExtended_v9now rejects a partition column that is not atomic. A list column passed the old check, becausesetdiff()also converts a list to character.setdiff(list("a"), c("a", "b", "c"))is therefore empty.drop_all_rows_and_then_upsert_data()then dropped every partition and died inside csdb onis.infinite(get(i)), which has no list method. A three-partition table holding six rows lost all six, and nothing failed until after the last drop. That is the fourth silent-data-loss defect of this shape in the class in one week.is.atomic()now rejects the column before the first drop. A factor is atomic, so a factor column still routes. -
DBPartitionedTableExtended_v9$initialize()now rejects a partition set that the class cannot use. It ran no check ontable_name_partitionsat all before. The check runs before the class opens its connection, so a rejected construction leaves no database connection open. It rejects four things.- A zero-length set. The table then carried no children, and every write was a silent no-op.
- An
NA. - An empty string after
as.character().self$tables[[""]] <- childmatches no name, so it APPENDS. A set ofc("a", "")built three children for two partitions. - A duplicate after
as.character().self$tables[[name]]takes the first match, so a duplicated partition made one child unreachable whilelength(self$partitions)still counted it.
- The guard now compares the character form of a partition value, not the raw value.
names(self$tables)and all 16self$tables[[...]]sites already usedas.character(), so the character form was already the identity of a partition. One accepted input changes as a result. Anewdatavalue of1.0000000000000002against a partition of1was rejected before, becausesetdiff()compares two doubles exactly. It now routes to the child named “1”. Both values print as1, and the partition column is stored asTEXT. A caller MUST NOT give one table two partition values thatas.character()renders identically.initialize()now rejects that set.
Known limitation
- A child that a caller keeps after
DBPartitionedTableExtended_v9$disconnect()can reopen the shared connection.csdb::DBConnection_v9$autoconnectioncallsconnect()on every access, so any later use of that child opens a new connection. The child cannot close that connection again, because it does not own it. Only the parent closes it. Callers MUST NOT use a child after they disconnect the parent. A caller that does MUST disconnect the parent again.r6_Task.Rcallsdisconnect()on the objects in the task’s table list. A partitioned table is registered there as the parent, so the production teardown path is unaffected.
Licensing
-
DESCRIPTIONno longer carries explicitAuthorandMaintainerfields. R derives both fromAuthors@R, which is the single source. The explicit fields named “Core Surveillance” as the copyright holder, whileAuthors@Rnames Folkehelseinstituttet. The 2026-08-06 sweep correctedAuthors@Rand left the free text behind, so the two disagreed on the legal entity.
Development
-
tests/testthat/test-shared-connection.Ris new. It builds a three-partition table against a temporary SQLite file, and it asserts five things.- The three children hold one connection object, counted by
data.table::address(). - A write reaches the partition that its
partcolumn names. -
disconnect()on the parent closes the shared connection exactly once. The test counts the closes. - A
disconnect()loop over every child leaves the partitioned table usable. - A child kept past the parent’s
disconnect()reopens the shared connection, and only the parent closes it again.
- The three children hold one connection object, counted by
-
tests/testthat/test-partition-routing.Ris new. It builds a three-partition table against a temporary SQLite file, and it asserts three things. The file holds 35 assertions, and 29 of them fail when the routing defect is present.-
drop_all_rows_and_then_upsert_data()sends every row to the partition that itspartcolumn names. The three partitions receive 2, 1 and 3 rows, so a row that reaches the wrong partition changes a count. -
drop_all_rows_and_then_upsert_data()anddrop_all_rows_and_then_insert_data()write the same rows to the same partitions. -
drop_all_rows_and_then_upsert_data()empties a partition thatnewdatadoes not name.
-
-
tests/testthat/test-partition-safety.Ris new. It holds 21 tests and 178 assertions against a temporary SQLite file. The duplicatedpartitions_in_useline has no test, because its removal changes nothing that a test can observe. The whole cs9 suite now reports 298 passing assertions, up from 256.- The first test is the one that matters. It writes two sentinel rows into each of three partitions, calls
drop_all_rows_and_then_upsert_data()with anewdatathat lacks the partition column, and then re-reads every partition. Before the fix it reported no error and zero rows in all three partitions. An error alone would not prove that the rows survived. - A zero-row
newdatathat carries the partition column still clears every partition. - A table with a
value_generator_partitionroutes every row, and all four write methods leave the caller’s data.table unchanged. - A single numeric partition numbered 9 gives
partitions_randomized == 9. Under seed 5 the oldsample()returned 2. - A table with partitions
c(5L, 7L)holds exactly two children, named “5” and “7”, and a row routes to the child its value names. - A
newdatathat names exactly one numeric partition reaches that partition. This covers the secondsample()call site, which is insideinsert_data(). -
pt$indexesholds the list the constructor received. - A factor partition column against numeric partitions routes every row, and every row survives. This is the case the adversarial review named. The test pins the measured behaviour, so a later change cannot regress it in silence.
- A list partition column is rejected, and all six sentinel rows survive.
- The constructor rejects each of the four unusable partition sets. Each error message names the problem.
-
check_for_correct_partitions_in_data()returns a character vector whose length isnrow(newdata). The test reaches private throughpt$.__enclos_env__$private, and it is the only test that does.
- The first test is the one that matters. It writes two sentinel rows into each of three partitions, calls
Version 26.8.6
Licensing
- The copyright holder is now Folkehelseinstituttet. It read “Core Surveillance”, which names the package family rather than a legal entity.
-
DESCRIPTIONAuthors@Rnow declares that holder withrole = "cph". It declared no copyright holder at all, and neither did any other package in the fleet. Nothing inR CMD checkreports that. - The copyright year is now 2026. It read 2025.
-
CLAUDE.mdnow carries a Licensing section, so the year gets checked rather than silently ageing.
Documentation
- Repository prose rewritten to ASD-STE100 (Simplified Technical English). Every sentence in the roxygen blocks, the five vignettes,
README.md,index.mdandNEWS.mdis now at most 25 words. 35 sentences were over that limit before, counted over the whole repository. They divide into 2 in roxygen, 15 in the vignettes, 16 inNEWS.mdand 2 inindex.md. The vignette count of 15 covers both the built.Rmdfiles and their.Rmd.origsources. No claim, number, condition or attribution changed. The NorSySS figures, the 106-diseases and 378-locations counts, and the White & Valcarcel Salamanca attribution are unaltered. - Long sentences that buried a sequence are split into one idea each. Three places stated three conditions in a single sentence and now state one per sentence. They are the
SET ROLE ""trap invignette("backends"), the same trap invignette("installation"), and thecsdbversion-floor entry inNEWS.md. - RFC-2119 keywords are capitalised where the vignettes and roxygen state an obligation.
CS9_PATHMUST NOT be empty.CS9_DBCONFIG_ACCESSMUST includeconfig. The function named bydata_selector_fn_nameMUST return a named list. - Roxygen field, parameter and return descriptions now end in a full stop. The
TaskJobmethod summary is a list. It was an indented block, which Rd collapsed into one paragraph. - Vignette prose edits were applied identically to each precompiled
.Rmdand its.Rmd.origsource.vignettes/_PRECOMPILER.Rtherefore still reproduces the.Rmdfrom the.orig.
Version 26.8.5
New Features
-
CS9_DBCONFIG_DRIVER=SQLiteis accepted, matched case-insensitively. SQLite is a file rather than a server.CS9_DBCONFIG_SERVERandCS9_DBCONFIG_PORTtherefore moved out of the always-required tier ofcheck_environment_setup(). Only the server-based drivers now require them. A SQLite environment needsCS9_AUTO,CS9_PATH,CS9_DBCONFIG_ACCESS,CS9_DBCONFIG_DRIVERand oneCS9_DBCONFIG_DB_<ACCESS>file path per access. It needs noCS9_DBCONFIG_USER, noCS9_DBCONFIG_PASSWORDand noCS9_DBCONFIG_SCHEMA_*. -
check_environment_setup()now rejects aCS9_DBCONFIG_ACCESSlist that omitsconfig. The four configuration tables are built from that access unconditionally, so its absence used to fail later and obscurely. -
reload_db_config()— new export, no arguments. Re-reads everyCS9_DBCONFIG_*variable and rebuilds the configuration tables. A package that sets its own values in.onLoad()needs this.cs9is a dependency, loads first, and reads the environment before that package runs. The reload is state-safe. It disconnects every table it replaces, so a repeated reload does not leak connections. It also emptiesconfig$tablesbefore it rebuilds them. A reload against an invalid environment therefore leaves an empty table list, not tables describing the previous configuration.
Bug Fixes
-
DESCRIPTIONrequirescsdb (>= 2026.8.5)and carriesRemotes: niphr/csdb. The barecsdbit had before let a resolver satisfy the dependency with any version. CRAN and RSPM serve version 2026.5.13, which predates csdb’s SQLite backend. Installed against that,CS9_DBCONFIG_DRIVER=SQLitematched no branch in csdb and fell through to the generic ODBC arm.$connect()then failed withCan't open lib 'SQLite' : file not found, which names neither csdb nor a version. csdb 2026.8.5 is on GitHub and not on CRAN, so the floor alone would leave the dependency unsatisfiable. TheRemotesfield is what makes it obtainable.cs9is not submitted to CRAN, so the field costs nothing. - Two blocks in
tests/testthat/test-sqlite-config.Rnow callskip_if_not_installed("csdb", "2026.8.5"). This is not redundant with the version floor. Nothing enforces anImportsversion after installation.cs9reachescsdbthroughcsdb::alone, soNAMESPACEholds no import directive. R therefore runs no version check at load time. Measured on 2026-08-05 against csdb 2026.5.13,R CMD INSTALLexits 0 andlibrary(cs9)succeeds. The guard is keyed on the version and on nothing else, so it cannot hide a failure that is not a version mismatch. -
setup_database_tables()now builds all four tables into a local list and assignsconfig$tablesonce, at the end. Assigning each table directly meant a failure in the third constructor left a partially-populatedconfig$tablesbehind, indistinguishable from a complete one. - Under SQLite, a dbconfig’s
idis the database file path rather than[db].[schema]. A partitioned table’s per-partition name uses thexxpxxseparator that PostgreSQL uses.PARTITIONis a keyword in SQLite’s window-function grammar.
Documentation
- The installation vignette starts on SQLite. A reader now installs
cs9, writes six settings into.Renviron, validates them and then opens the database, all before the vignette mentions a server. The last step is deliberate.check_environment_setup()only checks the variables. It takes a$connect()on a configuration table to create the SQLite file and theconfig_logtable in it. The PostgreSQL-in-Docker guide follows below. - The installation vignette no longer says CS9 requires PostgreSQL for full functionality, which stopped being true when the SQLite backend landed. It says PostgreSQL is the production backend and points at the SQLite section for the alternative.
-
vignette("backends")— new. It puts theCS9_DBCONFIG_*environments for PostgreSQL and SQLite side by side. It has a table of what each backend does with every variable. It gives the tierscheck_environment_setup()validates in, and the.onLoad()pattern that sets the variables from another package and then callsreload_db_config(). It carries no R-level detail:vignette("backends", package = "csdb")is the companion for that. - Both vignettes now warn about two traps that cost nothing to avoid and are hard to diagnose. An empty
CS9_PATHcounts as a missing variable, soCS9_PATH=fails validation. And an unsetCS9_DBCONFIG_ROLE_CREATE_TABLEreachescsdbas""rather thanNULL.""is not the"x"no-role sentinel, so the PostgreSQLcreate_tablecan emitSET ROLE "". Both PostgreSQL blocks now set the variable explicitly. - The PostgreSQL
.Renvironblock in the installation vignette carried both of those traps, and had done so since before the SQLite work. It wroteCS9_PATH=with no value. The variable table below it calledCS9_PATH“usually empty”.CS9_DBCONFIG_ROLE_CREATE_TABLEwas absent. A reader who copied the block gotMissing required environment variables: CS9_PATH. All three are fixed. -
README.mdnames both backends and links to the two vignettes. -
_pkgdown.ymlindexes all five vignettes.cs9andbackendswere missing from thearticles:list;cs9had been missing independently of this work, although the navbar links to it.
Version 26.8.4
Documentation
- Updated introduction vignette with single-instance design principle, NorSySS case study, and comparative analysis table
- Updated task creation vignette with single-instance framing
- Added Apache Airflow integration guidance to installation vignette
- Updated CLAUDE.md with surveillance-domain examples from academic paper (White & Valcarcel Salamanca, NIPH)
- Introduction vignette now states when CS9 is not the right choice. It names the hard requirements (PostgreSQL, containers, systems administration capability) and the workflow reorganisation adoption costs. It names
plnras the simpler option for a pilot or a resource-constrained setting. - Introduction vignette now states that CS9 analyses each stratum independently by default. It also states that borrowing strength across strata is a decision about the statistical method, not a property of the framework. Adjusting exceedance probabilities for multiple comparisons is the same kind of decision. With 106 diseases across 378 locations the number of simultaneous tests is large. Nothing in CS9 controls the family-wise error rate or the false discovery rate for you.
- Introduction vignette now lists what the framework handles: per-analysis structured logging, schema validation and time-period partitioning, framework-level parallelism, and validation workflows
Version 26.5.13
New Features
-
TaskJobR6 class andrun_task_sequentially_as_callr_bg_using_load_all()wrapper. A drop-in alternative torun_task_sequentially_as_rstudio_job_using_load_all()that works in editors without RStudio’s job API (notably Positron, which does not implementrunScriptJob). Spawns the task in a freshcallr::r_bg()process, so the current environment is not polluted. Captures output via a pipe. Streams the output back to the calling R console (prefixed with the task name) vialater::later()polling. Includes$start(),$wait(),$is_alive(),$status(),$tail(),$kill().
Version 25.7.31
New Features
- Enhanced environment variable validation with detailed diagnostic function
check_environment_setup() - Improved graceful degradation for CRAN compatibility - package loads with limited functionality when database infrastructure is not available
- Context-aware environment variable validation with clear user guidance
- Robust error handling in package loading process
Improvements
- Updated system environment configuration handling
- Comprehensive startup messages guide users through configuration issues
- Enhanced database connection error handling
- Improved package loading sequence with better error isolation
Documentation
- Comprehensive installation vignette explaining infrastructure requirements
- Enhanced function documentation with CRAN-compatible examples
- Clear guidance on functionality available in different deployment scenarios
- Added comprehensive package-level documentation (
?cs9) - Enhanced
check_environment_setup()documentation with detailed examples - Updated vignettes with CRAN vs. full setup guidance
CRAN Preparation
- Removed fhiplot dependency (replaced with standard R functions)
- Fixed non-portable file names in vignettes directory
- Cleaned up build artifacts and hidden files
- Updated LICENSE file copyright year to 2025
- Created vignette precompiler system for maintainable documentation
- Verified graceful degradation in minimal environments
Version 2025.2.24
New Features
- Added
get_config_log()function to retrieve configuration log entries from theconfig_logtable.- Supports optional filtering by surveillance system (
ss), task name (task), and date range (start_date,end_date). - Returns a
data.tablewith the filtered entries.
- Supports optional filtering by surveillance system (
Improvements
- Updated
update_config_log()to also route custom messages (...) to themessage()function for clearer console output.
Version 2025.2.21
-
Added
update_config_logfunction. Logs configuration updates including surveillance system (ss), task name (task), and a custommessage.
Version 2024.6.17
- When running in parallel, a seed is set according to the index of the first analysis in each plan.
Version 2024.3.7
- Including confirm_insert_via_nrow in DBtables. Checks nrow() before insert and after insert. If nrow() has not increased sufficiently, then attempt an upsert.
Version 2023.5.3
- In R 4.3.0
as.character(lubridate::now())adds microseconds, which breaks the SQL upload. This is now replaced bycstime::now_c().
Version 2023.4.13
-
DBPartitionedTableExtended_v9$info()bug fixed with argumentcollapse=TRUE. - Inclusion of
confirm_indexesinDBPartitionedTableExtended_v9.
Version 2023.4.12
-
DBPartitionedTableExtended_v9$nrow()now has a new argumentcollapse=FALSEthat provides partion-specific results -
DBPartitionedTableExtended_v9$info()now includes sizes in MB
Version 2023.4.2
- Extension of
DBPartitionedTableExtended_v9so that it is easier to use multiple partitioning variables.
Version 2023.4.1
- Inclusion of
partitions_randomizedinDBPartitionedTableExtended_v9so that when running in parallel, the database tables don’t get locked. - Inclusion of
remove_tableinDBPartitionedTableExtended_v9 - Fixed an error in RAM calculation in parallel for
get_config_tasks_stats
Version 2023.3.31
-
DBTableExtended_v9now automatically includes a column for all tables, calledauto_last_updated_datetime, which is automatically calculated each time that row is changed. - Creation of
DBPartitionedTableExtended_v9, which allows for one dataset to be partitioned amongst multiple SQL tables automatically.
Version 2023.3.8
-
SurveillanceSystem_v9constructor now takes in an argument calledimplementation_version, which can be used to identify what version of analytics code is currently being run. -
update_config_last_updatedhas now been replaced byupdate_config_tables_last_updated(which contains when the tables were last updated) andconfig_tasks_stats(which contains all the runtimes of the tasks). -
SurveillanceSystem_v9now uses an internal R6 classDBTableExtended_v9(which extendscsdb::DBTable_v9) instead of usingcsdb::DBTable_v9directly.DBTableExtended_v9callsupdate_config_last_updatedafter altering a database table.
Version 8.0.2
- Allows for multiple databases to be used for different access levels.
-
copy_into_new_table_wherenow also copies indexes. - V8 schemas now have a nice print function.
- V8 redirects now have a nice print function.
-
copy_into_new_table_whereuses tablock. -
upsert_at_end_of_each_planandinsert_at_end_of_each_plancan now take named lists as the return value from theaction_fn. - Custom progressr handler.
Version 8.0.1
- When using
sc8::add_task_from_config_v8the schema list is checked to make sure they are actually schemas. This will solve the issue where people incorrectly add non-existent schemas to the task. -
insert_data,upsert_data,drop_all_rows_and_then_insert_dataare now the recommended ways of inserting data addin_load_production- schemas now use
load_folder_fn, which should dynamically check if a user has permission to write to a folder, solving permissions errors - Including
tm_get_schema_names - Both
granularity_timeANDgranularity_geoare now included in db censors - Requires R >= 4.1.0
-
sc8::config$plan_attempt_indexnow exists. When running plans in parallel, if a plan fails it is retried five times. This lets a user track which attempt they are on. This is mostly useful so that emails and smses are only sent whensc8::config$plan_attempt_index==1 - (Disabled) TABLOCK is disabled right now due to issues where data would not be uploaded.
- (Disabled) Data is sorted before sending it to bcp to speed up in/upserts.
Version 8.0.0
- Release of schema redirects that allow for restricted and anonymous datasets to be seamlessly used by people with different access rights
- Consistent naming of
task_from_config_v8andadd_schema_v8
Version 7.1.4
-
db_insert_data,db_upsert_data,db_drop_all_rows_and_then_upsert_dataare now the recommended ways of inserting data
Version 7.1.3
-
update_config_datetimeandget_config_datetimenow automatically record database table updates as well
Version 7.1.0
-
task_from_config_v3sets a new direction for creation of tasks and management of tasks -
describe_tasksanddescribe_schemashelp with automatic documentation
Version 7.0.8
-
task_inline_v1allows for easy inline task creation - Corresponding RStudio addin for inline tasks that copy from one db table to another
Version 7.0.7
-
copy_into_new_table_whereallows for the creation of a new table from an old table - Including
task_from_config_v2 - First RStudio addin
