diff --git a/ToDo.adoc b/ToDo.adoc index 416c9c74ea..2ea99b392f 100644 --- a/ToDo.adoc +++ b/ToDo.adoc @@ -1,6 +1,7 @@ Aktuell: - Scripting: Ergebnis Unresolved reference 'memo', 'todo'.: line 94 to 94 (add only activated plugins) -- JsonValidatorTest anpassen. +- Zeitberichte: kost2.nummer:4.* dauert sehr lang, auch für kurze Zeiträume. +- Healthcheck (daily) with mail notification on errors. Mit Registrierung von Services (jcr, orderbook snaphosts etc.) - Groovy-scripts: remove or fix. - AG-Grid: setColumnStates wird nicht in den UserPrefs gespeichert. - Wicket: Auftragsbuch: org.apache.wicket.core.request.mapper.StalePageException: A request to page '[Page class = org.projectforge.web.fibu.AuftragEditPage, id = 9, render count = 3]' has been made with stale 'renderCount'. The page will be re-rendered. diff --git a/projectforge-application/src/main/resources/i18nKeys.json b/projectforge-application/src/main/resources/i18nKeys.json index df93f0b96b..86997bc8af 100644 --- a/projectforge-application/src/main/resources/i18nKeys.json +++ b/projectforge-application/src/main/resources/i18nKeys.json @@ -714,7 +714,7 @@ {"i18nKey":"currencyConverter.percentage.help","bundleName":"I18nResources","translation":"You can enter amounts as well as percent values (e. g. 10%).","translationDE":"Es können sowohl Beträge als auch Prozentzahlen (z. B. 10%) eingegeben werden.","usedInClasses":["org.projectforge.web.fibu.RechnungCostEditTablePanel"],"usedInFiles":[]}, {"i18nKey":"currencyFormat","bundleName":"I18nResources","translation":"{0,number,,##0.00}","translationDE":"{0,number,,##0.00}","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"datatable.no-records-found","bundleName":"I18nResources","translation":"No records found.","translationDE":"Keine Einträge gefunden.","usedInClasses":[],"usedInFiles":[]}, - {"i18nKey":"date","bundleName":"I18nResources","translation":"date","translationDE":"Datum","usedInClasses":["org.projectforge.business.book.BookDO","org.projectforge.business.fibu.OrderExport","org.projectforge.business.fibu.datev.EmployeeSalaryExportDao","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.fibu.orderbookstorage.OrderbookStorageDO","org.projectforge.business.fibu.orderbookstorage.OrderbookStorageService","org.projectforge.business.orga.ContractDO","org.projectforge.business.orga.ContractDao","org.projectforge.business.orga.PostausgangDO","org.projectforge.business.orga.PosteingangDO","org.projectforge.business.scripting.ScriptParameterType","org.projectforge.business.vacation.model.LeaveAccountEntryDO","org.projectforge.business.vacation.repository.LeaveAccountEntryDao","org.projectforge.carddav.CardDavServerDebugWriter","org.projectforge.framework.persistence.database.json.DatabaseWriter","org.projectforge.plugins.banking.BankAccountRecordPagesRest","org.projectforge.plugins.liquidityplanning.LiquidityForecastCashFlow","org.projectforge.rest.VacationAccountPageRest","org.projectforge.rest.hr.LeaveAccountEntryPagesRest","org.projectforge.rest.orga.ContractPagesRest","org.projectforge.rest.orga.VisitorbookPagesRest","org.projectforge.web.fibu.AccountingRecordEditForm","org.projectforge.web.fibu.DatevImportStoragePanel","org.projectforge.web.wicket.I18nParamMap","org.projectforge.web.wicket.WebConstants","org.projectforge.web.wicket.components.DateTimePanel"],"usedInFiles":["./plugins/org.projectforge.plugins.datatransfer/src/main/resources/mail/dataTransferMail.html","./projectforge-wicket/src/main/java/org/projectforge/web/wicket/components/DateTimePanel.html"]}, + {"i18nKey":"date","bundleName":"I18nResources","translation":"date","translationDE":"Datum","usedInClasses":["org.projectforge.business.book.BookDO","org.projectforge.business.fibu.OrderExport","org.projectforge.business.fibu.datev.EmployeeSalaryExportDao","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.fibu.orderbooksnapshots.OrderbookSnapshotDO","org.projectforge.business.fibu.orderbooksnapshots.OrderbookSnapshotsService","org.projectforge.business.orga.ContractDO","org.projectforge.business.orga.ContractDao","org.projectforge.business.orga.PostausgangDO","org.projectforge.business.orga.PosteingangDO","org.projectforge.business.scripting.ScriptParameterType","org.projectforge.business.vacation.model.LeaveAccountEntryDO","org.projectforge.business.vacation.repository.LeaveAccountEntryDao","org.projectforge.carddav.CardDavServerDebugWriter","org.projectforge.framework.persistence.database.json.DatabaseWriter","org.projectforge.plugins.banking.BankAccountRecordPagesRest","org.projectforge.plugins.liquidityplanning.LiquidityForecastCashFlow","org.projectforge.rest.VacationAccountPageRest","org.projectforge.rest.hr.LeaveAccountEntryPagesRest","org.projectforge.rest.orga.ContractPagesRest","org.projectforge.rest.orga.VisitorbookPagesRest","org.projectforge.web.fibu.AccountingRecordEditForm","org.projectforge.web.fibu.DatevImportStoragePanel","org.projectforge.web.wicket.I18nParamMap","org.projectforge.web.wicket.WebConstants","org.projectforge.web.wicket.components.DateTimePanel"],"usedInFiles":["./plugins/org.projectforge.plugins.datatransfer/src/main/resources/mail/dataTransferMail.html","./projectforge-wicket/src/main/java/org/projectforge/web/wicket/components/DateTimePanel.html"]}, {"i18nKey":"date.begin","bundleName":"I18nResources","translation":"Start date","translationDE":"Beginndatum","usedInClasses":["org.projectforge.rest.core.AbstractPagesRest","org.projectforge.rest.scripting.AbstractScriptExecutePageRest"],"usedInFiles":[]}, {"i18nKey":"date.end","bundleName":"I18nResources","translation":"End date","translationDE":"Endedatum","usedInClasses":["org.projectforge.rest.core.AbstractPagesRest","org.projectforge.rest.scripting.AbstractScriptExecutePageRest"],"usedInFiles":[]}, {"i18nKey":"date.from","bundleName":"I18nResources","translation":"from","translationDE":"von","usedInClasses":[],"usedInFiles":[]}, @@ -2210,8 +2210,8 @@ {"i18nKey":"system.admin.alertMessage.copyAndPaste.title","bundleName":"I18nResources","translation":"For copy & paste","translationDE":"For copy & paste","usedInClasses":["org.projectforge.web.admin.AdminForm"],"usedInFiles":[]}, {"i18nKey":"system.admin.button.checkI18nProperties","bundleName":"I18nResources","translation":"Check i18n properties","translationDE":"Check i18n properties","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, {"i18nKey":"system.admin.button.checkI18nProperties.tooltip","bundleName":"I18nResources","translation":"Check i18n properties for detecting missing translations in different languages.","translationDE":"Check i18n properties for detecting missing translations in different languages.","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, - {"i18nKey":"system.admin.button.checkJCRSanity","bundleName":"I18nResources","translation":"JCR sanity check","translationDE":"JCR sanity check","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, - {"i18nKey":"system.admin.button.checkJCRSanity.tooltip","bundleName":"I18nResources","translation":"Checks the whole document repository (JCR) by comparing check sums and file sizes.","translationDE":"Checks the whole document repository (JCR) by comparing check sums and file sizes.","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, + {"i18nKey":"system.admin.button.checkJCRSanity","bundleName":"I18nResources","translation":"JCR sanity check","translationDE":"JCR sanity check","usedInClasses":[],"usedInFiles":[]}, + {"i18nKey":"system.admin.button.checkJCRSanity.tooltip","bundleName":"I18nResources","translation":"Checks the whole document repository (JCR) by comparing check sums and file sizes.","translationDE":"Checks the whole document repository (JCR) by comparing check sums and file sizes.","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"system.admin.button.checkSystemIntegrity","bundleName":"I18nResources","translation":"Check system integrity","translationDE":"Check system integrity","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, {"i18nKey":"system.admin.button.checkSystemIntegrity.tooltip","bundleName":"I18nResources","translation":"Some basic checks are done (are there orphaned structure elements in the system?).","translationDE":"Some basic checks are done (are there orphaned structure elements in the system?).","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, {"i18nKey":"system.admin.button.clearAlertMessage","bundleName":"I18nResources","translation":"Clear alert message","translationDE":"Clear alert message","usedInClasses":[],"usedInFiles":[]}, @@ -2247,7 +2247,6 @@ {"i18nKey":"system.admin.group.title.misc.logEntries","bundleName":"I18nResources","translation":"Format log entries","translationDE":"Format log entries","usedInClasses":["org.projectforge.web.admin.AdminForm"],"usedInFiles":[]}, {"i18nKey":"system.admin.group.title.systemChecksAndFunctionality.caches","bundleName":"I18nResources","translation":"Caches","translationDE":"Caches","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, {"i18nKey":"system.admin.group.title.systemChecksAndFunctionality.configuration","bundleName":"I18nResources","translation":"Configuration","translationDE":"Configuration","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, - {"i18nKey":"system.admin.group.title.systemChecksAndFunctionality.miscChecks","bundleName":"I18nResources","translation":"Misc checks","translationDE":"Misc checks","usedInClasses":["org.projectforge.web.admin.AdminPage"],"usedInFiles":[]}, {"i18nKey":"system.admin.logViewer.autoRefresh","bundleName":"I18nResources","translation":"Auto refresh","translationDE":"Auto-Aktualisierung","usedInClasses":["org.projectforge.rest.admin.LogViewFilter"],"usedInFiles":[]}, {"i18nKey":"system.admin.logViewer.level","bundleName":"I18nResources","translation":"Loglevel","translationDE":"Log-Level","usedInClasses":["org.projectforge.common.logging.LoggingEventData","org.projectforge.rest.admin.LogViewFilter","org.projectforge.rest.admin.LogViewerEvent"],"usedInFiles":[]}, {"i18nKey":"system.admin.logViewer.loggerName","bundleName":"I18nResources","translation":"Logger name","translationDE":"Quelle","usedInClasses":["org.projectforge.common.logging.LoggingEventData"],"usedInFiles":[]}, diff --git a/projectforge-business/src/main/java/org/projectforge/business/jobs/CronReindexingHourlyJob.java b/projectforge-business/src/main/java/org/projectforge/business/jobs/CronReindexingHourlyJob.java index d931a12707..0deb628f32 100644 --- a/projectforge-business/src/main/java/org/projectforge/business/jobs/CronReindexingHourlyJob.java +++ b/projectforge-business/src/main/java/org/projectforge/business/jobs/CronReindexingHourlyJob.java @@ -47,6 +47,7 @@ public class CronReindexingHourlyJob { //@Scheduled(cron = "${projectforge.cron.hourly}") /** + * TODO: If reindexing of database entries, modified in the last hour, this job should be reactivated. * In ms. */ //@Scheduled(fixedDelay = 3600 * 1000, initialDelay = 120 * 1000) diff --git a/projectforge-business/src/main/java/org/projectforge/business/systeminfo/SystemService.java b/projectforge-business/src/main/java/org/projectforge/business/systeminfo/SystemService.java index f804e93c64..1fcb8988d1 100644 --- a/projectforge-business/src/main/java/org/projectforge/business/systeminfo/SystemService.java +++ b/projectforge-business/src/main/java/org/projectforge/business/systeminfo/SystemService.java @@ -29,8 +29,8 @@ import org.projectforge.business.fibu.KontoCache; import org.projectforge.business.fibu.RechnungCache; import org.projectforge.business.fibu.kost.KostCache; -import org.projectforge.business.jsonRest.RestCallService; -import org.projectforge.business.task.TaskDO; +import org.projectforge.business.jobs.CronSanityCheckJob; +import org.projectforge.jobs.JobListExecutionContext; import org.projectforge.business.task.TaskDao; import org.projectforge.business.task.TaskTree; import org.projectforge.business.user.UserGroupCache; @@ -40,9 +40,6 @@ import java.io.File; import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; /** * Provides some system routines. @@ -51,130 +48,80 @@ */ @Service public class SystemService { - private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(SystemService.class); + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(SystemService.class); - @Autowired - private UserGroupCache userGroupCache; + @Autowired + private UserGroupCache userGroupCache; - @Autowired - private TaskDao taskDao; + @Autowired + private TaskDao taskDao; - @Autowired - private TaskTree taskTree; + @Autowired + private TaskTree taskTree; - @Autowired - private SystemInfoCache systemInfoCache; + @Autowired + private SystemInfoCache systemInfoCache; - @Autowired - private AuftragsCache auftragsCache; + @Autowired + private AuftragsCache auftragsCache; - @Autowired - private RechnungCache rechnungCache; + @Autowired + private CronSanityCheckJob cronSanityCheckJob; - @Autowired - private KontoCache kontoCache; + @Autowired + private RechnungCache rechnungCache; - @Autowired - private KostCache kostCache; + @Autowired + private KontoCache kontoCache; - @Autowired - private RestCallService restCallService; + @Autowired + private KostCache kostCache; - public String exportSchema() { - final SchemaExport exp = new SchemaExport(); - File file; - try { - file = File.createTempFile("projectforge-schema", ".sql"); - } catch (final IOException ex) { - log.error(ex.getMessage(), ex); - return ex.getMessage(); - } - exp.exportSchema(file.getPath()); - String result; - try { - result = FileUtils.readFileToString(file, "UTF-8"); - } catch (final IOException ex) { - log.error(ex.getMessage(), ex); - return ex.getMessage(); - } - file.delete(); - return result; - } - - /** - * Search for abandoned tasks (task outside the task hierarchy, unaccessible and unavailable for the users). - * - * @return - */ - public String checkSystemIntegrity() { - final StringBuilder buf = new StringBuilder(); - buf.append("ProjectForge system integrity check.\n\n"); - buf.append("------------------------------------\n"); - buf.append("| |\n"); - buf.append("| Task integrity (abandoned tasks) |\n"); - buf.append("| |\n"); - buf.append("------------------------------------\n"); - final List tasks = taskDao.selectAll(false); - buf.append("Found " + tasks.size() + " tasks.\n"); - final Map taskMap = new HashMap<>(); - for (final TaskDO task : tasks) { - taskMap.put(task.getId(), task); - } - boolean rootTask = false; - boolean abandonedTasks = false; - for (final TaskDO task : tasks) { - if (task.getParentTask() == null) { - if (rootTask) { - buf.append("\n*** Error: Found another root task:\n " + task + "\n"); - } else { - buf.append("\nFound root task:\n " + task + "\n"); - rootTask = true; - } - } else { - TaskDO ancestor = taskMap.get(task.getParentTaskId()); - boolean rootTaskFound = false; - for (int i = 0; i < 50; i++) { // Max. depth of 50, otherwise cyclic task! - if (ancestor == null) { - break; - } - if (ancestor.getParentTaskId() == null) { - // Root task found, OK. - rootTaskFound = true; - break; - } - ancestor = taskMap.get(ancestor.getParentTaskId()); + public String exportSchema() { + final SchemaExport exp = new SchemaExport(); + File file; + try { + file = File.createTempFile("projectforge-schema", ".sql"); + } catch (final IOException ex) { + log.error(ex.getMessage(), ex); + return ex.getMessage(); } - if (!rootTaskFound) { - buf.append("\n*** Error: Found abandoned task (cyclic tasks without path to root):\n " + task + "\n"); - abandonedTasks = true; - } else { - buf.append('.'); + exp.exportSchema(file.getPath()); + String result; + try { + result = FileUtils.readFileToString(file, "UTF-8"); + } catch (final IOException ex) { + log.error(ex.getMessage(), ex); + return ex.getMessage(); } - } - taskMap.put(task.getId(), task); + file.delete(); + return result; } - if (!abandonedTasks) { - buf.append("\n\nTest OK, no abandoned tasks detected."); - } else { - buf.append("\n\n*** Test FAILED, abandoned tasks detected."); + + /** + * Search for abandoned tasks (task outside the task hierarchy, unaccessible and unavailable for the users). + * + * @return + */ + public String checkSystemIntegrity() { + JobListExecutionContext context = cronSanityCheckJob.execute(); + return context.getReportAsText(); + } + + /** + * Refreshes the caches: TaskTree, userGroupCache and kost2. + * + * @return the name of the refreshed caches. + */ + public String refreshCaches() { + userGroupCache.forceReload(); + taskTree.forceReload(); + kontoCache.forceReload(); + kostCache.forceReload(); + rechnungCache.forceReload(); + auftragsCache.forceReload(); + systemInfoCache.forceReload(); + BirthdayCache.getInstance().forceReload(); + return "UserGroupCache, TaskTree, KontoCache, KostCache, RechnungCache, AuftragsCache, SystemInfoCache, BirthdayCache"; } - return buf.toString(); - } - - /** - * Refreshes the caches: TaskTree, userGroupCache and kost2. - * - * @return the name of the refreshed caches. - */ - public String refreshCaches() { - userGroupCache.forceReload(); - taskTree.forceReload(); - kontoCache.forceReload(); - kostCache.forceReload(); - rechnungCache.forceReload(); - auftragsCache.forceReload(); - systemInfoCache.forceReload(); - BirthdayCache.getInstance().forceReload(); - return "UserGroupCache, TaskTree, KontoCache, KostCache, RechnungCache, AuftragsCache, SystemInfoCache, BirthdayCache"; - } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/Order.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/Order.kt similarity index 98% rename from projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/Order.kt rename to projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/Order.kt index 1f31b899ca..f89451b06b 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/Order.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/Order.kt @@ -21,7 +21,7 @@ // ///////////////////////////////////////////////////////////////////////////// -package org.projectforge.business.fibu.orderbookstorage +package org.projectforge.business.fibu.orderbooksnapshots import org.projectforge.business.fibu.AuftragDO import org.projectforge.business.fibu.AuftragsStatus diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderConverterService.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderConverterService.kt similarity index 98% rename from projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderConverterService.kt rename to projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderConverterService.kt index 238947ffc0..db8b91161a 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderConverterService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderConverterService.kt @@ -21,7 +21,7 @@ // ///////////////////////////////////////////////////////////////////////////// -package org.projectforge.business.fibu.orderbookstorage +package org.projectforge.business.fibu.orderbooksnapshots import mu.KotlinLogging import org.projectforge.business.PfCaches diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderPosition.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderPosition.kt similarity index 97% rename from projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderPosition.kt rename to projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderPosition.kt index 25d28a2e0f..2914e634b1 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderPosition.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderPosition.kt @@ -21,7 +21,7 @@ // ///////////////////////////////////////////////////////////////////////////// -package org.projectforge.business.fibu.orderbookstorage +package org.projectforge.business.fibu.orderbooksnapshots import org.projectforge.business.fibu.* import java.math.BigDecimal diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotDO.kt similarity index 54% rename from projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageDO.kt rename to projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotDO.kt index 03354060a8..3bd9760c7d 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotDO.kt @@ -21,35 +21,52 @@ // ///////////////////////////////////////////////////////////////////////////// -package org.projectforge.business.fibu.orderbookstorage +package org.projectforge.business.fibu.orderbooksnapshots +import com.fasterxml.jackson.annotation.JsonIgnore import jakarta.persistence.* +import org.projectforge.framework.json.JsonUtils import java.time.LocalDate +import java.util.* /** - * SELECT date, octet_length(serialized_orderbook) AS byte_count FROM t_fibu_orderbook_storage; + * SELECT date, created, incremental_based_on, octet_length(serialized_orderbook) AS byte_count, size FROM t_fibu_orderbook_snapshots; * @author Kai Reinhard (k.reinhard@micromata.de) */ @Entity @Table( - name = "t_fibu_orderbook_storage", + name = "t_fibu_orderbook_snapshots", uniqueConstraints = [UniqueConstraint(columnNames = ["date"])], ) @NamedQueries( - NamedQuery(name = OrderbookStorageDO.FIND_META_BY_DATE, query = "select date as date,incrementalBasedOn as incrementalBasedOn from OrderbookStorageDO where date=:date"), - NamedQuery(name = OrderbookStorageDO.SELECT_ALL_METAS, query = "select date as date,incrementalBasedOn as incrementalBasedOn from OrderbookStorageDO"), + NamedQuery( + name = OrderbookSnapshotDO.FIND_META_BY_DATE, + query = "select date as date,incrementalBasedOn as incrementalBasedOn,size as size from OrderbookSnapshotDO where date=:date" + ), + NamedQuery( + name = OrderbookSnapshotDO.SELECT_ALL_METAS, + query = "select date as date,incrementalBasedOn as incrementalBasedOn,size as size from OrderbookSnapshotDO order by date desc" + ), + NamedQuery( + name = OrderbookSnapshotDO.SELECT_ALL_FULLBACKUP_METAS, + query = "select date as date,incrementalBasedOn as incrementalBasedOn,size as size from OrderbookSnapshotDO where incrementalBasedOn is null order by date desc" + ), ) -internal class OrderbookStorageDO { +internal class OrderbookSnapshotDO { @get:Id - @get:Column + @get:Column(nullable = false) var date: LocalDate? = null + @get:Column(nullable = false) + var created: Date? = Date() + /** * Serialized order book. * All orders are serialized as json objects and zipped. */ @get:Column(name = "serialized_orderbook", columnDefinition = "BLOB") @get:Basic(fetch = FetchType.LAZY) // Lazy isn't reliable for byte arrays. + @JsonIgnore var serializedOrderBook: ByteArray? = null /** @@ -58,12 +75,20 @@ internal class OrderbookStorageDO { @get:Column(name = "incremental_based_on") var incrementalBasedOn: LocalDate? = null + @get:Column + var size: Int? = null + @get:Transient val incremental: Boolean get() = incrementalBasedOn != null + override fun toString(): String { + return JsonUtils.toJson(this) + } + companion object { - internal const val FIND_META_BY_DATE = "OrderStorageDO_FindMetaByDate" - internal const val SELECT_ALL_METAS = "OrderStorageDO_SelectAllMetas" + internal const val FIND_META_BY_DATE = "OrderSnapshotsDO_FindMetaByDate" + internal const val SELECT_ALL_METAS = "OrderSnapshotsDO_SelectAllMetas" + internal const val SELECT_ALL_FULLBACKUP_METAS = "OrderSnapshotsDO_SelectAllFullBackupMetas" } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotsSanityCheck.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotsSanityCheck.kt new file mode 100644 index 0000000000..e2d22bd34f --- /dev/null +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotsSanityCheck.kt @@ -0,0 +1,54 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2024 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.business.fibu.orderbooksnapshots + +import org.projectforge.common.extensions.format +import org.projectforge.common.extensions.formatBytes +import org.projectforge.jobs.AbstractJob +import org.projectforge.jobs.JobExecutionContext + +class OrderbookSnapshotsSanityCheck(val orderbookSnapshotsService: OrderbookSnapshotsService) : + AbstractJob("Checks the recent order book' snapshots.") { + override fun execute(jobContext: JobExecutionContext) { + val entries = orderbookSnapshotsService.selectMetas() + val fullSnapshots = entries.count { it.incrementalBasedOn == null } + val incrementalSnapshots = entries.count { it.incrementalBasedOn != null } + val totalSize = entries.sumOf { it.size ?: 0 } + jobContext.addMessage("Found ${entries.size} order book snapshots: total-size=${totalSize.formatBytes()}, full=${fullSnapshots.format()}, incremental=${incrementalSnapshots.format()}.") + // Test all last 10 snapshots: + entries.take(10).forEach { + val date = it.date + if (date == null) { + jobContext.addError("Date is null for entry: ${it}") + return@forEach + } + try { + orderbookSnapshotsService.readSnapshot(date) + jobContext.addMessage("Snapshot for date $date (${it.size.formatBytes()}) is readable.") + } catch (e: Exception) { + jobContext.addError("Error reading snapshot for date $date: $e") + } + } + } +} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotsService.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotsService.kt new file mode 100644 index 0000000000..1174c586a3 --- /dev/null +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookSnapshotsService.kt @@ -0,0 +1,291 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2024 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.business.fibu.orderbooksnapshots + +import com.fasterxml.jackson.core.type.TypeReference +import jakarta.annotation.PostConstruct +import jakarta.persistence.Tuple +import mu.KotlinLogging +import org.projectforge.Constants +import org.projectforge.business.fibu.AuftragDO +import org.projectforge.business.fibu.AuftragDao +import org.projectforge.business.jobs.CronSanityCheckJob +import org.projectforge.framework.json.JsonUtils +import org.projectforge.framework.persistence.database.TupleUtils +import org.projectforge.framework.persistence.jpa.PfPersistenceService +import org.projectforge.framework.time.PFDateTimeUtils +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.time.LocalDate +import java.util.* +import java.util.zip.Deflater +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +private val log = KotlinLogging.logger {} + +@Service +class OrderbookSnapshotsService { + class SerializedSnapshot(val count: Int, val gzBytes: ByteArray?, var date: LocalDate = LocalDate.now()) + + @Autowired + private lateinit var auftragDao: AuftragDao + + @Autowired + private lateinit var orderConverterService: OrderConverterService + + @Autowired + private lateinit var persistenceService: PfPersistenceService + + @Autowired + private lateinit var cronSanityCheckJob: CronSanityCheckJob + + @PostConstruct + private fun postConstruct() { + OrderbookSnapshotsSanityCheck(this).let { + cronSanityCheckJob.registerJob(it) + } + } + + /** + * Creates daily snapshots of the order book at the beginning of a day (after midnight). + * This jobs runs hourly and check, if a snapshot for today exists. If not, it will be created. + * For the beginning of a new month, a full backup is created. All other backups are incremental. + */ + @Scheduled(fixedDelay = 1 * Constants.MILLIS_PER_HOUR, initialDelay = 2 * Constants.MILLIS_PER_MINUTE) + fun createDailySnapshots() { + try { + log.info { "Checking daily snapshots..." } + persistenceService.runInNewTransaction(recordCallStats = true) { context -> + val today = LocalDate.now() + val entry = findEntry(today) + if (entry != null) { + log.info { "Order book for today already exists. OK, nothing to do." } + return@runInNewTransaction + } + var incrementalBasedOn: LocalDate? = null + if (today.dayOfMonth != 1) { + // For the first day of month, full backup is created. So handle all other days as incremental: + // Find the last full backup: + selectRecentFullBackup()?.let { + log.debug { "Found recent full backup: ${it.date}" } + it.date?.let { snapshotDate -> + if (snapshotDate.month == today.month) { + log.info { "Full backup found in the current month: ${it.date}, so store an incremental snapshot..." } + try { + // Checking for sanity: + readSnapshot(snapshotDate) + incrementalBasedOn = it.date + } catch (e: Exception) { + log.error { "Recent full backup seems to be corrupted. Creating a full backup again: ${e.message}" } + } + } + } + } + } + storeOrderbookSnapshot(incrementalBasedOn = incrementalBasedOn, date = today) + log.info { "Checking daily snapshots done. ${context.formatStats()}" } + } + } catch (e: Exception) { + log.error(e) { "Error in createDailySnapshots: ${e.message}" } + } + } + + /** + * Creates a snapshot of the current order book's state. + * @param incrementalBasedOn if given, this entry contains only the orders which were modified after this date. + * @param returnGZipBytes if true, the gzipped bytes are returned. + * @return the date and the number of stored orders. + */ + @JvmOverloads + fun createOrderbookSnapshot(incrementalBasedOn: LocalDate? = null): SerializedSnapshot { + if (incrementalBasedOn != null) { + log.info { "Creating incremental order book snapshot based on $incrementalBasedOn..." } + } else { + log.info { "Creating full order book snapshot..." } + } + // First, select all orders that are not deleted: + val auftragList = auftragDao.select(deleted = false, checkAccess = false) + val incrementList = if (incrementalBasedOn != null) { + val basedOn = selectMeta(incrementalBasedOn) + if (basedOn == null) { + log.error { "No order book found, which based on given date: $incrementalBasedOn. Falling back to full backup." } + auftragList + } else { + val basedOnDate = PFDateTimeUtils.getBeginOfDateAsUtildate(incrementalBasedOn) + // Filter all orders that were modified at or after the given date: + auftragList.filter { (it.lastUpdate ?: Date()) >= basedOnDate } + } + } else { + auftragList + } + log.info { "Converting ${incrementList.size}/${auftragList.size} orders..." } + val orderbook = orderConverterService.convertFromAuftragDO(incrementList) + if (orderbook.isNullOrEmpty()) { + log.warn { "No orders found to store!!!" } + return SerializedSnapshot(count = 0, gzBytes = null) + } + val count = orderbook.size + log.info { "Converting ${incrementList.size}/${auftragList.size} orders to json..." } + val json = JsonUtils.toJson(orderbook, ignoreNullableProps = true) + log.info { "Zipping ${incrementList.size} orders..." } + val gzBytes = gzip(json) + return SerializedSnapshot(count = count, gzBytes = gzBytes) + } + + /** + * If today's order book snapshot is already stored, nothing will be done. + * @param date the date of the order book's snapshot (default is today). This is for testing purposes only. + */ + internal fun storeOrderbookSnapshot( + incrementalBasedOn: LocalDate? = null, + date: LocalDate = LocalDate.now(), + ): SerializedSnapshot { + val rawSnapshot = createOrderbookSnapshot(incrementalBasedOn) + rawSnapshot.date = date + // Store the order book in the database: + OrderbookSnapshotDO().also { + it.date = date + it.serializedOrderBook = rawSnapshot.gzBytes + it.size = it.serializedOrderBook?.size + it.incrementalBasedOn = incrementalBasedOn + }.let { + persistenceService.runInTransaction { context -> + val entry = selectMeta(date) + if (entry != null) { + entry.serializedOrderBook = it.serializedOrderBook + context.em.merge(entry) + } else { + context.em.persist(it) + } + } + } + log.info { "Storing order book done." } + return rawSnapshot + } + + fun readSnapshot(date: LocalDate): List? { + val orderbook = mutableMapOf() + readSnapshot(date, orderbook) + return orderConverterService.convertFromOrder(orderbook.values) + } + + private fun readSnapshot(date: LocalDate, orderbook: MutableMap) { + val entry = findEntry(date) + if (entry == null) { + log.error { "No order book found for date: $date" } + return + } + if (entry.incremental) { + log.info { "Restoring order book for date $date (incremental based on ${entry.incrementalBasedOn})..." } + } else { + log.info { "Restoring order book for date $date..." } + } + entry.incrementalBasedOn?.let { incrementalBasedOn -> + log.info { "Restoring order book from previous backup first: date=$date..." } + if (date <= incrementalBasedOn) { + log.error { "Internal error: Incremental based on date is greater than the date of the order book: $incrementalBasedOn > $date" } + } + // Load order book from the incremental based on date first: + readSnapshot(incrementalBasedOn, orderbook) + } + val serialized = entry.serializedOrderBook ?: return + readSnapshot(serialized, orderbook) + } + + private fun readSnapshot(serialized: ByteArray, orderbook: MutableMap) { + val json = gunzip(serialized) + JsonUtils.fromJson(json, object : TypeReference?>() {})?.forEach { order -> + order?.id?.let { id -> + orderbook[id] = order + } + } + } + + private fun selectRecentFullBackup(): OrderbookSnapshotDO? { + // selectMetas is already sorted by date descending, but to be sure: + return selectMetas(onlyFullBackups = true).sortedByDescending { it.date }.firstOrNull() + } + + private fun selectMeta(date: LocalDate): OrderbookSnapshotDO? { + return persistenceService.selectNamedSingleResult( + OrderbookSnapshotDO.FIND_META_BY_DATE, + Tuple::class.java, + "date" to date, + )?.let { + OrderbookSnapshotDO().also { result -> + result.date = TupleUtils.getLocalDate(it, "date") + result.incrementalBasedOn = TupleUtils.getLocalDate(it, "incrementalBasedOn") + result.size = TupleUtils.getInt(it, "size") + } + } + } + + internal fun selectMetas(onlyFullBackups: Boolean = false): List { + val named = + if (onlyFullBackups) OrderbookSnapshotDO.SELECT_ALL_FULLBACKUP_METAS else OrderbookSnapshotDO.SELECT_ALL_METAS + val res = persistenceService.executeNamedQuery( + named, + Tuple::class.java, + ).map { + OrderbookSnapshotDO().also { result -> + result.date = TupleUtils.getLocalDate(it, "date") + result.incrementalBasedOn = TupleUtils.getLocalDate(it, "incrementalBasedOn") + result.size = TupleUtils.getInt(it, "size") + } + } + return if (onlyFullBackups) { + res.filter { it.incrementalBasedOn == null } // incrementalBasedOn == null means full backup (double check) + } else { + return res + } + } + + private fun findEntry(date: LocalDate): OrderbookSnapshotDO? { + return persistenceService.find(OrderbookSnapshotDO::class.java, date) + } + + private fun gzip(str: String): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + val gzipStream = object : GZIPOutputStream(byteArrayOutputStream) { + init { + def.setLevel(Deflater.BEST_COMPRESSION) + } + } + gzipStream.use { gzipStream -> + gzipStream.write(str.toByteArray(Charsets.UTF_8)) + } + return byteArrayOutputStream.toByteArray() + } + + fun gunzip(compressed: ByteArray): String { + val byteArrayInputStream = ByteArrayInputStream(compressed) + GZIPInputStream(byteArrayInputStream).use { gzipStream -> + return gzipStream.readBytes().toString(Charsets.UTF_8) + } + } +} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/PaymentSchedule.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/PaymentSchedule.kt similarity index 94% rename from projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/PaymentSchedule.kt rename to projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/PaymentSchedule.kt index ad267ec599..6af3d3bdba 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/PaymentSchedule.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbooksnapshots/PaymentSchedule.kt @@ -21,10 +21,9 @@ // ///////////////////////////////////////////////////////////////////////////// -package org.projectforge.business.fibu.orderbookstorage +package org.projectforge.business.fibu.orderbooksnapshots import org.projectforge.business.fibu.OrderInfo -import org.projectforge.business.fibu.PaymentScheduleDO import java.math.BigDecimal import java.time.LocalDate diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageService.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageService.kt deleted file mode 100644 index 35d0f59e6e..0000000000 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageService.kt +++ /dev/null @@ -1,199 +0,0 @@ -///////////////////////////////////////////////////////////////////////////// -// -// Project ProjectForge Community Edition -// www.projectforge.org -// -// Copyright (C) 2001-2024 Micromata GmbH, Germany (www.micromata.com) -// -// ProjectForge is dual-licensed. -// -// This community edition is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License as published -// by the Free Software Foundation; version 3 of the License. -// -// This community edition is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General -// Public License for more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, see http://www.gnu.org/licenses/. -// -///////////////////////////////////////////////////////////////////////////// - -package org.projectforge.business.fibu.orderbookstorage - -import com.fasterxml.jackson.core.type.TypeReference -import jakarta.persistence.Tuple -import mu.KotlinLogging -import org.jetbrains.kotlin.ir.types.IdSignatureValues.result -import org.projectforge.business.fibu.AuftragDO -import org.projectforge.business.fibu.AuftragDao -import org.projectforge.framework.json.JsonUtils -import org.projectforge.framework.persistence.database.TupleUtils -import org.projectforge.framework.persistence.jpa.PfPersistenceService -import org.projectforge.framework.time.PFDateTimeUtils -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Service -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.time.LocalDate -import java.util.* -import java.util.zip.Deflater -import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream - -private val log = KotlinLogging.logger {} - -@Service -class OrderbookStorageService { - class Stats(val date: LocalDate, val count: Int, val gzBytes: ByteArray?) - - @Autowired - private lateinit var auftragDao: AuftragDao - - @Autowired - private lateinit var orderConverterService: OrderConverterService - - @Autowired - private lateinit var persistenceService: PfPersistenceService - - /** - * Stores the current orderbook in the database. - * If there are no orders, nothing is stored. - * If today's orderbook is already stored, it will be updated/overwritten. - * @param incrementalBasedOn if given, this entry contains only the orders which were modified after this date. - * @param returnGZipBytes if true, the gzipped bytes are returned. - * @return the date and the number of stored orders. - */ - @JvmOverloads - fun storeOrderbook(returnGZipBytes: Boolean = false, incrementalBasedOn: LocalDate? = null): Stats { - return storeOrderbook(incrementalBasedOn, returnGZipBytes, LocalDate.now()) - } - - /** - * @param today the date of the orderbook, default is today. This is only for testing purposes. - */ - internal fun storeOrderbook( - incrementalBasedOn: LocalDate? = null, - returnGZipBytes: Boolean = false, - today: LocalDate, - ): Stats { - log.info { "Storing orderbook (incrementalBasedOn=$incrementalBasedOn)..." } - // First, select all orders that are not deleted: - val auftragList = auftragDao.select(deleted = false, checkAccess = false) - val incrementList = if (incrementalBasedOn != null) { - val basedOn = selectMeta(incrementalBasedOn) - if (basedOn == null) { - log.error { "No orderbook found, which based on given date: $incrementalBasedOn. Falling back to full backup." } - auftragList - } else { - val basedOnDate = PFDateTimeUtils.getBeginOfDateAsUtildate(incrementalBasedOn) - // Filter all orders that were modified at or after the given date: - auftragList.filter { (it.lastUpdate ?: Date()) >= basedOnDate } - } - } else { - auftragList - } - log.info { "Converting ${incrementList.size}/${auftragList.size} orders..." } - val orderbook = orderConverterService.convertFromAuftragDO(incrementList) - if (orderbook.isNullOrEmpty()) { - log.warn { "No orders found to store!!!" } - return Stats(today, 0, null) - } - val count = orderbook.size - log.info { "Converting ${incrementList.size}/${auftragList.size} orders to json..." } - val json = JsonUtils.toJson(orderbook, ignoreNullableProps = true) - log.info { "Zipping ${incrementList.size} orders..." } - val gzipBytes = gzip(json) - // Store the orderbook in the database: - OrderbookStorageDO().also { - it.date = today - it.serializedOrderBook = gzipBytes - it.incrementalBasedOn = incrementalBasedOn - }.let { - persistenceService.runInTransaction { context -> - val entry = selectMeta(today) - if (entry != null) { - entry.serializedOrderBook = it.serializedOrderBook - context.em.merge(entry) - } else { - context.em.persist(it) - } - } - } - log.info { "Storing orderbook done." } - return Stats(today, count, if (returnGZipBytes) gzipBytes else null) - } - - fun restoreOrderbook(date: LocalDate): List? { - val orderbook = mutableMapOf() - restoreOrderbook(date, orderbook) - return orderConverterService.convertFromOrder(orderbook.values) - } - - private fun restoreOrderbook(date: LocalDate, orderbook: MutableMap) { - val entry = findEntry(date) - if (entry == null) { - log.error { "No orderbook found for date: $date" } - return - } - if (entry.incremental) { - log.info { "Restoring orderbook for date $date (incremental based on ${entry.incrementalBasedOn})..." } - } else { - log.info { "Restoring orderbook for date $date..." } - } - entry.incrementalBasedOn?.let { incrementalBasedOn -> - log.info { "Restoring orderbook from previous backup first: date=$date..." } - if (date <= incrementalBasedOn) { - log.error { "Internal error: Incremental based on date is greater than the date of the orderbook: $incrementalBasedOn > $date" } - } - // Load orderbook from the incremental based on date first: - restoreOrderbook(incrementalBasedOn, orderbook) - } - val serialized = entry.serializedOrderBook ?: return - val json = gunzip(serialized) - JsonUtils.fromJson(json, object : TypeReference?>() {})?.forEach { order -> - order?.id?.let { id -> - orderbook[id] = order - } - } - } - - private fun selectMeta(date: LocalDate): OrderbookStorageDO? { - return persistenceService.selectNamedSingleResult( - OrderbookStorageDO.FIND_META_BY_DATE, - Tuple::class.java, - "date" to date, - )?.let { - OrderbookStorageDO().also { result -> - result.date = TupleUtils.getLocalDate(it, "date") - result.incrementalBasedOn = TupleUtils.getLocalDate(it, "incrementalBasedOn") - } - } - } - - private fun findEntry(date: LocalDate): OrderbookStorageDO? { - return persistenceService.find(OrderbookStorageDO::class.java, date) - } - - private fun gzip(str: String): ByteArray { - val byteArrayOutputStream = ByteArrayOutputStream() - val gzipStream = object : GZIPOutputStream(byteArrayOutputStream) { - init { - def.setLevel(Deflater.BEST_COMPRESSION) - } - } - gzipStream.use { gzipStream -> - gzipStream.write(str.toByteArray(Charsets.UTF_8)) - } - return byteArrayOutputStream.toByteArray() - } - - fun gunzip(compressed: ByteArray): String { - val byteArrayInputStream = ByteArrayInputStream(compressed) - GZIPInputStream(byteArrayInputStream).use { gzipStream -> - return gzipStream.readBytes().toString(Charsets.UTF_8) - } - } -} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/CronNightlyJob.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/CronNightlyJob.kt index ac4fdededd..85007d8fab 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/CronNightlyJob.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/CronNightlyJob.kt @@ -23,7 +23,12 @@ package org.projectforge.business.jobs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import mu.KotlinLogging +import org.projectforge.common.extensions.formatMillis import org.projectforge.framework.persistence.search.HibernateSearchReindexer import org.springframework.beans.factory.annotation.Autowired import org.springframework.scheduling.annotation.Scheduled @@ -42,17 +47,21 @@ class CronNightlyJob { @Autowired private lateinit var hibernateSearchReindexer: HibernateSearchReindexer + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + //@Scheduled(cron = "0 30 2 * * *") @Scheduled(cron = "\${projectforge.cron.nightly}") fun execute() { + val started = System.currentTimeMillis() log.info("Nightly job started.") - - try { - hibernateSearchReindexer.execute() - } catch (ex: Throwable) { - log.error("While executing hibernate search re-index job: " + ex.message, ex) + coroutineScope.launch { + try { + hibernateSearchReindexer.execute() + } catch (ex: Throwable) { + log.error("While executing hibernate search re-index job: " + ex.message, ex) + } finally { + log.info("Nightly job job finished after ${(System.currentTimeMillis() - started).formatMillis()}.") + } } - - log.info("Nightly job job finished.") } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/CronSanityCheckJob.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/CronSanityCheckJob.kt new file mode 100644 index 0000000000..925710cd02 --- /dev/null +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/CronSanityCheckJob.kt @@ -0,0 +1,100 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2024 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.business.jobs + +import jakarta.annotation.PostConstruct +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.projectforge.business.task.TaskDao +import org.projectforge.common.extensions.formatMillis +import org.projectforge.jcr.JCRCheckSanityJob +import org.projectforge.jobs.AbstractJob +import org.projectforge.jobs.JobListExecutionContext +import org.projectforge.plugins.core.PluginAdminService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +private val log = KotlinLogging.logger {} + +/** + * Job should be scheduled nightly. + * Lot of sanity checks will be done and a mail is sent to the administrator, if something is wrong. + * + * @author Kai Reinhard + */ +@Component +class CronSanityCheckJob { + private val jobs = mutableListOf() + private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @Autowired + private lateinit var jcrCheckSanityJob: JCRCheckSanityJob + + @Autowired + private lateinit var taskDao: TaskDao + + @PostConstruct + private fun postConstruct() { + registerJob(SystemSanityCheckJob(taskDao)) + registerJob(jcrCheckSanityJob) + } + + @Scheduled(cron = "\${projectforge.cron.sanityChecks}") + fun cron() { + log.info("Cronjob for executing sanity checks started...") + + coroutineScope.launch { + val start = System.currentTimeMillis() + try { + val contextList = execute() + } finally { + log.info("Cronjob for executing sanity checks finished after ${(System.currentTimeMillis() - start).formatMillis()}") + } + } + } + + fun execute(): JobListExecutionContext { + val context = JobListExecutionContext() + jobs.forEach { job -> + val jobContext = context.add(job) + try { + log.info("Executing sanity check job: ${job::class.simpleName}") + job.execute(jobContext) + log.info("Execution of sanity check job done: ${job::class.simpleName}") + } catch (ex: Throwable) { + log.error("While executing sanity job ${job::class.simpleName}: " + ex.message, ex) + } + } + return context + } + + fun registerJob(job: AbstractJob) { + log.info { "Registering sanity check job: ${job::class.simpleName}" } + jobs.add(job) + } +} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/SystemSanityCheckJob.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/SystemSanityCheckJob.kt new file mode 100644 index 0000000000..81ab901030 --- /dev/null +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/jobs/SystemSanityCheckJob.kt @@ -0,0 +1,82 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2024 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.business.jobs + +import org.projectforge.business.task.TaskDO +import org.projectforge.business.task.TaskDao +import org.projectforge.business.task.TaskNode +import org.projectforge.jobs.AbstractJob +import org.projectforge.jobs.JobExecutionContext + +class SystemSanityCheckJob(val taskDao: TaskDao) : AbstractJob("System integrity check") { + override fun execute(jobContext: JobExecutionContext) { + jobContext.addMessage("Task integrity (abandoned tasks)") + val tasks: List = taskDao.selectAll(false) + jobContext.addMessage("Found ${tasks.size} tasks.") + val taskMap: MutableMap = HashMap() + for (task in tasks) { + taskMap[task.id] = task + } + var rootTask = false + var abandonedTasks = false + for (task in tasks) { + if (task.parentTask == null) { + if (rootTask) { + jobContext.addError("Found another root task: ${asString(task)}") + } else { + jobContext.addMessage("Found root task: ${asString(task)}") + rootTask = true + } + } else { + var ancestor = taskMap[task.parentTaskId] + var rootTaskFound = false + for (i in 0..49) { // Max. depth of 50, otherwise cyclic task! + if (ancestor == null) { + break + } + if (ancestor.parentTaskId == null) { + // Root task found, OK. + rootTaskFound = true + break + } + ancestor = taskMap[ancestor.parentTaskId] + } + if (!rootTaskFound) { + jobContext.addError("Found abandoned task (cyclic tasks without path to root): ${asString(task)}") + abandonedTasks = true + } + } + taskMap[task.id] = task + } + if (!abandonedTasks) { + jobContext.addMessage("Test OK, no abandoned tasks detected.") + } else { + jobContext.addError("Test FAILED, abandoned tasks detected.") + } + } + + private fun asString(task: TaskDO): String { + return "TaskNode[id=[${task.id}], created=[${task.created}] title=[${task.title}]]" + } +} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/plugins/core/PluginAdminService.kt b/projectforge-business/src/main/kotlin/org/projectforge/plugins/core/PluginAdminService.kt index 296ccc87d8..49ca77e86f 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/plugins/core/PluginAdminService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/plugins/core/PluginAdminService.kt @@ -80,6 +80,14 @@ open class PluginAdminService { return pluginsRegistry.plugins } + fun isActive(pluginId: String): Boolean { + return activePlugins.any { it.id == pluginId } + } + + fun isActive(clazz: Class<*>): Boolean { + return activePlugins.any { it.javaClass == clazz } + } + /** * Store a plugin as activated. * read LocalSettings pf.plugins.active. If not defined, uses ConfigurationParam. diff --git a/projectforge-business/src/main/resources/I18nResources.properties b/projectforge-business/src/main/resources/I18nResources.properties index 19149153b8..84347c206c 100644 --- a/projectforge-business/src/main/resources/I18nResources.properties +++ b/projectforge-business/src/main/resources/I18nResources.properties @@ -2256,7 +2256,6 @@ system.admin.group.title.databaseActions=Database actions system.admin.group.title.misc.logEntries=Format log entries system.admin.group.title.systemChecksAndFunctionality.caches=Caches system.admin.group.title.systemChecksAndFunctionality.configuration=Configuration -system.admin.group.title.systemChecksAndFunctionality.miscChecks=Misc checks system.admin.logViewer.autoRefresh=Auto refresh system.admin.logViewer.level=Loglevel system.admin.logViewer.loggerName=Logger name diff --git a/projectforge-business/src/main/resources/I18nResources_de.properties b/projectforge-business/src/main/resources/I18nResources_de.properties index d122f9b684..e72ffa06e5 100644 --- a/projectforge-business/src/main/resources/I18nResources_de.properties +++ b/projectforge-business/src/main/resources/I18nResources_de.properties @@ -71,7 +71,6 @@ # system.admin.group.title.misc.logEntries=Format log entries # system.admin.group.title.systemChecksAndFunctionality.caches=Caches # system.admin.group.title.systemChecksAndFunctionality.configuration=Configuration -# system.admin.group.title.systemChecksAndFunctionality.miscChecks=Misc checks # system.admin.reindex.fromDate=From date # system.admin.reindex.fromDate.tooltip=Re-index only those entries with a date of last modification newer than the given date. This date setting is optional. # system.admin.reindex.newestEntries=Number @@ -2340,7 +2339,6 @@ system.admin.development.testObjectsCreationQuestion=Sollen wirklich {0} Testobj ### not translated: system.admin.group.title.misc.logEntries=Format log entries ### not translated: system.admin.group.title.systemChecksAndFunctionality.caches=Caches ### not translated: system.admin.group.title.systemChecksAndFunctionality.configuration=Configuration -### not translated: system.admin.group.title.systemChecksAndFunctionality.miscChecks=Misc checks system.admin.logViewer.autoRefresh=Auto-Aktualisierung system.admin.logViewer.level=Log-Level system.admin.logViewer.loggerName=Quelle diff --git a/projectforge-business/src/main/resources/application.properties b/projectforge-business/src/main/resources/application.properties index 600839e09d..1ebbbb9c47 100644 --- a/projectforge-business/src/main/resources/application.properties +++ b/projectforge-business/src/main/resources/application.properties @@ -180,6 +180,7 @@ projectforge.ldap.sambaAccountsPrimaryGroupSID= #Cron-Jobs: second, minute, hour, day, month, weekday (UTC) projectforge.cron.hourly=0 0 * * * * projectforge.cron.nightly=0 30 2 * * * +projectforge.cron.sanityChecks=3 30 2 * * * projectforge.cron.externalCalendar=0 */15 * * * * # Nightly job runs at 4 am: diff --git a/projectforge-business/src/main/resources/flyway/migrate/hsqldb/V8.0.5__RELEASE-OrderStorage.sql b/projectforge-business/src/main/resources/flyway/migrate/hsqldb/V8.0.5__RELEASE-OrderStorage.sql deleted file mode 100644 index acfbd76ba2..0000000000 --- a/projectforge-business/src/main/resources/flyway/migrate/hsqldb/V8.0.5__RELEASE-OrderStorage.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE t_fibu_orderbook_storage -( - date DATE NOT NULL, - incremental_based_on DATE, - serialized_orderbook BLOB -); - -ALTER TABLE t_fibu_orderbook_storage - ADD CONSTRAINT t_fibu_orderbook_storage_pkey PRIMARY KEY (date); diff --git a/projectforge-business/src/main/resources/flyway/migrate/hsqldb/V8.0.5__RELEASE-OrderbookSnapshots.sql b/projectforge-business/src/main/resources/flyway/migrate/hsqldb/V8.0.5__RELEASE-OrderbookSnapshots.sql new file mode 100644 index 0000000000..19b1536116 --- /dev/null +++ b/projectforge-business/src/main/resources/flyway/migrate/hsqldb/V8.0.5__RELEASE-OrderbookSnapshots.sql @@ -0,0 +1,11 @@ +CREATE TABLE t_fibu_orderbook_snapshots +( + date DATE NOT NULL, + created TIMESTAMP WITHOUT TIME ZONE NOT NULL, + incremental_based_on DATE, + serialized_orderbook BLOB, + size INTEGER +); + +ALTER TABLE t_fibu_orderbook_snapshots + ADD CONSTRAINT t_fibu_orderbook_snapshots_pkey PRIMARY KEY (date); diff --git a/projectforge-business/src/main/resources/flyway/migrate/postgresql/V8.0.5__RELEASE-OrderSnapshots.sql b/projectforge-business/src/main/resources/flyway/migrate/postgresql/V8.0.5__RELEASE-OrderSnapshots.sql new file mode 100644 index 0000000000..38834ab670 --- /dev/null +++ b/projectforge-business/src/main/resources/flyway/migrate/postgresql/V8.0.5__RELEASE-OrderSnapshots.sql @@ -0,0 +1,11 @@ +CREATE TABLE t_fibu_orderbook_snapshots +( + date DATE NOT NULL, + created TIMESTAMP WITHOUT TIME ZONE NOT NULL, + incremental_based_on DATE, + serialized_orderbook BYTEA, + size INTEGER +); + +ALTER TABLE t_fibu_orderbook_snapshots + ADD CONSTRAINT t_fibu_orderbook_snapshots_pkey PRIMARY KEY (date); diff --git a/projectforge-business/src/main/resources/flyway/migrate/postgresql/V8.0.5__RELEASE-OrderStorage.sql b/projectforge-business/src/main/resources/flyway/migrate/postgresql/V8.0.5__RELEASE-OrderStorage.sql deleted file mode 100644 index 4188d5abca..0000000000 --- a/projectforge-business/src/main/resources/flyway/migrate/postgresql/V8.0.5__RELEASE-OrderStorage.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE t_fibu_orderbook_storage -( - date DATE NOT NULL, - incremental_based_on DATE, - serialized_orderbook BYTEA -); - -ALTER TABLE t_fibu_orderbook_storage - ADD CONSTRAINT t_fibu_orderbook_storage_pkey PRIMARY KEY (date); diff --git a/projectforge-business/src/test/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageTest.kt b/projectforge-business/src/test/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookStorageTest.kt similarity index 90% rename from projectforge-business/src/test/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageTest.kt rename to projectforge-business/src/test/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookStorageTest.kt index e866bd89a2..7e90ec8d09 100644 --- a/projectforge-business/src/test/kotlin/org/projectforge/business/fibu/orderbookstorage/OrderbookStorageTest.kt +++ b/projectforge-business/src/test/kotlin/org/projectforge/business/fibu/orderbooksnapshots/OrderbookStorageTest.kt @@ -21,7 +21,7 @@ // ///////////////////////////////////////////////////////////////////////////// -package org.projectforge.business.fibu.orderbookstorage +package org.projectforge.business.fibu.orderbooksnapshots import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test @@ -42,7 +42,7 @@ class OrderbookStorageTest : AbstractTestBase() { private lateinit var kundeDao: KundeDao @Autowired - private lateinit var orderbookStorageService: OrderbookStorageService + private lateinit var orderbookStorageService: OrderbookSnapshotsService @Autowired private lateinit var projektDao: ProjektDao @@ -71,11 +71,11 @@ class OrderbookStorageTest : AbstractTestBase() { val order4 = createOrder("4") val order5 = createOrder("5") val order6 = createOrder("6") - val stats = orderbookStorageService.storeOrderbook(today = LocalDate.of(2024, Month.DECEMBER, 15)) + val stats = orderbookStorageService.storeOrderbookSnapshot(date = LocalDate.of(2024, Month.DECEMBER, 15)) // Previous entry should be overwritten. No exception expected: - orderbookStorageService.storeOrderbook(today = stats.date) + orderbookStorageService.storeOrderbookSnapshot(date = stats.date) Assertions.assertEquals(6, stats.count) - var list = orderbookStorageService.restoreOrderbook(stats.date) + var list = orderbookStorageService.readSnapshot(stats.date) Assertions.assertEquals(6, list!!.size) list.forEach { order -> Assertions.assertEquals("30.00".toBigDecimal(), order.info.netSum) @@ -95,11 +95,11 @@ class OrderbookStorageTest : AbstractTestBase() { // Test incremental storage: var incrementalStats = - orderbookStorageService.storeOrderbook(incrementalBasedOn = stats.date) + orderbookStorageService.createOrderbookSnapshot(incrementalBasedOn = stats.date) Assertions.assertEquals(3, incrementalStats.count) // Restore the incremental storage: - list = orderbookStorageService.restoreOrderbook(incrementalStats.date) + list = orderbookStorageService.readSnapshot(incrementalStats.date) Assertions.assertEquals(6, list!!.size) list.forEach { order -> Assertions.assertEquals("30.00".toBigDecimal(), order.info.netSum) @@ -109,13 +109,13 @@ class OrderbookStorageTest : AbstractTestBase() { } Assertions.assertTrue(list.any { it.titel == "new title 4" }, "Modified title expected.") - list = orderbookStorageService.restoreOrderbook(stats.date) // Restore the full backup (without last changes). + list = orderbookStorageService.readSnapshot(stats.date) // Restore the full backup (without last changes). Assertions.assertEquals(6, list!!.size) Assertions.assertTrue(list.none { it.titel == "new title 4" }, "Modified title expected.") // Test incremental storage with a date that is not found: incrementalStats = - orderbookStorageService.storeOrderbook(incrementalBasedOn = LocalDate.of(2024, Month.NOVEMBER, 12)) + orderbookStorageService.createOrderbookSnapshot(incrementalBasedOn = LocalDate.of(2024, Month.NOVEMBER, 12)) Assertions.assertEquals(6, incrementalStats.count, "No storage found, based on date 2024-11-12, full backup expected.") } diff --git a/projectforge-common/src/main/kotlin/org/projectforge/common/BackupFilesPurging.kt b/projectforge-common/src/main/kotlin/org/projectforge/common/BackupFilesPurging.kt index 6642d57f90..9fe6968fef 100644 --- a/projectforge-common/src/main/kotlin/org/projectforge/common/BackupFilesPurging.kt +++ b/projectforge-common/src/main/kotlin/org/projectforge/common/BackupFilesPurging.kt @@ -74,7 +74,7 @@ object BackupFilesPurging { val weeksSet = mutableSetOf>() // File prefix, Year, week of year val keepDailyBackupsUntil = baseDate.minusDays(keepDailyBackups) val keepWeeklyBackupsUntil = baseDate.minusDays(keepWeeklyBackups * 7) - log.info { "Keeping daily backups back until ${DATE_FORMATTER.format(keepDailyBackupsUntil)}, wwekly backups until ${DATE_FORMATTER.format(keepWeeklyBackupsUntil)} and keeping monthly backups forever in ${backupDirectory.absolutePath}/${filePrefix ?: ""}*..." } + log.info { "Keeping daily backups back until ${DATE_FORMATTER.format(keepDailyBackupsUntil)}, weekly backups until ${DATE_FORMATTER.format(keepWeeklyBackupsUntil)} and keeping monthly backups forever in ${backupDirectory.absolutePath}/${filePrefix ?: ""}*..." } var deletedFiles = 0 var keptFiles = 0 var totalFiles = 0 diff --git a/projectforge-common/src/main/kotlin/org/projectforge/common/extensions/KotlinNumberExtensions.kt b/projectforge-common/src/main/kotlin/org/projectforge/common/extensions/KotlinNumberExtensions.kt index dea117bb99..d88cfa904a 100644 --- a/projectforge-common/src/main/kotlin/org/projectforge/common/extensions/KotlinNumberExtensions.kt +++ b/projectforge-common/src/main/kotlin/org/projectforge/common/extensions/KotlinNumberExtensions.kt @@ -102,6 +102,9 @@ fun Number?.formatBytes(locale: Locale? = null): String { return FormatterUtils.formatBytes(this.toLong(), locale ?: Locale.getDefault()) } +/** + * Formats a number given in millis to a string in the format HH:mm:ss.SSS. + */ fun Number?.formatMillis(): String { this ?: return "" val millis = this.toLong() diff --git a/projectforge-common/src/main/kotlin/org/projectforge/jobs/AbstractJob.kt b/projectforge-common/src/main/kotlin/org/projectforge/jobs/AbstractJob.kt new file mode 100644 index 0000000000..5fce6b33b8 --- /dev/null +++ b/projectforge-common/src/main/kotlin/org/projectforge/jobs/AbstractJob.kt @@ -0,0 +1,31 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2024 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.jobs + +/** + * A job using [JobExecutionContext] to report its status and messages. + */ +abstract class AbstractJob(val title: String) { + abstract fun execute(jobContext: JobExecutionContext) +} diff --git a/projectforge-common/src/main/kotlin/org/projectforge/jobs/JobExecutionContext.kt b/projectforge-common/src/main/kotlin/org/projectforge/jobs/JobExecutionContext.kt new file mode 100644 index 0000000000..7a00ec4003 --- /dev/null +++ b/projectforge-common/src/main/kotlin/org/projectforge/jobs/JobExecutionContext.kt @@ -0,0 +1,139 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2024 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.jobs + +import org.projectforge.jobs.JobListExecutionContext.Companion.addBoxedLine +import org.projectforge.jobs.JobListExecutionContext.Companion.addCell +import org.projectforge.jobs.JobListExecutionContext.Companion.addErrorBoxedLineMarker +import org.projectforge.jobs.JobListExecutionContext.Companion.addSeparatorLine +import org.projectforge.jobs.JobListExecutionContext.Companion.format +import java.util.* + +class JobExecutionContext(val producer: AbstractJob) { + enum class Status { + ERRORS, WARNINGS, OK + } + + class Message(val message: String, val status: Status) { + val date = Date() + } + + private val attributes = mutableMapOf() + + val status: Status + get() = when { + errors.isNotEmpty() -> Status.ERRORS + warnings.isNotEmpty() -> Status.WARNINGS + else -> Status.OK + } + val warnings = mutableListOf() + val errors = mutableListOf() + + val allMessages = mutableListOf() + + /** + * Info messages, log messages etc. + */ + val messages = mutableListOf() + + val lastUpdate: Date + get() = allMessages.maxByOrNull { it.date }?.date ?: Date() + + fun setAttribute(key: String, value: Any?) { + attributes[key] = value + } + + fun getAttribute(key: String): Any? { + return attributes[key] + } + + fun getAttributeAsLong(key: String): Long? { + return attributes[key] as? Long + } + + fun getAttributeAsInt(key: String): Int? { + return attributes[key] as? Int + } + + fun addError(msg: String) { + Message(msg, Status.ERRORS).also { + errors.add(it) + allMessages.add(it) + } + } + + fun addWarning(msg: String) { + Message(msg, Status.WARNINGS).also { + warnings.add(it) + allMessages.add(it) + } + } + + fun addMessage(msg: String) { + Message(msg, Status.OK).also { + messages.add(it) + allMessages.add(it) + } + } + + fun addReportAsText(sb: StringBuilder) { + addIntro(sb) + if (errors.isNotEmpty()) { + sb.appendLine("*** Errors:") + errors.forEach { sb.appendLine("*** ERROR: ${it.date}: ${it.message}") } + sb.appendLine() + } + if (allMessages.isNotEmpty()) { + sb.appendLine("Messages:") + allMessages.forEach { msg -> + val marker = when (msg.status) { + Status.OK -> " INFO " + Status.WARNINGS -> "!!! WARN " + Status.ERRORS -> "*** ERROR ***" + } + + sb.appendLine("${format(msg.date)} $marker: ${msg.message}") + } + sb.appendLine() + } + } + + fun addStatusLineAsText(sb: StringBuilder) { + addCell(sb, producer.title, 50, lineCompleted = false) + val statusString = if (status == Status.ERRORS) "*** ERRORS ***" else status.toString() + addCell(sb, statusString, 30) + } + + internal fun addIntro(sb: StringBuilder) { + addSeparatorLine(sb) + addBoxedLine(sb, "${producer::class.simpleName}:${producer.title}") + addSeparatorLine(sb) + when (status) { + Status.OK -> addBoxedLine(sb, " Status: OK") + Status.WARNINGS -> addBoxedLine(sb, " Status: WARNINGS") + Status.ERRORS -> addErrorBoxedLineMarker(sb) + } + addSeparatorLine(sb) + } +} diff --git a/projectforge-common/src/main/kotlin/org/projectforge/jobs/JobListExecutionContext.kt b/projectforge-common/src/main/kotlin/org/projectforge/jobs/JobListExecutionContext.kt new file mode 100644 index 0000000000..ae2e6aa311 --- /dev/null +++ b/projectforge-common/src/main/kotlin/org/projectforge/jobs/JobListExecutionContext.kt @@ -0,0 +1,104 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2024 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.jobs + +import org.projectforge.common.extensions.abbreviate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.* + +class JobListExecutionContext { + val jobs = mutableListOf() + + val status: JobExecutionContext.Status + get() = when { + jobs.any { it.status == JobExecutionContext.Status.ERRORS } -> JobExecutionContext.Status.ERRORS + jobs.any { it.status == JobExecutionContext.Status.WARNINGS } -> JobExecutionContext.Status.WARNINGS + else -> JobExecutionContext.Status.OK + } + + fun add(job: AbstractJob): JobExecutionContext { + return JobExecutionContext(job).also { jobs.add(it) } + } + + fun getReportAsText(): String { + val sb = StringBuilder() + addSeparatorLine(sb) + addSeparatorLine(sb) + addBoxedLine(sb, "Sanity Check Report: ${format(Date())}") + addSeparatorLine(sb) + when (status) { + JobExecutionContext.Status.OK -> addBoxedLine(sb, " All checks passed successfully.") + JobExecutionContext.Status.WARNINGS -> addBoxedLine(sb, " Some checks passed with warnings.") + JobExecutionContext.Status.ERRORS -> addErrorBoxedLineMarker(sb) + } + addSeparatorLine(sb) + sortedJobs.forEach { job -> + job.addStatusLineAsText(sb) + } + addSeparatorLine(sb) + sb.appendLine() + sortedJobs.forEach { job -> + job.addReportAsText(sb) + } + sb.appendLine() + return sb.toString() + } + + private val sortedJobs: List + get() = jobs.sortedWith(compareBy { it.status } + .thenBy { it.lastUpdate }) + + companion object { + internal const val LINE_LENGTH = 80 + private val isoDateTimeFormatterSeconds = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC) + + internal fun format(date: Date): String { + return isoDateTimeFormatterSeconds.format(date.toInstant()) + } + + internal fun addBoxedLine(sb: StringBuilder, title: String) { + addCell(sb, title, LINE_LENGTH) + } + + internal fun addCell(sb: StringBuilder, text: String, length: Int, lineCompleted: Boolean = true) { + val useLength = if (lineCompleted) length - 4 else length -2 + sb.append("| ${text.abbreviate(useLength).padEnd(useLength)}") + if (lineCompleted) { + sb.appendLine(" |") + } + } + + internal fun addErrorBoxedLineMarker(sb: StringBuilder) { + addBoxedLine(sb, " *************************************") + addBoxedLine(sb, " ******* Some checks failed!!! *******") + addBoxedLine(sb, " *************************************") + } + + internal fun addSeparatorLine(sb: StringBuilder) { + sb.appendLine("-".repeat(LINE_LENGTH)) + } + } +} diff --git a/projectforge-common/src/test/kotlin/org/projectforge/common/BackupFilesPurgingTest.kt b/projectforge-common/src/test/kotlin/org/projectforge/common/BackupFilesPurgingTest.kt index 1d4dbf0f6f..21d622e859 100644 --- a/projectforge-common/src/test/kotlin/org/projectforge/common/BackupFilesPurgingTest.kt +++ b/projectforge-common/src/test/kotlin/org/projectforge/common/BackupFilesPurgingTest.kt @@ -63,7 +63,7 @@ class BackupFilesPurgingTest { files.any { it.name == "$basename-2019-12-03$SUFFIX" }, "Keep the first file of the month." ) - // Keep daily until 2020-04-03, wwekly backups until 2020-05-03 + // Keep daily until 2020-04-03, weekly backups until 2020-05-03 Assertions.assertTrue(files.any { it.name == "$basename-2020-04-03$SUFFIX" }) Assertions.assertFalse(files.any { it.name == "$basename-2020-04-02$SUFFIX" }) Assertions.assertTrue(files.any { it.name == "$basename-2020-04-01$SUFFIX" }) diff --git a/projectforge-jcr/build.gradle.kts b/projectforge-jcr/build.gradle.kts index ba3d3cd6f9..a5e572eab7 100644 --- a/projectforge-jcr/build.gradle.kts +++ b/projectforge-jcr/build.gradle.kts @@ -17,12 +17,13 @@ dependencies { api(libs.com.fasterxml.jackson.core.databind) api(libs.com.fasterxml.jackson.core.annotations) api(libs.org.springframework.spring.context) - api (libs.io.dropwizard.metrics.core) + api(libs.io.dropwizard.metrics.core) api(libs.org.apache.jackrabbit.oak.jcr) api(libs.jakarta.annotation.api) api(libs.net.lingala.zip4j.zip4j) api(libs.org.apache.jackrabbit.oak.jcr) api(libs.org.apache.jackrabbit.oak.segment.tar) + api(libs.org.jetbrains.kotlinx.coroutines.core) api(libs.org.jetbrains.kotlin.stdlib) testImplementation(project(":projectforge-commons-test")) } diff --git a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/JCRCheckSanityJob.kt b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/JCRCheckSanityJob.kt index e02ae42b60..d7de4b5216 100644 --- a/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/JCRCheckSanityJob.kt +++ b/projectforge-jcr/src/main/kotlin/org/projectforge/jcr/JCRCheckSanityJob.kt @@ -23,8 +23,17 @@ package org.projectforge.jcr +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import mu.KotlinLogging import org.projectforge.common.FormatterUtils +import org.projectforge.common.extensions.format +import org.projectforge.common.extensions.formatBytes +import org.projectforge.common.extensions.formatMillis +import org.projectforge.jobs.AbstractJob +import org.projectforge.jobs.JobExecutionContext import org.springframework.beans.factory.annotation.Autowired import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component @@ -33,114 +42,144 @@ import javax.jcr.Node private val log = KotlinLogging.logger {} @Component -open class JCRCheckSanityJob { - @Autowired - internal lateinit var repoService: RepoService +open class JCRCheckSanityJob : AbstractJob("JCR Check Sanity") { + @Autowired + internal lateinit var repoService: RepoService - class CheckResult( - val errors: List, - val warnings: List, - val numberOfVisitedFiles: Int, - val numberOfVisitedNodes: Int - ) { - fun toText(): String { - val sb = StringBuilder() - sb.appendLine("Errors").appendLine("------") - errors.forEach { sb.appendLine(" *** $it") } - sb.appendLine("Warnings").appendLine("--------") - warnings.forEach { sb.appendLine(" $it") } - sb.appendLine() - sb.appendLine("Number of visited nodes: ${FormatterUtils.format(numberOfVisitedNodes)}") - sb.appendLine("Number of visited files: ${FormatterUtils.format(numberOfVisitedFiles)}") - return sb.toString() - } - } + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - // For testing: @Scheduled(fixedDelay = 3600 * 1000, initialDelay = 10 * 1000) - // projectforge.jcr.cron.backup=0 30 0 * * * - @Scheduled(cron = "\${projectforge.jcr.cron.sanityCheck}") - open fun execute(): CheckResult { - log.info("JCR sanity check job started.") - val errors = mutableListOf() - val warnings = mutableListOf() - val walker = object : RepoTreeWalker(repoService) { - override fun visitFile(fileNode: Node, fileObject: FileObject) { - fileObject.checksum.let { repoChecksum -> - if (repoChecksum != null && repoChecksum.length > 10) { - log.info { "Checking checksum of file '${fileObject.fileName}' (${FormatterUtils.formatBytes(fileObject.size)})..." } - val checksum = - repoService.getFileInputStream(fileNode, fileObject, true, useEncryptedFile = true) - .use { istream -> RepoService.checksum(istream) } - if (!validateChecksum(checksum, repoChecksum)) { - val msg = - "Checksum of file '${fileObject.fileName}' from repository '${normalizeChecksum(checksum)}' differs from repository value '${ - normalizeChecksum( - repoChecksum - ) - }'! ['${fileNode.path}']" - errors.add(msg) - log.error { msg } - } - } else { - val msg = - "Checksum of file '${fileObject.fileName}' from repository not given (skipping checksum check). ['${fileNode.path}']" - warnings.add(msg) - log.info { msg } - } - } - if (fileObject.fileExtension == "zip") { - // Check mode of zip files (encryption). - if (fileObject.zipMode == null) { - val newZipMode = repoService.getFileInputStream(fileNode, fileObject, true, useEncryptedFile = true) - .use { istream -> ZipUtils.determineZipMode(istream) } - if (newZipMode != null) { - fileNode.setProperty(RepoService.PROPERTY_ZIP_MODE, newZipMode.name) + class CheckResult( + val errors: List, + val warnings: List, + val numberOfVisitedFiles: Int, + val numberOfVisitedNodes: Int + ) + + // For testing: @Scheduled(fixedDelay = 3600 * 1000, initialDelay = 10 * 1000) + // projectforge.jcr.cron.backup=0 30 0 * * * + @Scheduled(cron = "\${projectforge.jcr.cron.sanityCheck}") + open fun cron() { + val started = System.currentTimeMillis() + log.info("JCR sanity check job started.") + val job = this + coroutineScope.launch { + try { + val jobContext = JobExecutionContext(job) + execute(jobContext) + val numberOfVisitedNodes = jobContext.getAttributeAsInt(NUMBER_OF_VISITED_NODES) + val numberOfVisitedFiles = jobContext.getAttributeAsInt(NUMBER_OF_VISITED_FILES) + val msgPart1 = + "JCR sanity check job finished after ${(System.currentTimeMillis() - started).formatMillis()}" + val msgPart2 = + "${numberOfVisitedFiles.format()} files and ${numberOfVisitedNodes.format()} nodes checked: errors=${jobContext.errors.size}, warnings=${jobContext.warnings.size}." + if (jobContext.status == JobExecutionContext.Status.ERRORS) { + log.error { "$msgPart1 with errors. $msgPart2" } + } else { + log.info { "$msgPart1. $msgPart2" } + } + } catch (ex: Throwable) { + log.error("While executing hibernate search re-index job: " + ex.message, ex) } - } } - fileObject.size.let { repoSize -> - if (repoSize == null) { - val msg = - "Size of file '${fileObject.fileName}' from repository not given (skipping file size check). ['${fileNode.path}']" - warnings.add(msg) - log.info { msg } - } else { - val fileSize = repoService.getFileSize(fileNode, fileObject, true) - if (fileSize != repoSize) { - val msg = - "Size of file from repository '${fileNode.path}': '${fileObject.fileName}'=${ - FormatterUtils.format( - fileSize - ) - } differs from repository value ${FormatterUtils.format(repoSize)}!" - errors.add(msg) - log.error { msg } + } + + override fun execute(jobContext: JobExecutionContext) { + val walker = object : RepoTreeWalker(repoService) { + override fun visitFile(fileNode: Node, fileObject: FileObject) { + fileObject.checksum.let { repoChecksum -> + if (repoChecksum != null && repoChecksum.length > 10) { + jobContext.addMessage( + "Checking checksum of file '${fileObject.fileName}' (${fileObject.size.formatBytes()})..." + ) + val checksum = + repoService.getFileInputStream(fileNode, fileObject, true, useEncryptedFile = true) + .use { istream -> RepoService.checksum(istream) } + if (!validateChecksum(checksum, repoChecksum)) { + val msg = + "Checksum of file '${fileObject.fileName}' from repository '${normalizeChecksum(checksum)}' differs from repository value '${ + normalizeChecksum( + repoChecksum + ) + }'! ['${fileNode.path}']" + jobContext.addError(msg) + log.error { msg } + } + } else { + val msg = + "Checksum of file '${fileObject.fileName}' from repository not given (skipping checksum check). ['${fileNode.path}']" + jobContext.addError(msg) + log.error { msg } + } + } + if (fileObject.fileExtension == "zip") { + // Check mode of zip files (encryption). + if (fileObject.zipMode == null) { + val newZipMode = + repoService.getFileInputStream(fileNode, fileObject, true, useEncryptedFile = true) + .use { istream -> ZipUtils.determineZipMode(istream) } + if (newZipMode != null) { + fileNode.setProperty(RepoService.PROPERTY_ZIP_MODE, newZipMode.name) + } + } + } + fileObject.size.let { repoSize -> + if (repoSize == null) { + val msg = + "Size of file '${fileObject.fileName}' from repository not given (skipping file size check). ['${fileNode.path}']" + jobContext.addWarning(msg) + log.info { msg } + } else { + val fileSize = repoService.getFileSize(fileNode, fileObject, true) + if (fileSize != repoSize) { + val msg = + "Size of file from repository '${fileNode.path}': '${fileObject.fileName}'=${ + FormatterUtils.format( + fileSize + ) + } differs from repository value ${FormatterUtils.format(repoSize)}!" + jobContext.addError(msg) + log.error { msg } + } + } + } } - } } - } + walker.walk() + jobContext.setAttribute(NUMBER_OF_VISITED_NODES, walker.numberOfVisitedNodes) + jobContext.setAttribute(NUMBER_OF_VISITED_FILES, walker.numberOfVisitedFiles) } - walker.walk() - log.info { "JCR sanity check job finished. ${walker.numberOfVisitedFiles} Files checked with ${warnings.size} warnings and ${errors.size} errors." } - return CheckResult(errors, warnings, walker.numberOfVisitedFiles, walker.numberOfVisitedNodes) - } - private fun validateChecksum(checksum1: String, checksum2: String): Boolean { - val c1 = normalizeChecksum(checksum1) - val c2 = normalizeChecksum(checksum2) - return c1 == c2 - } + open fun execute(): CheckResult { + val jobContext = JobExecutionContext(this) + execute(jobContext) + val errors = jobContext.errors.map { it.message }.toMutableList() + val warnings = jobContext.warnings.map { it.message }.toMutableList() + val numberOfVisitedNodes = jobContext.getAttributeAsInt(NUMBER_OF_VISITED_NODES) + val numberOfVisitedFiles = jobContext.getAttributeAsInt(NUMBER_OF_VISITED_FILES) + return CheckResult(errors, warnings, numberOfVisitedFiles ?: -1, numberOfVisitedNodes ?: -1) + } - private fun normalizeChecksum(checksum: String): String { - return subString(subString(checksum, '='), ' ') - } + private fun validateChecksum(checksum1: String, checksum2: String): Boolean { + val c1 = normalizeChecksum(checksum1) + val c2 = normalizeChecksum(checksum2) + return c1 == c2 + } + + private fun normalizeChecksum(checksum: String): String { + return subString(subString(checksum, '='), ' ') + } + + private fun subString(checksum: String, ch: Char): String { + val idx = checksum.indexOf(ch) + return if (idx > 0 && checksum.length > idx) { + checksum.substring(idx + 1) + } else { + checksum + } + } - private fun subString(checksum: String, ch: Char): String { - val idx = checksum.indexOf(ch) - return if (idx > 0 && checksum.length > idx) { - checksum.substring(idx + 1) - } else { - checksum + companion object { + const val NUMBER_OF_VISITED_NODES = "numberOfVisitedNodes" + const val NUMBER_OF_VISITED_FILES = "numberOfVisitedFiles" } - } } diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/admin/AdminPage.java b/projectforge-wicket/src/main/java/org/projectforge/web/admin/AdminPage.java index aa54094360..a39b3fc886 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/admin/AdminPage.java +++ b/projectforge-wicket/src/main/java/org/projectforge/web/admin/AdminPage.java @@ -43,7 +43,6 @@ import org.projectforge.framework.persistence.search.HibernateSearchReindexer; import org.projectforge.framework.time.DateHelper; import org.projectforge.framework.time.PFDateTime; -import org.projectforge.jcr.JCRCheckSanityJob; import org.projectforge.web.WicketSupport; import org.projectforge.web.fibu.ISelectCallerPage; import org.projectforge.web.wicket.AbstractStandardFormPage; @@ -54,7 +53,6 @@ import java.io.IOException; import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.util.*; public class AdminPage extends AbstractStandardFormPage implements ISelectCallerPage { @@ -87,7 +85,7 @@ public AdminPage(final PageParameters parameters) { addDatabaseActionsMenu(); addCachesMenu(); addConfigurationMenu(); - addMiscMenu(); + addCheckMenuItem(); addDevelopmentMenu(); } @@ -220,11 +218,7 @@ schemaExportLink, getString("system.admin.button.schemaExport")) } @SuppressWarnings("serial") - protected void addMiscMenu() { - // Misc checks - final ContentMenuEntryPanel miscChecksMenu = new ContentMenuEntryPanel(getNewContentMenuChildId(), - getString("system.admin.group.title.systemChecksAndFunctionality.miscChecks")); - addContentMenuEntry(miscChecksMenu); + protected void addCheckMenuItem() { // Check system integrity final Link checkSystemIntegrityLink = new Link(ContentMenuEntryPanel.LINK_ID) { @Override @@ -233,25 +227,10 @@ public void onClick() { } }; final ContentMenuEntryPanel checkSystemIntegrityLinkMenuItem = new ContentMenuEntryPanel( - miscChecksMenu.newSubMenuChildId(), + getNewContentMenuChildId(), checkSystemIntegrityLink, getString("system.admin.button.checkSystemIntegrity")) .setTooltip(getString("system.admin.button.checkSystemIntegrity.tooltip")); - miscChecksMenu.addSubMenuEntry(checkSystemIntegrityLinkMenuItem); - - // JCR sanity check - // Check system integrity - final Link checkJCRSanityLink = new Link(ContentMenuEntryPanel.LINK_ID) { - @Override - public void onClick() { - checkJCRSanity(); - } - }; - final ContentMenuEntryPanel checkJCRSanityLinkMenuItem = new ContentMenuEntryPanel( - miscChecksMenu.newSubMenuChildId(), - checkJCRSanityLink, getString("system.admin.button.checkJCRSanity")) - .setTooltip(getString("system.admin.button.checkJCRSanity.tooltip")); - miscChecksMenu.addSubMenuEntry(checkJCRSanityLinkMenuItem); - + addContentMenuEntry(checkSystemIntegrityLinkMenuItem); } @SuppressWarnings("serial") @@ -299,18 +278,10 @@ protected void checkSystemIntegrity() { log.info("Administration: check integrity of tasks."); checkAccess(); final String result = WicketSupport.get(SystemService.class).checkSystemIntegrity(); - final String filename = "projectforge_check_report" + DateHelper.getDateAsFilenameSuffix(new Date()) + ".txt"; + final String filename = "projectforge_sanity-check" + DateHelper.getDateAsFilenameSuffix(new Date()) + ".txt"; DownloadUtils.setDownloadTarget(result.getBytes(), filename); } - protected void checkJCRSanity() { - log.info("Administration: JCR sanity check."); - checkAccess(); - JCRCheckSanityJob.CheckResult result = WicketSupport.get(JCRCheckSanityJob.class).execute(); - final String filename = "projectforge_jcr-sanity-check" + DateHelper.getDateAsFilenameSuffix(new Date()) + ".txt"; - DownloadUtils.setDownloadTarget(result.toText().getBytes(StandardCharsets.UTF_8), filename); - } - protected void refreshCaches() { log.info("Administration: refresh of caches."); diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/AuftragListPage.java b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/AuftragListPage.java index 0a7d17ffa5..422c1e2658 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/fibu/AuftragListPage.java +++ b/projectforge-wicket/src/main/java/org/projectforge/web/fibu/AuftragListPage.java @@ -42,7 +42,7 @@ import org.projectforge.business.fibu.*; import org.projectforge.business.fibu.kost.KundeCache; import org.projectforge.business.fibu.kost.ProjektCache; -import org.projectforge.business.fibu.orderbookstorage.OrderbookStorageService; +import org.projectforge.business.fibu.orderbooksnapshots.OrderbookSnapshotsService; import org.projectforge.business.task.formatter.WicketTaskFormatter; import org.projectforge.business.utils.CurrencyFormatter; import org.projectforge.common.i18n.UserException; @@ -279,7 +279,7 @@ public void onSubmit() { new SubmitLink(ContentMenuEntryPanel.LINK_ID, form) { @Override public void onSubmit() { - byte[] gz = WicketSupport.get(OrderbookStorageService.class).storeOrderbook(true).getGzBytes(); + byte[] gz = WicketSupport.get(OrderbookSnapshotsService.class).createOrderbookSnapshot().getGzBytes(); final String filename = "ProjectForge-Orderbook_" + DateHelper.getDateAsFilenameSuffix(new Date()) + ".gz"; DownloadUtils.setDownloadTarget(gz, filename);