2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.persistence.rrd4j.internal;
15 import java.io.IOException;
16 import java.nio.file.Files;
17 import java.nio.file.Path;
18 import java.time.Instant;
19 import java.time.ZoneId;
20 import java.time.ZonedDateTime;
21 import java.util.ArrayList;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.Iterator;
25 import java.util.List;
26 import java.util.Locale;
29 import java.util.concurrent.ConcurrentHashMap;
30 import java.util.concurrent.Executors;
31 import java.util.concurrent.RejectedExecutionException;
32 import java.util.concurrent.ScheduledExecutorService;
33 import java.util.concurrent.ScheduledFuture;
34 import java.util.concurrent.TimeUnit;
36 import javax.measure.Quantity;
37 import javax.measure.Unit;
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.openhab.core.OpenHAB;
42 import org.openhab.core.common.NamedThreadFactory;
43 import org.openhab.core.items.GroupItem;
44 import org.openhab.core.items.Item;
45 import org.openhab.core.items.ItemNotFoundException;
46 import org.openhab.core.items.ItemRegistry;
47 import org.openhab.core.items.ItemUtil;
48 import org.openhab.core.library.CoreItemFactory;
49 import org.openhab.core.library.items.ColorItem;
50 import org.openhab.core.library.items.ContactItem;
51 import org.openhab.core.library.items.DimmerItem;
52 import org.openhab.core.library.items.NumberItem;
53 import org.openhab.core.library.items.RollershutterItem;
54 import org.openhab.core.library.items.SwitchItem;
55 import org.openhab.core.library.types.DecimalType;
56 import org.openhab.core.library.types.OnOffType;
57 import org.openhab.core.library.types.OpenClosedType;
58 import org.openhab.core.library.types.PercentType;
59 import org.openhab.core.library.types.QuantityType;
60 import org.openhab.core.persistence.FilterCriteria;
61 import org.openhab.core.persistence.FilterCriteria.Ordering;
62 import org.openhab.core.persistence.HistoricItem;
63 import org.openhab.core.persistence.PersistenceItemInfo;
64 import org.openhab.core.persistence.PersistenceService;
65 import org.openhab.core.persistence.QueryablePersistenceService;
66 import org.openhab.core.persistence.strategy.PersistenceCronStrategy;
67 import org.openhab.core.persistence.strategy.PersistenceStrategy;
68 import org.openhab.core.types.State;
69 import org.osgi.service.component.annotations.Activate;
70 import org.osgi.service.component.annotations.Component;
71 import org.osgi.service.component.annotations.ConfigurationPolicy;
72 import org.osgi.service.component.annotations.Modified;
73 import org.osgi.service.component.annotations.Reference;
74 import org.rrd4j.ConsolFun;
75 import org.rrd4j.DsType;
76 import org.rrd4j.core.FetchData;
77 import org.rrd4j.core.FetchRequest;
78 import org.rrd4j.core.RrdDb;
79 import org.rrd4j.core.RrdDb.Builder;
80 import org.rrd4j.core.RrdDbPool;
81 import org.rrd4j.core.RrdDef;
82 import org.rrd4j.core.Sample;
83 import org.slf4j.Logger;
84 import org.slf4j.LoggerFactory;
87 * This is the implementation of the RRD4j {@link PersistenceService}. To learn
88 * more about RRD4j please visit their
89 * <a href="https://github.com/rrd4j/rrd4j">website</a>.
91 * @author Kai Kreuzer - Initial contribution
92 * @author Jan N. Klug - some improvements
93 * @author Karel Goderis - remove TimerThread dependency
96 @Component(service = { PersistenceService.class,
97 QueryablePersistenceService.class }, configurationPid = "org.openhab.rrd4j", configurationPolicy = ConfigurationPolicy.OPTIONAL)
98 public class RRD4jPersistenceService implements QueryablePersistenceService {
100 private static final String DEFAULT_OTHER = "default_other";
101 private static final String DEFAULT_NUMERIC = "default_numeric";
102 private static final String DEFAULT_QUANTIFIABLE = "default_quantifiable";
104 private static final Set<String> SUPPORTED_TYPES = Set.of(CoreItemFactory.SWITCH, CoreItemFactory.CONTACT,
105 CoreItemFactory.DIMMER, CoreItemFactory.NUMBER, CoreItemFactory.ROLLERSHUTTER, CoreItemFactory.COLOR);
107 private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(3,
108 new NamedThreadFactory("RRD4j"));
110 private final Map<String, RrdDefConfig> rrdDefs = new ConcurrentHashMap<>();
112 private static final String DATASOURCE_STATE = "state";
114 private static final Path DB_FOLDER = Path.of(OpenHAB.getUserDataFolder(), "persistence", "rrd4j").toAbsolutePath();
116 private static final RrdDbPool DATABASE_POOL = new RrdDbPool();
118 private final Logger logger = LoggerFactory.getLogger(RRD4jPersistenceService.class);
120 private final Map<String, ScheduledFuture<?>> scheduledJobs = new HashMap<>();
122 private final ItemRegistry itemRegistry;
124 public static Path getDatabasePath(String name) {
125 return DB_FOLDER.resolve(name + ".rrd");
128 public static RrdDbPool getDatabasePool() {
129 return DATABASE_POOL;
133 public RRD4jPersistenceService(final @Reference ItemRegistry itemRegistry) {
134 this.itemRegistry = itemRegistry;
138 public String getId() {
143 public String getLabel(@Nullable Locale locale) {
148 public synchronized void store(final Item item, @Nullable final String alias) {
149 if (!isSupportedItemType(item)) {
150 logger.trace("Ignoring item '{}' since its type {} is not supported", item.getName(), item.getType());
153 final String name = alias == null ? item.getName() : alias;
158 } catch (Exception e) {
159 logger.warn("Failed to open rrd4j database '{}' to store data ({})", name, e.toString());
165 ConsolFun function = getConsolidationFunction(db);
166 long now = System.currentTimeMillis() / 1000;
167 if (function != ConsolFun.AVERAGE) {
169 // we store the last value again, so that the value change
170 // in the database is not interpolated, but
171 // happens right at this spot
172 if (now - 1 > db.getLastUpdateTime()) {
173 // only do it if there is not already a value
174 double lastValue = db.getLastDatasourceValue(DATASOURCE_STATE);
175 if (!Double.isNaN(lastValue)) {
176 Sample sample = db.createSample();
177 sample.setTime(now - 1);
178 sample.setValue(DATASOURCE_STATE, lastValue);
180 logger.debug("Stored '{}' as value '{}' in rrd4j database (again)", name, lastValue);
183 } catch (IOException e) {
184 logger.debug("Error storing last value (again): {}", e.getMessage());
188 Sample sample = db.createSample();
193 if (item instanceof NumberItem && item.getState() instanceof QuantityType) {
194 NumberItem nItem = (NumberItem) item;
195 QuantityType<?> qState = (QuantityType<?>) item.getState();
196 Unit<? extends Quantity<?>> unit = nItem.getUnit();
198 QuantityType<?> convertedState = qState.toUnit(unit);
199 if (convertedState != null) {
200 value = convertedState.doubleValue();
203 "Failed to convert state '{}' to unit '{}'. Please check your item definition for correctness.",
207 value = qState.doubleValue();
210 DecimalType state = item.getStateAs(DecimalType.class);
212 value = state.toBigDecimal().doubleValue();
216 if (db.getDatasource(DATASOURCE_STATE).getType() == DsType.COUNTER) { // counter values must be
217 // adjusted by stepsize
218 value = value * db.getRrdDef().getStep();
220 sample.setValue(DATASOURCE_STATE, value);
222 logger.debug("Stored '{}' as value '{}' in rrd4j database", name, value);
224 } catch (IllegalArgumentException e) {
225 String message = e.getMessage();
226 if (message != null && message.contains("at least one second step is required")) {
227 // we try to store the value one second later
228 ScheduledFuture<?> job = scheduledJobs.get(name);
231 scheduledJobs.remove(name);
233 job = scheduler.schedule(() -> store(item, name), 1, TimeUnit.SECONDS);
234 scheduledJobs.put(name, job);
236 logger.warn("Could not persist '{}' to rrd4j database: {}", name, e.getMessage());
238 } catch (Exception e) {
239 logger.warn("Could not persist '{}' to rrd4j database: {}", name, e.getMessage());
243 } catch (IOException e) {
244 logger.debug("Error closing rrd4j database: {}", e.getMessage());
249 public void store(Item item) {
254 public Iterable<HistoricItem> query(FilterCriteria filter) {
255 String itemName = filter.getItemName();
259 db = getDB(itemName);
260 } catch (Exception e) {
261 logger.warn("Failed to open rrd4j database '{}' for querying ({})", itemName, e.toString());
265 logger.debug("Could not find item '{}' in rrd4j database", itemName);
272 item = itemRegistry.getItem(itemName);
273 if (item instanceof NumberItem) {
274 // we already retrieve the unit here once as it is a very costly operation,
275 // see https://github.com/openhab/openhab-addons/issues/8928
276 unit = ((NumberItem) item).getUnit();
278 } catch (ItemNotFoundException e) {
279 logger.debug("Could not find item '{}' in registry", itemName);
283 // set end to {@link Instant#MAX} instead of current timestamp to enable requesting future time ranges including
284 // boundary values via REST API
285 // see discussion in https://github.com/openhab/openhab-addons/pull/14238
286 long end = filter.getEndDate() == null ? Instant.MAX.getEpochSecond()
287 : filter.getEndDate().toInstant().getEpochSecond();
290 if (filter.getBeginDate() == null) {
291 // as rrd goes back for years and gets more and more
292 // inaccurate, we only support descending order
293 // and a single return value
294 // if there is no begin date is given - this case is
295 // required specifically for the historicState()
296 // query, which we want to support
297 if (filter.getOrdering() == Ordering.DESCENDING && filter.getPageSize() == 1
298 && filter.getPageNumber() == 0) {
299 if (filter.getEndDate() == null) {
300 // we are asked only for the most recent value!
301 double lastValue = db.getLastDatasourceValue(DATASOURCE_STATE);
302 if (!Double.isNaN(lastValue)) {
303 HistoricItem rrd4jItem = new RRD4jItem(itemName, mapToState(lastValue, item, unit),
304 ZonedDateTime.ofInstant(Instant.ofEpochMilli(db.getLastArchiveUpdateTime() * 1000),
305 ZoneId.systemDefault()));
306 return List.of(rrd4jItem);
314 throw new UnsupportedOperationException(
315 "rrd4j does not allow querys without a begin date, unless order is descending and a single value is requested");
318 start = filter.getBeginDate().toInstant().getEpochSecond();
321 // do not call method {@link RrdDb#createFetchRequest(ConsolFun, long, long, long)} if start > end to avoid
322 // an IAE to be thrown
324 logger.warn("Could not query rrd4j database for item '{}': start ({}) > end ({})", itemName, start,
329 FetchRequest request = db.createFetchRequest(getConsolidationFunction(db), start, end, 1);
330 FetchData result = request.fetchData();
332 List<HistoricItem> items = new ArrayList<>();
333 long ts = result.getFirstTimestamp();
334 long step = result.getRowCount() > 1 ? result.getStep() : 0;
335 for (double value : result.getValues(DATASOURCE_STATE)) {
336 if (!Double.isNaN(value) && (((ts >= start) && (ts <= end)) || (start == end))) {
337 RRD4jItem rrd4jItem = new RRD4jItem(itemName, mapToState(value, item, unit),
338 ZonedDateTime.ofInstant(Instant.ofEpochSecond(ts), ZoneId.systemDefault()));
339 items.add(rrd4jItem);
344 } catch (IOException e) {
345 logger.warn("Could not query rrd4j database for item '{}': {}", itemName, e.getMessage());
350 } catch (IOException e) {
351 logger.debug("Error closing rrd4j database: {}", e.getMessage());
357 public Set<PersistenceItemInfo> getItemInfo() {
361 protected synchronized @Nullable RrdDb getDB(String alias) {
363 Path path = getDatabasePath(alias);
365 Builder builder = RrdDb.getBuilder();
366 builder.setPool(DATABASE_POOL);
368 if (Files.exists(path)) {
369 // recreate the RrdDb instance from the file
370 builder.setPath(path.toString());
371 db = builder.build();
373 if (!Files.exists(DB_FOLDER)) {
374 Files.createDirectories(DB_FOLDER);
376 RrdDef rrdDef = getRrdDef(alias, path);
377 if (rrdDef != null) {
378 // create a new database file
379 builder.setRrdDef(rrdDef);
380 db = builder.build();
383 "Did not create rrd4j database for item '{}' since no rrd definition could be determined. This is likely due to an unsupported item type.",
387 } catch (IOException e) {
388 logger.error("Could not create rrd4j database file '{}': {}", path, e.getMessage());
389 } catch (RejectedExecutionException e) {
390 // this happens if the system is shut down
391 logger.debug("Could not create rrd4j database file '{}': {}", path, e.getMessage());
396 private @Nullable RrdDefConfig getRrdDefConfig(String itemName) {
397 RrdDefConfig useRdc = null;
398 for (Map.Entry<String, RrdDefConfig> e : rrdDefs.entrySet()) {
399 // try to find special config
400 RrdDefConfig rdc = e.getValue();
401 if (rdc.appliesTo(itemName)) {
406 if (useRdc == null) { // not defined, use defaults
408 Item item = itemRegistry.getItem(itemName);
409 if (!isSupportedItemType(item)) {
412 if (item instanceof NumberItem) {
413 NumberItem numberItem = (NumberItem) item;
414 useRdc = numberItem.getDimension() != null ? rrdDefs.get(DEFAULT_QUANTIFIABLE)
415 : rrdDefs.get(DEFAULT_NUMERIC);
417 useRdc = rrdDefs.get(DEFAULT_OTHER);
419 } catch (ItemNotFoundException e) {
420 logger.debug("Could not find item '{}' in registry", itemName);
424 logger.trace("Using rrd definition '{}' for item '{}'.", useRdc, itemName);
428 private @Nullable RrdDef getRrdDef(String itemName, Path path) {
429 RrdDef rrdDef = new RrdDef(path.toString());
430 RrdDefConfig useRdc = getRrdDefConfig(itemName);
431 if (useRdc != null) {
432 rrdDef.setStep(useRdc.step);
433 rrdDef.setStartTime(System.currentTimeMillis() / 1000 - 1);
434 rrdDef.addDatasource(DATASOURCE_STATE, useRdc.dsType, useRdc.heartbeat, useRdc.min, useRdc.max);
435 for (RrdArchiveDef rad : useRdc.archives) {
436 rrdDef.addArchive(rad.fcn, rad.xff, rad.steps, rad.rows);
444 public ConsolFun getConsolidationFunction(RrdDb db) {
446 return db.getRrdDef().getArcDefs()[0].getConsolFun();
447 } catch (IOException e) {
448 return ConsolFun.MAX;
452 @SuppressWarnings({ "unchecked", "rawtypes" })
453 private State mapToState(double value, @Nullable Item item, @Nullable Unit unit) {
454 if (item instanceof GroupItem) {
455 item = ((GroupItem) item).getBaseItem();
458 if (item instanceof SwitchItem && !(item instanceof DimmerItem)) {
459 return value == 0.0d ? OnOffType.OFF : OnOffType.ON;
460 } else if (item instanceof ContactItem) {
461 return value == 0.0d ? OpenClosedType.CLOSED : OpenClosedType.OPEN;
462 } else if (item instanceof DimmerItem || item instanceof RollershutterItem || item instanceof ColorItem) {
463 // make sure Items that need PercentTypes instead of DecimalTypes do receive the right information
464 return new PercentType((int) Math.round(value * 100));
465 } else if (item instanceof NumberItem) {
467 return new QuantityType(value, unit);
470 return new DecimalType(value);
473 private boolean isSupportedItemType(Item item) {
474 if (item instanceof GroupItem) {
475 final Item baseItem = ((GroupItem) item).getBaseItem();
476 if (baseItem != null) {
481 return SUPPORTED_TYPES.contains(ItemUtil.getMainItemType(item.getType()));
485 protected void activate(final Map<String, Object> config) {
490 protected void modified(final Map<String, Object> config) {
491 // clean existing definitions
494 // add default configurations
496 RrdDefConfig defaultNumeric = new RrdDefConfig(DEFAULT_NUMERIC);
497 // use 10 seconds as a step size for numeric values and allow a 10 minute silence between updates
498 defaultNumeric.setDef("GAUGE,600,U,U,10");
499 // define 5 different boxes:
500 // 1. granularity of 10s for the last hour
501 // 2. granularity of 1m for the last week
502 // 3. granularity of 15m for the last year
503 // 4. granularity of 1h for the last 5 years
504 // 5. granularity of 1d for the last 10 years
506 .addArchives("LAST,0.5,1,360:LAST,0.5,6,10080:LAST,0.5,90,36500:LAST,0.5,360,43800:LAST,0.5,8640,3650");
507 rrdDefs.put(DEFAULT_NUMERIC, defaultNumeric);
509 RrdDefConfig defaultQuantifiable = new RrdDefConfig(DEFAULT_QUANTIFIABLE);
510 // use 10 seconds as a step size for numeric values and allow a 10 minute silence between updates
511 defaultQuantifiable.setDef("GAUGE,600,U,U,10");
512 // define 5 different boxes:
513 // 1. granularity of 10s for the last hour
514 // 2. granularity of 1m for the last week
515 // 3. granularity of 15m for the last year
516 // 4. granularity of 1h for the last 5 years
517 // 5. granularity of 1d for the last 10 years
518 defaultQuantifiable.addArchives(
519 "AVERAGE,0.5,1,360:AVERAGE,0.5,6,10080:AVERAGE,0.5,90,36500:AVERAGE,0.5,360,43800:AVERAGE,0.5,8640,3650");
520 rrdDefs.put(DEFAULT_QUANTIFIABLE, defaultQuantifiable);
522 RrdDefConfig defaultOther = new RrdDefConfig(DEFAULT_OTHER);
523 // use 5 seconds as a step size for discrete values and allow a 1h silence between updates
524 defaultOther.setDef("GAUGE,3600,U,U,5");
525 // define 4 different boxes:
526 // 1. granularity of 5s for the last hour
527 // 2. granularity of 1m for the last week
528 // 3. granularity of 15m for the last year
529 // 4. granularity of 4h for the last 10 years
530 defaultOther.addArchives("LAST,0.5,1,720:LAST,0.5,12,10080:LAST,0.5,180,35040:LAST,0.5,2880,21900");
531 rrdDefs.put(DEFAULT_OTHER, defaultOther);
533 if (config.isEmpty()) {
534 logger.debug("using default configuration only");
538 Iterator<String> keys = config.keySet().iterator();
539 while (keys.hasNext()) {
540 String key = keys.next();
542 if ("service.pid".equals(key) || "component.name".equals(key)) {
543 // ignore service.pid and name
547 String[] subkeys = key.split("\\.");
548 if (subkeys.length != 2) {
549 logger.debug("config '{}' should have the format 'name.configkey'", key);
553 Object v = config.get(key);
554 if (v instanceof String) {
555 String value = (String) v;
556 String name = subkeys[0].toLowerCase();
557 String property = subkeys[1].toLowerCase();
559 if (value.isBlank()) {
560 logger.trace("Config is empty: {}", property);
563 logger.trace("Processing config: {} = {}", property, value);
566 RrdDefConfig rrdDef = rrdDefs.get(name);
567 if (rrdDef == null) {
568 rrdDef = new RrdDefConfig(name);
569 rrdDefs.put(name, rrdDef);
573 if ("def".equals(property)) {
574 rrdDef.setDef(value);
575 } else if ("archives".equals(property)) {
576 rrdDef.addArchives(value);
577 } else if ("items".equals(property)) {
578 rrdDef.addItems(value);
580 logger.debug("Unknown property {} : {}", property, value);
582 } catch (IllegalArgumentException e) {
583 logger.warn("Ignoring illegal configuration: {}", e.getMessage());
588 for (RrdDefConfig rrdDef : rrdDefs.values()) {
589 if (rrdDef.isValid()) {
590 logger.debug("Created {}", rrdDef);
592 logger.info("Removing invalid definition {}", rrdDef);
593 rrdDefs.remove(rrdDef.name);
598 private static class RrdArchiveDef {
599 public @Nullable ConsolFun fcn;
601 public int steps, rows;
604 public String toString() {
605 StringBuilder sb = new StringBuilder(" " + fcn);
606 sb.append(" xff = ").append(xff);
607 sb.append(" steps = ").append(steps);
608 sb.append(" rows = ").append(rows);
609 return sb.toString();
613 private class RrdDefConfig {
615 public @Nullable DsType dsType;
616 public int heartbeat, step;
617 public double min, max;
618 public List<RrdArchiveDef> archives;
619 public List<String> itemNames;
621 private boolean isInitialized;
623 public RrdDefConfig(String name) {
625 archives = new ArrayList<>();
626 itemNames = new ArrayList<>();
627 isInitialized = false;
630 public void setDef(String defString) {
631 String[] opts = defString.split(",");
632 if (opts.length != 5) { // check if correct number of parameters
633 logger.warn("invalid number of parameters {}: {}", name, defString);
637 if ("ABSOLUTE".equals(opts[0])) { // dsType
638 dsType = DsType.ABSOLUTE;
639 } else if ("COUNTER".equals(opts[0])) {
640 dsType = DsType.COUNTER;
641 } else if ("DERIVE".equals(opts[0])) {
642 dsType = DsType.DERIVE;
643 } else if ("GAUGE".equals(opts[0])) {
644 dsType = DsType.GAUGE;
646 logger.warn("{}: dsType {} not supported", name, opts[0]);
649 heartbeat = Integer.parseInt(opts[1]);
651 if ("U".equals(opts[2])) {
654 min = Double.parseDouble(opts[2]);
657 if ("U".equals(opts[3])) {
660 max = Double.parseDouble(opts[3]);
663 step = Integer.parseInt(opts[4]);
665 isInitialized = true; // successfully initialized
670 public void addArchives(String archivesString) {
671 String splitArchives[] = archivesString.split(":");
672 for (String archiveString : splitArchives) {
673 String[] opts = archiveString.split(",");
674 if (opts.length != 4) { // check if correct number of parameters
675 logger.warn("invalid number of parameters {}: {}", name, archiveString);
678 RrdArchiveDef arc = new RrdArchiveDef();
680 if ("AVERAGE".equals(opts[0])) {
681 arc.fcn = ConsolFun.AVERAGE;
682 } else if ("MIN".equals(opts[0])) {
683 arc.fcn = ConsolFun.MIN;
684 } else if ("MAX".equals(opts[0])) {
685 arc.fcn = ConsolFun.MAX;
686 } else if ("LAST".equals(opts[0])) {
687 arc.fcn = ConsolFun.LAST;
688 } else if ("FIRST".equals(opts[0])) {
689 arc.fcn = ConsolFun.FIRST;
690 } else if ("TOTAL".equals(opts[0])) {
691 arc.fcn = ConsolFun.TOTAL;
693 logger.warn("{}: consolidation function {} not supported", name, opts[0]);
695 arc.xff = Double.parseDouble(opts[1]);
696 arc.steps = Integer.parseInt(opts[2]);
697 arc.rows = Integer.parseInt(opts[3]);
702 public void addItems(String itemsString) {
703 Collections.addAll(itemNames, itemsString.split(","));
706 public boolean appliesTo(String item) {
707 return itemNames.contains(item);
710 public boolean isValid() { // a valid configuration must be initialized
711 // and contain at least one function
712 return isInitialized && !archives.isEmpty();
716 public String toString() {
717 StringBuilder sb = new StringBuilder(name);
718 sb.append(" = ").append(dsType);
719 sb.append(" heartbeat = ").append(heartbeat);
720 sb.append(" min/max = ").append(min).append("/").append(max);
721 sb.append(" step = ").append(step);
722 sb.append(" ").append(archives.size()).append(" archives(s) = [");
723 for (RrdArchiveDef arc : archives) {
724 sb.append(arc.toString());
727 sb.append(itemNames.size()).append(" items(s) = [");
728 for (String item : itemNames) {
729 sb.append(item).append(" ");
732 return sb.toString();
737 public List<PersistenceStrategy> getDefaultStrategies() {
738 return List.of(PersistenceStrategy.Globals.RESTORE, PersistenceStrategy.Globals.CHANGE,
739 new PersistenceCronStrategy("everyMinute", "0 * * * * ?"));