Skip to content

Database Packaging - #1121

Open
isc-dchui wants to merge 35 commits into
mainfrom
db-packaging
Open

isc-dchui wants to merge 35 commits into
mainfrom
db-packaging

Conversation

@isc-dchui

@isc-dchui isc-dchui commented Apr 13, 2026

Copy link
Copy Markdown
Collaborator

Description

Resolves #986

Overview

Packaging workflow

Running package-database <module> (or publish-database <module>) forces the %IPM.Lifecycle.Database lifecycle class and routes to the Package (or Publish) phase. The full pipeline inside %Package:

  1. Validate IPM is externally mapped — %IPM.Main.CLS must not live in the namespace's local routines DB, since that DB is about to be dismounted. Also validates the routines DB is not mirrored (dismounting a mirrored DB breaks the mirror).
  2. Export Generated=true resources from the source namespace to temp XML files before remapping. These are classes whose compiled form exists in the source DB but whose generator does not run during standard %Reload/%Compile — for example, a class produced by an external tool. Once the old DB is dismounted, compiled generated classes are inaccessible. Resources that do regenerate during packaging (e.g. BPL/DTL produce a .1.cls subclass during compilation) are exported and re-imported harmlessly — the import is a no-op since the class already exists in the temp DB by then.
  3. Create a fresh empty temp database and remap the namespace's routines to it (or use the existing routines DB as-is with -use-current-db). The remap happens before dismounting the old DB to avoid any window where the namespace has no mounted routines DB.
  4. Load and compile this module's resources plus all transitive dependencies into the temp DB, so the resulting IRIS.DAT is self-contained.
  5. Restore the namespace to its original routines DB (dismount temp, remap back, remount original), then import the previously exported generated resources.
  6. Assemble a staging directory: IRIS.DAT, an enriched module.xml (with <Packaging>database</Packaging> and a SHA-256 <Checksum>), a deps/ directory of per-dependency IRIS export XML files, and any non-compiled resources (wheels, FileCopy, CPF). Create the .tgz from the staging directory.

If an error occurs after the remap (step 3), a safety net in %Package restores the namespace before propagating the error.

For OCI registries, database packages are tagged as <version>_database__<IRIS-major>.<IRIS-minor> and the manifest carries a com.intersystems.ipm.packaging=database annotation. Source packages use existing tag formats with no annotation (backwards compatible — absent annotation means source).

When publishing, Base.%Publish calls ..Package(.pParams, 0) first (populating ..Payload), then uploads to the registry. The lifecycle class's PACKAGING parameter determines the tag format, not the module's stored Packaging property (which reflects how the module was loaded and may be stale).

Installation workflow

From a registry (install/update): the -swap-db modifier controls which packaging type is resolved:

  • Without -swap-db: only source-packaged modules are fetched. If none exist but a database package does, the user is told to re-run with -swap-db.
  • With -swap-db: only database-packaged modules are fetched. Dependencies are always resolved as source packages regardless of the top-level flag, since a database package already embeds its dependencies in the IRIS.DAT.

From a local path (load -path <tgz>): no packaging filter is applied. load passes all command data (including SwapDB if -swap-db was given) through to the lifecycle. Without -swap-db, DoDatabaseInstall prompts the user for confirmation before proceeding.

IsInstallContext() returns true when module.Packaging = "database" and IRIS.DAT is present in the module root. When true, %Reload routes to DoDatabaseInstall, which:

  1. Validates the package: IRIS.DAT present, module.xml declares database packaging, deps/ directory exists, SHA-256 checksum matches.
  2. Checks that no other database-packaged module is already installed in the namespace (only one database package per namespace is allowed).
  3. Checks that no already-installed module has code in the local routines DB that would be destroyed by the swap. Modules packaged inside this database package, modules whose code is mapped from another database, and modules with no routines-DB code are exempt. Modules the previous version carried but the incoming version drops are also exempt — their code leaves with the old DAT — and are collected for cleanup in later steps. Any other blocking module must be uninstalled first.
  4. Validates IPM is externally mapped and the routines DB is not mirrored.
  5. Prompts for confirmation if -swap-db was not passed. The prompt lists any modules that will be removed by the swap.
  6. Unconfigures dropped modules (web applications, copied files, installer class side effects) while their code is still mounted.
  7. Performs the swap: dismount existing routines DB → rename current IRIS.DAT to IRIS_<dbname>_<timestamp>.DAT as a backup → rename packaged IRIS.DAT into place → remount. Renames are atomic and avoid copying large files. On failure after the swap starts, RollbackDatabaseSwap attempts to restore the backup (best-effort, never throws — called from a catch block).
  8. Registers dependency metadata from the deps/ manifests via $system.OBJ.Load. The code is already in the swapped-in DB; only IPM metadata records need registering.
  9. Removes IPM records for dropped modules (now that the swap has succeeded and their code is gone).
  10. Seeds all update steps as already-run on a fresh install, so a subsequent update only runs steps introduced after the installed version. Skipped when params("Update")=1.

