diff --git a/doc/src/asciidoc/book.adoc b/doc/src/asciidoc/book.adoc
index 6ab8d0b080..b302e0ef2b 100644
--- a/doc/src/asciidoc/book.adoc
+++ b/doc/src/asciidoc/book.adoc
@@ -83,6 +83,7 @@ include::module_fsdmsgX.adoc[]
include::entities/sysconfig.adoc[]
include::entities/syslog.adoc[]
include::entities/eeuser.adoc[]
+include::entities/seqno.adoc[]
= Appendices
diff --git a/doc/src/asciidoc/entities/seqno.adoc b/doc/src/asciidoc/entities/seqno.adoc
new file mode 100644
index 0000000000..174f2e39f4
--- /dev/null
+++ b/doc/src/asciidoc/entities/seqno.adoc
@@ -0,0 +1,66 @@
+[[seqno]]
+== SeqNo
+
+[plantuml, sysconfig, svg]
+----
+@startuml
+
+class SeqNo {
+ String id;
+ long value;
+ long lockedBy;
+ long lockUntil;
+}
+
+@enduml
+----
+
+The `SeqNo` entity footnote:['seqno' table] is a general purpose entity
+used by jPOS-EE to store application specific sequencers.
+
+Typical use case would be terminal-level STANS, Voucher IDs and the like.
+
+The `SeqNoManager` supports two operating modes:
+
+- Synchronous
+- Asynchronous
+
+In Synchronous mode, the transaction life-cycle is handled by the caller, e.g.:
+
+[source,java]
+-------------
+ private long next(String id) {
+ try (DB db = new DB()) {
+ SeqNoManager mgr = new SeqNoManager(db);
+ db.open();
+ db.beginTransaction();
+ long l = mgr.next(id, 999999L);
+ db.commit();
+ return l;
+ }
+ }
+-------------
+
+[NOTE]
+======
+In 'sync' mode, a second thread trying to obtain a sequence number will block until the
+former is committed.
+
+======
+
+In Asynchronous mode, the JDBC connection is released and an explicit call to `SeqNoManager.release`
+has to be issued, e.g.:
+
+[source,java]
+-------------
+ SeqNoManager mgr = new SeqNoManager(new DB());
+ long l = mgr.next("sync", id, 60000L, 60000L, 999999L);
+ // ... do something, e.g.: query remote host
+ mgr.release("sync", id);
+ return l;
+-------------
+
+Interesting thing about async mode, is that while one calls a remote host (i.e. using `QueryHost`),
+the JDBC connection gets released back to its pool.
+
+
diff --git a/modules/seqno/build.gradle b/modules/seqno/build.gradle
new file mode 100644
index 0000000000..cf1df89ae7
--- /dev/null
+++ b/modules/seqno/build.gradle
@@ -0,0 +1,26 @@
+description = 'jPOS-EE :: SeqNo'
+
+dependencies {
+ compile project(':modules:dbsupport')
+ testCompile project(':modules:db-h2')
+ // compile project(':modules:db-mysql')
+}
+
+
+ext {
+ testRuntimeDir = "$buildDir/runtime" as File
+}
+
+task jposeeSetup(dependsOn: 'classes', type: JavaExec) {
+ classpath = sourceSets.test.runtimeClasspath
+ main = 'org.jpos.q2.install.Install'
+ args = ["--quiet", "--force", "--outputDir=" + testRuntimeDir]
+}
+
+test {
+ dependsOn 'jposeeSetup'
+ scanForTestClasses true
+ workingDir testRuntimeDir
+}
+
+apply from: "${rootProject.projectDir}/jpos-app.gradle"
diff --git a/modules/seqno/src/dist/log/q2.log b/modules/seqno/src/dist/log/q2.log
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/modules/seqno/src/main/java/org/jpos/ee/SeqNo.java b/modules/seqno/src/main/java/org/jpos/ee/SeqNo.java
new file mode 100644
index 0000000000..351cf4de98
--- /dev/null
+++ b/modules/seqno/src/main/java/org/jpos/ee/SeqNo.java
@@ -0,0 +1,129 @@
+/*
+ * jPOS Project [http://jpos.org]
+ * Copyright (C) 2000-2019 jPOS Software SRL
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jpos.ee;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+import org.hibernate.annotations.CacheConcurrencyStrategy;
+
+import javax.persistence.*;
+
+/**
+ * SeqNo can be used to manage application level sequencers and both synchronous as well as asynchronous locking.
+ *
+ * In synchronous mode, a lock is placed on SeqNo.id
.
+ * In asynchronous mode, a unique lockedBy
identifier is used, and the entry is considered locked
+ * until a given timeout, or gets released by calling SeqNoManager.release
.
+ *
+ * While this entity support both modes (sync/async), applications shouldn't mix them.
+ *
+ * @see org.jpos.ee.SeqNoManager
+ * @since 2.2.6
+ */
+
+@Entity
+@Table(name = "seqno")
+@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
+@SuppressWarnings("unused")
+public class SeqNo extends Cloneable implements Serializable {
+ private static final long serialVersionUID = -1475470843801587648L;
+ @Id
+ @Column(length=128)
+ private String id;
+
+ private long value;
+ private long lockedBy;
+ private long lockUntil;
+
+ public SeqNo(String id) {
+ this.id = id;
+ }
+
+ public SeqNo() {
+ super();
+ }
+
+ public String getId() {
+ return this.id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public long getValue() {
+ return this.value;
+ }
+
+ public void setValue(long value) {
+ this.value = value;
+ }
+
+ /**
+ * Returns next sequence number.
+ *
+ * @param wrapAt greater than wraps back to 1
+ * @return next sequence number
+ */
+ public long next (long wrapAt) {
+ synchronized (this) {
+ if (++value > wrapAt)
+ value = 1;
+ }
+ return value;
+ }
+
+ public long getLockedBy() {
+ return lockedBy;
+ }
+
+ public void setLockedBy(long lockedBy) {
+ this.lockedBy = lockedBy;
+ }
+
+ public long getLockUntil() {
+ return lockUntil;
+ }
+
+ public void setLockUntil(long lockUntil) {
+ this.lockUntil = lockUntil;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SeqNo seqNo = (SeqNo) o;
+ return value == seqNo.value && Objects.equals(id, seqNo.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, value);
+ }
+
+ @Override
+ public String toString() {
+ return "SeqNo{" +
+ "id='" + id + '\'' +
+ ", value=" + value +
+ '}';
+ }
+}
diff --git a/modules/seqno/src/main/java/org/jpos/ee/SeqNoManager.java b/modules/seqno/src/main/java/org/jpos/ee/SeqNoManager.java
new file mode 100644
index 0000000000..b45b0afa33
--- /dev/null
+++ b/modules/seqno/src/main/java/org/jpos/ee/SeqNoManager.java
@@ -0,0 +1,137 @@
+/*
+ * jPOS Project [http://jpos.org]
+ * Copyright (C) 2000-2019 jPOS Software SRL
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jpos.ee;
+
+import org.hibernate.LockMode;
+import org.jpos.iso.ISOUtil;
+import javax.persistence.LockTimeoutException;
+
+/**
+ * SeqNoManager can be used to manage application level sequencers and both synchronous as well as asynchronous locking.
+ *
+ * This Manager can operate in synchronous or asynchronous way.
+ * In sync mode, the caller needs to provide the transaction life-cycle
+ * (open, beginTransaction, commit, close
).
+ *
+ * In async mode the application has to provide a pristine (not opened) DB object
+ * and the manager takes care of the transaction lifecycle.
+ *
+ * @see org.jpos.ee.SeqNoManager
+ * @since 2.2.6
+ */
+public class SeqNoManager {
+ private DB db;
+
+ public SeqNoManager(DB db) {
+ this.db = db;
+ }
+
+
+ /**
+ * Synchronous 'next'
+ * @param id sequencer id
+ * @param wrapAt wrap at value
+ * @return next sequencer value
+ */
+ public long next (String id, long wrapAt) {
+ return getOrCreate(id).next(wrapAt);
+ }
+
+ /**
+ * Synchronous 'reset'
+ * @param id sequencer id
+ * @param value reset value
+ */
+ public void reset (String id, long value) {
+ getOrCreate(id).setValue(value);
+ }
+
+ /**
+ * Asynchronous 'next'
+ *
+ * @param id sequencer ID
+ * @param lockedBy unique client identifier (any long, has to be system-wide unique)
+ * @param lockTimeout once lock is obtained, keep it fo 'lockTimeout' millis (if not 'released' earlier)
+ * @param timeout time (in millis) to wait for this lock
+ * @param wrapAt wrap at value
+ * @return next sequencer value
+ * @throws LockTimeoutException if lock can't be obtained after 'timeout' has elapsed
+ */
+ public long next (String id, long lockedBy, long lockTimeout, long timeout, long wrapAt) {
+ long until = System.currentTimeMillis() + timeout;
+ if (db.session != null && db.session.isOpen())
+ throw new IllegalStateException("DB should not be open");
+ while (System.currentTimeMillis() < until) {
+ try (DB db1 = db) {
+ db1.open();
+ db1.beginTransaction();
+ SeqNo seq = getOrCreate(id);
+ long now = System.currentTimeMillis();
+ if (seq.getLockedBy() == 0 || seq.getLockUntil() < now) {
+ seq.setLockedBy(lockedBy);
+ seq.setLockUntil(now + lockTimeout);
+ long stan = seq.next(wrapAt);
+ db1.commit();
+ return stan;
+ }
+ db1.commit();
+ ISOUtil.sleep(500L);
+ }
+ }
+ throw new LockTimeoutException("Unable to lock " + id + " in less than " + timeout + " millis");
+ }
+
+ /**
+ * Release an async lock
+ * @param id lock ID
+ * @param lockedBy unique client identifier
+ */
+ public void release (String id, long lockedBy) {
+ if (db.session != null && db.session.isOpen())
+ throw new IllegalStateException("DB should not be open");
+ try (DB db1 = db) {
+ db1.open();
+ db1.beginTransaction();
+ SeqNo seq = getOrCreate(id);
+ if (seq.getLockedBy() == lockedBy) {
+ seq.setLockedBy(0L);
+ db1.commit();
+ }
+ }
+ }
+
+ private SeqNo getOrCreate(String id) {
+ SeqNo seq = db.session().get(SeqNo.class, id, LockMode.PESSIMISTIC_WRITE);
+ if (seq == null) {
+ create (id);
+ seq = db.session().get(SeqNo.class, id, LockMode.PESSIMISTIC_WRITE);
+ }
+ return seq;
+ }
+
+ private void create (String id) {
+ try (DB db = new DB()) {
+ db.open();
+ db.beginTransaction();
+ SeqNo seq = new SeqNo(id);
+ db.session().save(seq);
+ db.commit();
+ } catch (Exception ignored) { }
+ }
+}
diff --git a/modules/seqno/src/main/resources/META-INF/org/jpos/ee/modules/seqno.xml b/modules/seqno/src/main/resources/META-INF/org/jpos/ee/modules/seqno.xml
new file mode 100644
index 0000000000..99c6a7b98b
--- /dev/null
+++ b/modules/seqno/src/main/resources/META-INF/org/jpos/ee/modules/seqno.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/modules/seqno/src/test/java/org/jpos/ee/SeqNoTest.java b/modules/seqno/src/test/java/org/jpos/ee/SeqNoTest.java
new file mode 100644
index 0000000000..afd780b2c1
--- /dev/null
+++ b/modules/seqno/src/test/java/org/jpos/ee/SeqNoTest.java
@@ -0,0 +1,108 @@
+/*
+ * jPOS Project [http://jpos.org]
+ * Copyright (C) 2000-2019 jPOS Software SRL
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.jpos.ee;
+
+import org.dom4j.DocumentException;
+import org.jpos.iso.ISOUtil;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+
+public class SeqNoTest {
+ @BeforeClass
+ public static void setUp() throws DocumentException {
+ try (DB db = new DB()) {
+ db.createSchema(null, true);
+ }
+ }
+
+ @Test
+ public void testSyncLock() throws InterruptedException {
+ int runs = 20;
+ List tl = new ArrayList<>();
+ Random r = new Random();
+ for (int i=0; i {
+ next(10 + Math.abs(r.nextLong()) % 100);
+ });
+ tl.add(t);
+ t.start();
+ }
+ for (Thread t : tl) {
+ t.join();
+ }
+ assertEquals("seq value incorrect", ++runs, next(1L));
+ reset();
+ assertEquals("reset failed", 1L, next(1L));
+ reset();
+ }
+
+ @Test
+ public void testAsyncLock() throws InterruptedException {
+ int runs = 20;
+ List tl = new ArrayList<>();
+ Random r = new Random();
+ for (int i=0; i {
+ nextAsync(lockId, 10 + Math.abs(r.nextLong()) % 100);
+ });
+ tl.add(t);
+ t.start();
+ }
+ for (Thread t : tl) {
+ t.join();
+ }
+ assertEquals("seq value incorrect", ++runs, next(1L));
+ }
+
+ private long next(long delay) {
+ try (DB db = new DB()) {
+ SeqNoManager mgr = new SeqNoManager(db);
+ db.open();
+ db.beginTransaction();
+ long l = mgr.next("sync", 999999L);
+ ISOUtil.sleep(delay);
+ db.commit();
+ return l;
+ }
+ }
+
+ private long nextAsync(long id, long delay) {
+ SeqNoManager mgr = new SeqNoManager(new DB());
+ long l = mgr.next("sync", id, 60000L, 60000L, 999999L);
+ ISOUtil.sleep(delay);
+ mgr.release("sync", id);
+ return l;
+ }
+ private void reset() {
+ try (DB db = new DB()) {
+ SeqNoManager mgr = new SeqNoManager(db);
+ db.open();
+ db.beginTransaction();
+ mgr.reset("sync", 0L);
+ db.commit();
+ }
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index fd6bb85c62..eb627fcb4b 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -41,7 +41,8 @@ include ':modules:core',
':modules:iso-http-client',
':modules:iso-http-server',
':modules:iso-http-servlet',
- ':modules:http-client'
+ ':modules:http-client',
+ ':modules:seqno'
rootProject.name = 'jposee'