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'