Because all install work completes inside %Reload, %Validate, %Compile, and %Activate are no-ops during install (they check params("IsInstallContext")). Invokes fire naturally after %Reload returns — the DB is mounted by then.

Non-compiled resources (FileCopy, WebApplication, CPF) are handled by resource processor hooks at the Module level. They run via OnBeforePhase/OnAfterPhase independently of the %Activate override.

Notable details

  • package-source command added. Explicit alias for the existing package behavior. Useful for clarity when both packaging types are in play.
  • Lifecycle auto-derived from <Packaging>. Storage.Module calls Base.GetBaseClassForPackaging to derive the correct lifecycle class from module.xml's <Packaging> value rather than rejecting a mismatch.
  • Python deps default on for package-database. Unlike package (which defaults off), package-database defaults ExportPythonDependencies=1. Both requirements.txt-based wheels and explicit <PythonWheel> resources are staged into the .tgz. A missing wheels directory only warns if a requirements.txt exists — a module with no Python requirements legitimately has no wheels directory.
  • XSLT checksum injection. InjectDatabasePackagingTransform (XData) injects <Packaging> and <Checksum> into module.xml. The checksum placeholder REPLACECHECKSUM is substituted in ObjectScript before the stylesheet runs — XSLT 1.0 has no parameterized element content.
  • deps/ is a directory, not a single file. Each dependency gets its own IRIS export XML, one per transitive dependency, loadable with $system.OBJ.Load.

Testing

All tests are in Test.PM.Integration.DatabasePackaging. The test infrastructure uses two shared lazy-initialized namespaces to avoid ~15 namespace create/teardown cycles per run:

  • SharedNS1 (TESTDBPKGNS1): simple-db-module, dep-module, main-with-deps, module-with-tests, module-with-invokes; also has the zot ORAS registry configured
  • SharedNS2 (TESTDBPKGNS2): all-resources-module, module-with-requirements, module-with-mixed-python, module-with-non-compiled-resources

Per-test install namespaces are created fresh and torn down in OnAfterOneTest.

Test What it covers
TestSimplePackageAndInstall Package → verify IRIS.DAT + module.xml content (Packaging, checksum, SystemRequirements version) → install with load -swap-db → classes callable, module in list and history
TestPackageAndInstallWithDependencies Package dep-module + main-with-deps; install main-with-deps → main class exists, dep appears in list
TestPackageAndInstallAllResourceTypes Smoke test: module with class, include file, generated class, WebApp, FileCopy, PythonWheel packages and installs; include constant accessible, generated class callable, WebApp created
TestPackageAndInstallWithTestResources -include-test-resources includes Scope=test (TestHelper) and UnitTest (Test) classes; default packaging excludes both
TestPackagingAndInstallValidation Valid install succeeds; tampered IRIS.DAT (checksum mismatch) and missing deps/ directory each fail validation
TestUpgradeSourceToDatabase Source-installed module upgraded via update -path -swap-db; Packaging changes to "database"; backup confirms DB swap occurred
TestUninstallDatabasePackage Module removed from list; backup preserved; compiled classes gone from namespace
TestMixedPackaging Database + source package coexist in same namespace; second database package in same namespace is rejected
TestInstallRejectedWhenModulesWouldBeOrphaned Source module with classes in the routines DB blocks install; uninstalling it clears the way; modules with no routines-DB code and packaged dependencies are exempt
TestUpdateWithChangedDependencyTree v1→v2 upgrade with a reshaped dependency tree: dropped dep's metadata cleaned up, new dep registered, re-parented and new transitive deps handled correctly; upgrade not blocked by orphan check
TestPythonDependencyPackaging module-with-mixed-python: explicit PythonWheel (lune) + requirements.txt wheel (pycparser) both included by default, excluded with -export-python-deps 0; lune importable after install. module-with-requirements: wheel included by default, excluded with flag
TestInvokeBehaviorDuringDatabaseInstall <Invoke After="Compile"> skipped during database install; <Invoke After="Activate"> runs; global markers confirm each
TestNonCompiledResourcesAppliedDuringInstall CPF file staged in package; FileCopy target deleted before install, confirmed recreated after install — proves resource processor hooks fire independently of %Activate override
TestUpdateStepsWithDatabasePackaging v1 fresh install seeds Step001 (not run); v2 upgrade runs Step002 once; Step001 still not run; backup count > 1 confirms second swap
TestPublishDatabaseWithORAS publish-database + publish to zot; source install (no -swap-db) → Packaging=module; database install (-swap-db) → Packaging=database; main-with-deps install resolves dep as source; after unpublishing source tag, install without -swap-db fails with hint to use -swap-db
TestSystemRequirementsOverwrite package-database overwrites a stale IRIS version in <SystemRequirements> with the current version
TestPackagingDerivesLifecycleClass Module with Packaging=database and no explicit LifecycleClass derives %IPM.Lifecycle.Database on validation
TestRollbackPreservesOriginalWhenNoBackup Rollback invoked when no backup exists leaves original IRIS.DAT untouched
TestChecksumMatchesShippedDat Checksum embedded in module.xml equals SHA-256 of the IRIS.DAT that ships in the .tgz (exercises -use-current-db copy path)
TestSHA256HexFormat ComputeSHA256Hex produces a 64-character lowercase hex string

Not Yet Handled

  • Mirroring (not supported on either side — both packaging and install reject mirrored DBs)

Dependency Lifecycle Support (Commit a8733ae and onwards)

Problem

When installing a database-packaged module, dependency code is compiled into the IRIS.DAT at packaging time and arrives already mounted after the swap. But the dependency modules' lifecycle phases (Activate, CPF merges, etc.) never ran, so dependencies with resources like CPF entries, Python wheels, or data files had their code present but none of their install-time setup applied.

Dependency Tree (example)

main-module          ← root (the package being installed)
├── parent-dep
│   ├── leaf-dep-A
│   └── leaf-dep-B

Topo (leaf-first) order: leaf-dep-A → leaf-dep-B → parent-dep → main-module.

Phase Execution: Root vs Dependencies

Each column is a module; rows are execution time (top = earlier). Dependency phases run in topological (leaf-first) order.

         │   Root               │   leaf-dep-A         │   leaf-dep-B         │   parent-dep
─────────┼──────────────────────┼──────────────────────┼──────────────────────┼──────────────────────
PRE-SWAP │   Initialize [1]     │                      │                      │
─────────┴──────────────────────┴──────────────────────┴──────────────────────┴──────────────────────
                                    PerformDatabaseSwap (Reload)
─────────┬──────────────────────┬──────────────────────┬──────────────────────┬──────────────────────
POST-    │                      │   Initialize         │                      │
SWAP     │                      │   Validate           │                      │
         │                      │   Activate           │                      │
         │                      │   ApplyUpdateSteps[2]│                      │
         │                      │                      │   Initialize         │
         │                      │                      │   Validate           │
         │                      │                      │   Activate           │
         │                      │                      │   ApplyUpdateSteps[2]│
         │                      │                      │                      │   Initialize
         │                      │                      │                      │   Validate
         │                      │                      │                      │   Activate
         │                      │                      │                      │   ApplyUpdateSteps[2]
         │   ReassertInit [1']  │                      │                      │
         │   Initialize         │                      │                      │
         │   Validate           │                      │                      │
         │   Activate           │                      │                      │
         │   ApplyUpdateSteps[2]│                      │                      │

[1] — Root's Initialize fires pre-swap against the old DB. Dep Initialize runs post-swap because their code is already in the mounted IRIS.DAT.

[1'] — ReassertInit re-runs root's Initialize-phase resources (CPF merges, Python wheels) after all deps so root's versions land last. Deps' Initialize runs post-swap and writes into the same global state root already set pre-swap; re-running root's restores the intended ordering.

[2] — ApplyUpdateSteps runs per-module only on updates (zpm "update"). On a fresh install, Activate seeds all update steps as already-run so a future update only executes steps added after this version.

Dep phases are derived from the canonical load/install chain ending at Activate (or ApplyUpdateSteps on updates), with Reload, Compile, and * excluded because dep code is already compiled into IRIS.DAT.

Rollback Timing

All steps below execute inside %Reload via DoDatabaseInstall. The swap is the point of no return: after it commits, rollback is not attempted and failures are reported forward.

Step                          On failure
──────────────────────────────────────────────────────────────────────────────
ValidateBeforeSwap            abort, nothing changed

UnconfigureStaleModules       abort + warn: web apps, copied files, and
                              installer-class effects for stale modules are
                              already removed; IRIS.DAT not yet touched
                                    │
                                    ▼
                        ┌─── PerformDatabaseSwap ───────────────────────────┐
  failure inside swap ──┤   RollbackDatabaseSwap restores old IRIS.DAT      │
                        │   and re-registers old DB with IRIS                │
                        │   NOTE: UnconfigureStaleModules effects are NOT    │
                        │   reversed — those modules are now partially       │
                        │   installed; reinstall them to recover             │
                        └───────────────────────────────────────────────────┘
                                    │
                              swap committed
                         ══════════════════════ POINT OF NO RETURN
                                    │
RegisterDependencyMetadata    report error, stop — dep metadata may be
                              partially written; namespace has new DB mounted

ApplyDependencyResources      report error, stop — some deps may be fully
                              configured, others not

ReassertRootInitializeResources  report error, stop — root Initialize
                                 resources partially applied

RemoveStaleModuleMetadata     report error, stop — stale module records
                              may still exist in 'list' output

Why the boundary sits where it does:

  • UnconfigureStaleModules side effects (web app deletions, file removals) have no clean undo, but IRIS.DAT is untouched so a re-run from scratch is still possible.
  • PerformDatabaseSwap is a filesystem rename, so RollbackDatabaseSwap can reverse it. This is the last step with a clean undo path.
  • Post-swap steps are not rolled back because rolling back would discard a working swap and pair old code with new configuration. Post-swap failures are typically fixable by re-running the install against the already-mounted code.
  • Subtlety: module metadata is in globals inside the transaction, so a post-swap failure rolls it back automatically while the IRIS.DAT rename stays. list will then show the previous version's metadata against the newly mounted code. Re-run the install to fix this; do not uninstall first, as it would delete resources by the old version's names.

Other Notable Changes

Generated resources extended to deps. ExportGeneratedResources now iterates all transitive dependencies via GetSelfAndDependencyModules(), not just the root module. Dep generated resources become inaccessible after the namespace routines DB is remapped, so they must be exported before the remap alongside root's.

Ensemble opt-in for test namespaces. %IPM.Test.Utils.CreateNamespace previously ensemble-enabled every test namespace unconditionally, adding setup cost even when tests don't need it. The method now accepts pEnableEnsemble (default 0). When set, it passes pFromInstall=1 to suppress HS-specific global mappings that would otherwise point ^IRIS.Msg at HSLIB (read-only on IRIS for Health), then explicitly maps ^IRIS.Msg/^IRIS.MsgNames to the namespace's own database. DeleteNamespace was also fixed to skip ensemble secondary and temp databases when the namespace was never ensemble-enabled.

Warnings at packaging time. Two methods fire during %Package to surface problems early:

  • WarnUnrunnableInvokes — flags <Invoke> elements on phases that never execute during a database install (root: Initialize and Reload.Before; deps: the excluded phases Reload, Compile, *).
  • WarnMappingsNotApplied — flags <Mapping> elements in any module. Mappings are applied by Base.%Reload, which this path bypasses. Applying them inline would deadlock against a concurrent <CPF> merge because Config.Map* locks are held until the install transaction commits, and iris merge cannot wait that out.

Packaged-manifest guard. %Reload now rejects a source load from a package directory whose IRIS.DAT was already moved by a prior swap, rather than silently compiling source into the swapped-in database.

New tests. Integration tests added: TestDependencyResourcesAppliedDuringInstall (CPF, wheel, and data file resources on a dep are applied post-swap), TestNonCompiledResourcesAppliedDuringInstall (FileCopy and web app resources), TestInvokeBehaviorDuringDatabaseInstall (invokes fire at the right phases), and TestUpdateStepsWithDatabasePackaging (update steps run per-module on upgrade). New test fixtures dep-with-resources and main-with-resource-deps support these. Unit tests in Test.PM.Unit.LifecycleDatabase cover topological ordering, phase list correctness (excluded phases, canonical chain coverage), packaged-manifest detection, and SHA-256 format.

Checklist

  • This branch has the latest changes from the main branch rebased or merged.
  • Changelog entry added.
  • Unit (zpm test -only) and integration tests (zpm verify -only) pass.
  • Style matches the style guide in the contributing guide.
  • Documentation has been/will be updated
    • Source controlled docs, e.g. README.md, should be included in this PR and Wiki changes should be made after this PR is merged (add an extra issue for this if needed)
  • Pull request correctly renders in the "Preview" tab.

@isc-jili isc-jili left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great @isc-dchui ! I left some comments!

Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread tests/integration_tests/Test/PM/Integration/DatabasePackaging.cls Outdated
Comment thread tests/integration_tests/Test/PM/Integration/DatabasePackaging.cls Outdated
Comment thread tests/integration_tests/Test/PM/Integration/DatabasePackaging.cls Outdated
Comment thread tests/integration_tests/Test/PM/Integration/DatabasePackaging.cls
Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Database.cls
Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Database.cls
@isc-dchui
isc-dchui force-pushed the db-packaging branch 2 times, most recently from bba26d8 to 216223e Compare June 1, 2026 19:58
Comment thread CHANGELOG.md Outdated
Comment thread .github/workflows/main.yml
Comment thread src/cls/IPM/Main.cls Outdated
Comment thread src/cls/IPM/Main.cls Outdated
Comment thread src/cls/IPM/Main.cls Outdated
// Identical to "package": route to the Package phase using current lifecycle
set tCommandInfo = "package"
do ..RunOnePhase(.tCommandInfo)
} elseif (tCommandInfo = "package-database") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@isc-dchui I remember that adding package-** commands was part of your original design, but I can't remember why we went with that instead of flags on the existing package command so that it continues to be considered a module-action rather than separate commands. I'm not sure I have a strong preference either way, design-wise, but I'm noticing that the flow through the Shell is different.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because of the lifecycle phases. A separate command allows it to dispatch to the %IPM.Lifecycle.Database class directly

Comment thread src/cls/IPM/Lifecycle/Base.cls Outdated

@isc-kiyer isc-kiyer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@isc-dchui few small notes. I love the description of the MR! Could you add some more information for each of the "Key Details"? In particular, for the following:

  • Can you explain why generated resources need special treatment? When packaging, wouldn't they be in the routine db already?
  • What is the reason for exporting the module manifests and reimporting them? Could that not be accomplished by setting up a global mapping/copying over the IPM globals from the globals db to the routine db during packaging? Then its one less step at deployment time.

// then it should have a package mapping of this specific resource to its namespace database
$$$ThrowOnError(..OnConfigureMappings(.pParams))
}
if ..ResourceReference.Generated {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this removed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's just dead code. If it's generated, it gets marked as handled at the bottom of the method anyway.

Comment thread src/cls/IPM/Main.cls Outdated
Comment thread src/cls/IPM/Main.cls
<description>
Creates an IRIS.DAT database package bundled in a .tgz containing IRIS.DAT, module.xml (with SHA-256 checksum), and dependencies.xml.
</description>
<parameter name="module" required="true" description="Name of module to package as database" />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should indicate that this module should not be a dependent of any other module (something we should check and throw an error for). We should also probably check for DB packaging that every module other than this one has at least 1 dependent because if not, you are packaging things that aren't intended.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out this we don't need this constraint since database-packaging will iterate through the specified module's resources (and dependencies), so any module that depends on this one will simply be ignored.

Comment thread src/cls/IPM/Storage/InvokeReference.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated

if useCurrentDB {
// -use-current-db: use the existing routines DB directly, no remapping
// Note: globals mapping is unchanged throughout — IPM metadata stays in globals DB

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be better to have IPM metadata be put into routine db? Or I guess we achieve this by exporting the module manifests and loading them into the ns after mounting the IRIS.DAT on install from db packaged?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the export/import is the mechanism and it avoids some trickiness around namespace/database mappings and possibly conflicting IPM data

Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
{
new $namespace
set $namespace = "%SYS"
$$$ThrowOnError(##class(Config.Namespaces).Get(ns, .nsProps))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: instead of needing a ns switch, we can use ##class(%SYS.Namespace).GetAllNSInfo()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but we only need it for one namespace so seemed unnecessary to get it for each


// get metadata from annotations
set metadata = ..GetPackageMetadata(..Location, name, tag, "", client)
if (metadata = "") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the metadata value if the tag doesn't exist in the tag list? So that it's not equal to ""

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetAllTags() won't return it so that tag won't be part of the while loop.

  set allTagsString = ..GetAllTags(..Location, name, "", client)
  set allVersionsList = ..AggregatePlatformVersions($listfromstring(allTagsString, ", "), .aggregatedPlatformVersion)
  set pointer = 0
  while $listnext(allVersionsList,pointer,moduleVersion) {
     ...
  }

Comment thread src/cls/IPM/Utils/Module.cls Outdated
@isc-dchui

Copy link
Copy Markdown
Collaborator Author

@isc-dchui few small notes. I love the description of the MR! Could you add some more information for each of the "Key Details"? In particular, for the following:

  • Can you explain why generated resources need special treatment? When packaging, wouldn't they be in the routine db already?
  • What is the reason for exporting the module manifests and reimporting them? Could that not be accomplished by setting up a global mapping/copying over the IPM globals from the globals db to the routine db during packaging? Then its one less step at deployment time.

@isc-kiyer Updated the description with a little more organization clarity! To explicitly answer these,

  1. Standard generated resources like BPL and DTL are fine without special handling, but anything that is special and generated outside of the standard lifecycle phases won't be picked up. So this is just redundancy for that. Maybe it's a case of YAGNI though and overkill.
  2. Yeah, but exporting/re-importing turns out to be simpler than setting up and managing the global mappings.

@isc-jili isc-jili left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! @isc-dchui One more Q: are all the resources as part of transitive dependencies supposed to be packaged up too? Since ExportGeneratedResources() CopyNonCompiledResources() and ExportPythonDependencies() seem to be invoked on the root module only
A test case along those lines that would be nice also would be having a dependency contain a Generated="1" resource or a requirements.txt

Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Database.cls
Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Database.cls Outdated
Comment thread src/cls/IPM/Lifecycle/Base.cls
Comment thread src/cls/IPM/Utils/Module.cls Outdated
pVersion As %String,
pDeployed As %Boolean,
pPlatformVersion As %String,
pIPMPackaging As %String = "",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would adding it as param #6 mean that uses of this method would break?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because currently param 6 is pParams and param 7 is pDedendencyGraph

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes should add to the end of the method for backwards compatibility

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does look like all calls to this method are accounted for however, so nothing within IPM would break

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and as for our uses of IPM, we never call this method directly, there are other methods that wrap around this one that tend to be more user-entry-point friendly

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I initially had it at the end but @isc-jlechtne suggested I move it so I did and made sure all its callers were also updated. It seems pretty unlikely that there would be an external caller of this method directly so it seemed safe enough, but also happy to revert it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm taking a step back, if we are making a backwards incompatible change here, I propose just passing in the full module reference (as the method name suggests) instead of just passing specific properties. That also ensures any new stuff added to the module ref in future doesn't require changing the signature here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed LoadModuleReference to take a %IPM.Storage.QualifiedModuleInfo oref as the first argument, replacing the various properties

@isc-dchui

Copy link
Copy Markdown
Collaborator Author

Fixed the issue where, on install, dependencies wouldn't run through their lifecycle phases. Details are in a new section of the description.

}
// Nothing usable was written: an empty export means the resource matched no compiled
// classes, which is expected for resources this namespace never generated.
if ##class(%Library.File).Exists(tempFile) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This seems like it belongs as another another elseif clause above

do ..StageResourceFile(resource.Name, sourcePath, destPath)

} elseif exportPythonDeps && resource.Processor.%IsA("%IPM.ResourceProcessor.PythonWheel") {
set wheelDir = resource.Processor.Directory

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (Inconsistent): xDir and xName have opposite order in when they were set in this clause vs the next CPF one.

continue
}

kill children

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yikes 🫣

if $$$ISERR(tSC) {
quit
}
// Map ^IRIS.Msg to the namespace's own database so LOC compilation can write message domains.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my understanding, why is this section needed for DB packaging?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got the same question!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not actually. This was from my trying to reduce the time it takes to run all the integration tests as enabling ensemble for every new namespace is unnecessary work most of the time. However, when it is needed, we somehow need these mappings for it to work.

Comment thread CHANGELOG.md

### Added
- #1117: Add `sync` command for incremental loading of changed files in dev-mode modules. Detects modified files since last sync using SHA-1 hash and recompiles only what is stale. Supports `-delete` for processing removed files and `-test` for running changed test-phase unit tests.
- #986: Database packaging: new `package-database` and `publish-database` commands create an IRIS.DAT-based package that installs via swapping of the routines database rather than compilation of source files. Pass `-dev` to include test resources (`Scope="test"` or `Scope="verify"`) in the package, which are excluded by default.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the new command package-source (and maybe package-studio-project as well, though I see that's marked deprecated already) be listed here as well? Or did you decide that wasn't necessary due to their equivalence to the package command?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only listed the new command since package-source is an alias for package and nothing changes for the user there


set sc = ##class(%IPM.Main).Shell("load " _ tamperedPackage _ " -swap-db")
do $$$AssertStatusNotOK(sc, "Tampered IRIS.DAT rejected by checksum validation")
do $$$AssertTrue($system.Status.GetErrorText(sc) [ "SHA-256", "Error mentions SHA-256 checksum mismatch")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're checking for an error substring here, then the call before of checking the status not okay is redundant.

set $namespace = installNS

set sc = ##class(%IPM.Main).Shell("load " _ missingDepsPackage _ " -swap-db")
do $$$AssertStatusNotOK(sc, "Missing deps/ directory rejected by validation")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, no longer need this line


set sc = ##class(%IPM.Main).Shell("load " _ noChecksumPackage _ " -swap-db")
do $$$AssertStatusNotOK(sc, "module.xml without <Checksum> rejected by validation")
do $$$AssertTrue($system.Status.GetErrorText(sc) [ "Checksum", "Error mentions the missing <Checksum> element")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, don't need to check that there was an error if we're also making a check for the error text

// Attempt to install second database package should fail
set sc = ##class(%IPM.Main).Shell("load " _ dbPackage2 _ " -swap-db")
do $$$AssertStatusNotOK(sc, "Cannot install second database package in same namespace")
do $$$AssertTrue($system.Status.GetErrorText(sc) [ "Only one database package", "Error mentions one-package-per-namespace constraint")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, no need to check that there was an error if we're also making a check for the error text

// --- Cleanup ---
// Unpublish all remaining versions of dep-module and main-with-deps from zot
set $namespace = packagingNS
set sc = ##class(%IPM.Main).Shell("unpublish zot/dep-module all -f")

@isc-jlechtne isc-jlechtne Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need to set a status here since it's never used

@isc-jlechtne isc-jlechtne left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great! Left some comments

Comment thread src/cls/IPM/Main.cls

<command name="package-database" dataPrefix="D">
<description>
Creates an IRIS.DAT database package bundled in a .tgz containing IRIS.DAT, module.xml (with SHA-256 checksum), and a deps/ directory of dependency manifests.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (correct me if I'm wrong): add "and non-compiled resource files" after "... directory of dependency manifests"

Comment thread src/cls/IPM/Main.cls
Comment thread src/cls/IPM/Main.cls
Comment thread src/cls/IPM/Lifecycle/Database.cls
Comment thread src/cls/IPM/Lifecycle/Database.cls
}

/// Runs %Reload and %Compile in ns (which maps to the temp DB) to load and compile resources into it.
Method LoadResourcesIntoDatabase(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitty naming nit: technically we're loading and compiling resources into a namespace (which has certain default routine and globals databases) so can we rename this method to LoadAndCompileResources() and omit the "Database" part?

@isc-cborbonm isc-cborbonm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a few questions (just for my own understanding) 😃

Comment thread src/cls/IPM/Lifecycle/Database.cls
Comment thread src/cls/IPM/Lifecycle/Database.cls
Comment thread src/cls/IPM/Lifecycle/Database.cls
set ptr = 0
while $listnext(generatedExports, ptr, tempFile) {
if ##class(%Library.File).Exists(tempFile) {
do ##class(%Library.File).Delete(tempFile)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In which cases would we have remaining temp files? and would it cause an issue if they were never imported / should it be logged?

Comment thread src/cls/IPM/Lifecycle/Database.cls
if $$$ISERR(tSC) {
quit
}
// Map ^IRIS.Msg to the namespace's own database so LOC compilation can write message domains.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got the same question!

// =====================================================================

/// Returns 1 if the module is database-packaged and IRIS.DAT is present in the module root.
/// Both conditions are required: Packaging alone could mean a source load with a Database lifecycle.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does it mean for a module to have a Database lifecycle?

set declared($zconvert(depResource.Name, "L")) = ""
}

set destWheelDir = ##class(%Library.File).NormalizeDirectory(..GetDependencyStagingDir(stagingDir, depModule.Name) _ "wheels")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the call to NormalizeDirectory() needed given that GetDependencyStaginDir() also normalizes the stagingDir directory?

// Test: Cannot install second database package in same namespace
set $namespace = installNS

// Attempt to install second database package should fail

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is it only possible to install one database package in a namespace?

@isc-eneil isc-eneil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great @isc-dchui ! I did a proper first round of review on the installation and packaging logic. Will look at tests next.

set sc = ex.AsStatus()
}

// Safety net: restore namespace if an error occurred mid-remap

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of moving the two chunks of safety net code (to restore NS and clean up generated resources) up to within the catch {...}? Because they execute conditionally, it's technically not required, but they are intended to execute only in the case that an error is thrown in the try {...} so it feels a bit more logical to me.

ClassMethod ValidateIPMNotInRoutinesDB()
{
if ##class(%Dictionary.ClassDefinition).%ExistsId("%IPM.Main") && '##class(%Library.RoutineMgr).IsMapped("%IPM.Main.CLS") {
$$$ThrowStatus($$$ERROR($$$GeneralError,"IPM must be mapped from another namespace. %IPM classes were found in the current routines database. Map IPM from a non-system code namespace before packaging."))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: be a bit more specific in the steps required of the user at this point so the path to correct this error is clear. Technically they'd need to uninstall IPM in the current namespace, install it in another, switch to that namespace, then map it to the packaging namespace using zpm "enable ", right?

Comment thread src/cls/IPM/Lifecycle/Database.cls
quit deps
}

/// Returns this module followed by its installed transitive dependencies.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this method belongs in %IPM.Lifecycle.Base.cls, not database-specific

set generatedExports = ""
}

write:verbose !, "Restoring namespace routines..."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: add "database" to the end of this write


/// Records the processor class of each resource that acts during the Initialize phase.
/// CPF is phase-configurable, so only count it when it actually targets Initialize.
ClassMethod CollectInitializePhaseClasses(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add argument definition to document the intended format of initClasses

if (resource.Processor.CustomPhase '= "") || (resource.Processor.Phase '= "Initialize") {
continue
}
write !, "Re-applying CPF merge '", resource.Name, "' — a dependency merged a CPF after this module's Initialize phase."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit on wording: "Re-merging Configuration Parameter File (CPF)..."

set ptr = 0
while $listnext(staleModules, ptr, moduleName) {
continue:moduleName=""
if '##class(%IPM.Storage.Module).NameExists(moduleName) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you additionally need to delete the %IPM.Storage.Module object associated with moduleName?


/// Best-effort rollback after a failed swap. Never throws; all failures are logged.
/// Restores the backup IRIS.DAT if one exists; otherwise leaves the original in place.
Method RollbackDatabaseSwap(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add method argument definitions to the method docs

} elseif dbDir '= "" {
write !, "ERROR: encountered an error: ", $system.Status.GetErrorText(sc)
write !, "Attempting to roll back system state..."
do ..RollbackDatabaseSwap(dbDir, backupPath)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like how you wrote the rollback logic!

@isc-tleavitt isc-tleavitt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall: very well tested, lots of good comments from others on details.

My main concerns: making sure testing is sufficient for our cases. It would be interesting to try it out on some more beefy + complex packages - thinking e.g. Plaza or Payer Services - as part of validation. I think that could happen most simply after merge / before release, expecting we might need some iteration on this feature in future PRs that don't last for 5 months.

I also want to keep an eye on extensibility for packaging - this change doesn't preclude or complicate future introduction of more packaging modes so that's good enough. (To go further, there'd be the option to add inversion of control via a class parameter + projection or something for packaging type monikers, which get mapped to a classname.) Curious if HS agrees on the "plugin" use case as a future thing. This would be a good level to make decisions about mappings - e.g. we create a separate -PLUGINS DB and auto-update/recompile plugins after installing an underlying DB package. (Would like to discuss on Monday.)

Two other high level questions:

  • Where does the IPM package metadata live for a DB package? Added into data DB when package is installed? (I think so, just want to confirm.)
  • It should be really easy to say from namespace A "install a DB package in new namespace B after creating it with a new proper set of databases" - maybe I'm just missing that, but an explicit callout would be nice. This matters more for the general case and less for HS as it'd need to go through existing installer patterns for you.

/// is called for "*", and no resource processor hooks or <Invoke> elements target it.
///
/// Should be kept in sync with %IPM.Lifecycle.Base.PHASES.
Parameter VALUELIST = ",Clean,Initialize,Reload,*,Validate,ExportData,Compile,Activate,Document,MakeDeployed,Test,Package,Verify,Publish,Configure,Unconfigure,ApplyUpdateSteps";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like Sync was dropped on merge? (or out of date?)

// Verify WebApplication was created
new $namespace
set $namespace = "%SYS"
set webAppExists = ##class(Security.Applications).Exists("/allresources")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this web app namespace-relative (a common pattern) and also assert that the files exist there?

Comment thread src/cls/IPM/Main.cls
@@ -133,7 +133,8 @@ This command is an alias for `module-action module-name test`

<command name="package" dataPrefix="D">

@isc-tleavitt isc-tleavitt Sep 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm undecided on overloading the package command vs. splitting out the package-source/database/studio-project. Since the modifiers are different I think this is OK.

Part of the original intent with packaging was to mirror Maven's packaging capabilities, thinking we might eventually have packaging modes other than module/database (originally application)/studio-project - e.g. I could imagine "plugin" (sits on top of a database package and fits into update flow) or "pod" (we drive overlaying the package on an IRIS docker image, drive docker build process, and publish as a docker image - maybe a silly idea I know, that really fits in external CI, but could help with simplicity / reducing boilerplate).

Here's a Gemini summary of Maven packaging:

The default packaging type in Apache Maven is , which is automatically used if no tag is specified in your . [1, 2]
Core Maven Packaging Types
Maven defines several standard core packaging values that dictate how a project builds and packages its output:

• jar: Packages the project as a standard Java Archive file.
• war: Packages the project as a Web Application Archive for servlet containers.
• pom: Acts as a Project Object Model container; it does not produce a binary artifact but manages dependencies and multi-module aggregators.
• maven-plugin: Packages the project as a custom Maven plugin.
• ear: Packages multiple Java EE modules into an Enterprise Archive.
• ejb: Packages enterprise beans into an Enterprise JavaBean file.
• rar: Packages resource adapter archives for Java EE connectors. [3, 4, 5]

Custom Packaging
You can extend Maven with custom packaging types (such as for OSGi or for Tycho) by declaring extensions and configuring the appropriate builder plugin in your project lifecycle. [6, 7, 8]
If you'd like, let me know:What kind of application or component you are buildingWhether you need help configuring a multi-module POM or a custom pluginI can provide the exact configuration for your project.
AI responses may include mistakes.

[1] https://maven.apache.org/pom.html
[2] https://maven.apache.org/guides/introduction/introduction-to-the-pom.html
[3] https://stackoverflow.com/questions/5544019/what-are-all-the-default-maven-packing-types
[4] https://maven.apache.org/ref/4.0.0-beta-4/api/maven-api-core/apidocs/org/apache/maven/api/Packaging.html
[5] https://maven.apache.org/plugins/
[6] https://tycho.eclipseprojects.io/doc/main/PackagingTypes.html
[7] https://stackoverflow.com/questions/74315779/what-mean-packagingbundle-mean-inside-the-pom
[8] https://stackoverflow.com/questions/1427722/how-do-i-create-a-new-packaging-type-for-maven

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for database packaging

7 participants