Number Sources — Unique Business Numbers¶
Overview and Motivation¶
Applications need unique numbers that users see and refer to: invoice numbers,
order numbers, message numbers, customer numbers. These are not object ids. An
object id is a technical surrogate key, invisible to the user, handed out by an
IdSource
and never reused. A business number is part of the domain: it may be reserved in
blocks, carved up between sites, handed back when a document is discarded, and
partitioned per tenant or per year.
A number source (NumberSource)
is Tentackle's answer for the second kind. It is a persistent, transactional pool
of numbers that supports both directions — you can pop numbers out of it and
push numbers back into it — and it is backed by ordinary
PDOs, which means it is backend-agnostic, validated, secured, tracked,
and part of the model like any other entity.
The subsystem has two layers:
| Layer | Package | What it is |
|---|---|---|
| The API | org.tentackle.ns |
NumberSource, NumberSourceFactory and the default implementation. |
| The storage | org.tentackle.ns.pdo |
NumberPool and NumberRange — the PDOs the default implementation persists into. |
The two are deliberately separate: NumberSource is an interface with a
service-provided factory, so an
application that must draw its numbers from somewhere else entirely can replace the
implementation without the pool entities coming along.
The NumberSource API¶
A source hands out longs and describes itself in terms of ranges. The
Range record is half-open — begin is the first number, end is the last number
plus one, exactly like subList or IntStream.range:
| Method | Purpose |
|---|---|
popNumber() |
Take the next single number. Throws NumberSourceEmptyException if the pool is empty. |
popNumbers(long count) |
Take count numbers. Returns them as ranges, ascending — they need not be contiguous. |
popNumbers(long begin, long end) |
Take a specific interval, if the pool holds it. Returns the parts actually consumed. |
pushNumber(long number) |
Give one number back. A number already in the pool is ignored. |
pushNumbers(long begin, long end) |
Give an interval back. May overlap what the pool already holds. |
getRanges() |
The current ranges, consuming nothing. |
getCount() |
How many numbers remain. |
isEmpty() / isOnline() |
Whether the pool is exhausted / enabled. |
Failures are unchecked:
NumberSourceException
extends TentackleRuntimeException, and
NumberSourceEmptyException
extends it in turn — so "empty" can be caught on its own or with everything else.
Two subtleties worth knowing¶
popNumbers(count) may return fewer than count numbers. If the pool is empty
when the call starts, it throws. If it runs dry part-way through, it returns what
it managed to collect and stops. Always check the sizes of the returned ranges
rather than assuming they sum to count:
long got = 0;
for (Range r : ns.popNumbers(500)) {
got += r.size();
}
if (got < 500) { /* pool ran dry */ }
popNumbers(begin, end) is a request, not a reservation. It consumes only the
parts of the interval the pool actually holds, and returns exactly those parts. It
does not throw when the interval is absent — it returns an empty array.
Getting a Number Source¶
Number sources are addressed by two strings, a pool name and an optional realm, and reached through the factory singleton:
NumberSource ns = NumberSourceFactory.getInstance().getNumberSource("MSG", "myapp");
long number = ns.popNumber();
The realm partitions one logical pool into independent number spaces. The generated
project uses the application name as the realm
(Constants.POOL_REALM), so several applications can share a database without
sharing message numbers; a multi-tenant application would use the tenant, a
year-scoped numbering scheme the year.
NumberSourceFactory is an SPI
singleton resolved through ServiceFactory.createService(...), defaulting to
DefaultNumberSourceFactory
(annotated @Service(NumberSourceFactory.class)). The default factory caches one
source per pool:realm key, and throws NumberSourceException if no such pool
exists in the database — a missing pool is a configuration error, not an empty
result.
The Model — NumberPool and NumberRange¶
The default implementation stores a pool as an aggregate: a NumberPool root
entity owning a composite list of NumberRange components. The pool holds the
policy; the ranges hold the actual numbers.
NumberPool — table numpool,
class id 6, options [root, remote, fulltracked]:
| Attribute | Column | Type | Purpose |
|---|---|---|---|
name |
name |
String(30) |
The pool name. Domain key, @NotNull. |
realm |
realm |
String(80) |
Optional partition. [MAPNULL]. |
description |
description |
String |
Free-text description. |
online |
poolonline |
boolean |
Whether the pool may be drawn from. |
lowWaterMark |
lowmark |
long |
Uplink refill threshold; 0 disables. |
requestSize |
reqsize |
long |
Uplink request size; 0 disables. |
uplink |
uplink |
String |
Uplink configuration; see Uplinks. |
A unique index udk spans name, realm. Because realm is declared [MAPNULL],
a null realm is stored as the backend's empty-string constant rather than SQL
NULL — which keeps the unique index meaningful for pools that have no realm, where
NULL values would otherwise not compare equal to each other.
NumberRange — table numrange,
class id 7, options [remote, fulltracked]:
| Attribute | Column | Type | Purpose |
|---|---|---|---|
numberPoolId |
poolid |
long |
The owning pool. |
begin |
rbegin |
long |
First number in the range. |
end |
rend |
long |
Last number + 1. |
Both entities declare integrity := none: no foreign keys are emitted, because the aggregate
is managed programmatically which allows performance optimizations and an efficient caching
when handling with the pool's ranges.
The relation is a composite list with delete = cascade: deleting a pool deletes its
ranges, which is necessary due to integrity := none.
Class ids 6 and 7 are framework-reserved; an application's own entities must not
claim them.
Setting Up a Pool¶
A pool is created like any other aggregate — build the root, add components, save.
This is what the generated project's FillDatabase does:
NumberPool pool = on(NumberPool.class);
pool.setRealm(Constants.POOL_REALM);
pool.setName(Message.POOL_NAME);
pool.setDescription("pool for message numbers");
pool.setOnline(true);
NumberRange range = on(NumberRange.class);
range.setBegin(1000000);
range.setEnd (9999999);
pool.getNumberRangeList().add(range);
pool.save();
A pool may hold several disjoint ranges, and they need not be added in order —
numbers are always handed out from the lowest non-empty range first, whatever order
the ranges were created in. Setting online to false disables the pool: every
attempt to draw from it fails with NumberSourceException until it is re-enabled,
which is the intended way to take a pool out of service without deleting it.
How the Default Implementation Works¶
DefaultNumberSource keeps the
pool and a cached current range — the non-empty range with the lowest begin,
determined by
NumberPoolDomainImpl.getCurrentRange(),
which also prunes exhausted ranges as it scans.
Popping a single number is the hot path, and it is deliberately the cheapest one:
lock the pool, find the current range, take its begin and increment it, persist.
return Session.getSession().transaction(() -> {
lockPool();
findCurrentRange();
long number = currentRange.popNumber();
pool = pool.persist();
return number;
});
The other operations are variations on the same theme.
popNumbers(count) drains the current range, moves to the next, and repeats.
popNumbers(begin, end) walks every range looking for intersections and carves the
requested interval out of them — consuming a whole range, trimming its head, trimming
its tail, or, when the interval falls strictly inside a range, splitting it into
two by creating a fresh NumberRange.
The push operations merge a returned number or interval into an adjacent or
overlapping range, or create a new range when it stands alone. Afterwards fixRanges()
sorts the ranges and merges any that now overlap or touch. That last step is what
makes uniqueness a property of the pool rather than of the caller: however sloppily
numbers are pushed back, the pool never ends up holding the same number twice, so it
can never hand it out twice.
Concurrency and Correctness¶
A number source is a contended, shared resource, and a duplicate number is exactly the kind of quiet wrongness the framework refuses to produce. Three mechanisms combine:
Every operation runs in a transaction. Each public method wraps its work in
Session.getSession().transaction(...), so a number is never handed out by a
transaction that later rolls back.
The pool row is write-locked. lockPool() calls reloadForUpdate(), which
reloads the pool under a database write lock for the remainder of the transaction.
Concurrent poppers on the same pool — in other processes, on other hosts — serialize
on that row. The reload doubles as a staleness check: if the reloaded pool's
serial differs from
the cached one, the cached range is dropped. It also re-checks online, so disabling
a pool takes effect on the next operation.
The cached range is revalidated. findCurrentRange() reloads the cached range
and compares serials. If it vanished, changed underneath, or is now empty — another
tier drew from it, or a transaction rolled back — the cache is discarded and the
ranges are re-read from the database.
Additionally, the methods are synchronized, so threads within one JVM are excluded
before they ever reach the database.
One deliberate optimization deserves note.
NumberPoolPersistenceImpl
overrides:
@Override
public boolean isUpdatingSerialEvenIfNotModified() {
return false; // don't update the pool if only ranges changed
}
A root entity would normally have its serial bumped whenever its aggregate is
persisted. For a pool, popping a number changes only a range — bumping the pool's
serial on every single popNumber() would invalidate every other tier's cached copy
of the pool for a change that never touched it. The pool's own attributes are still
serial-protected; only range churn is exempted.
Remoting — Why the Source Is Local by Design¶
DefaultNumberSource refuses a remote session outright:
if (pool.getSession().isRemote()) {
throw new PersistenceException(pool, "number sources cannot be remote");
}
This is not a limitation so much as a statement about where the work belongs. The
algorithm is a tight loop of locking, reading and updating rows; running it over a
remote session would turn each popNumber() into a series of round-trips while
holding a write lock on a hot row.
The supported pattern is to keep the source on the tier that owns the database and
expose a remote method that draws from it — which is what the generated project
does. MessagePersistence.nextMessageNumber() is declared on the persistence
interface and implemented as a @RemoteMethod:
@RemoteMethod
@Override
public String nextMessageNumber() {
return Long.toString(NumberSourceFactory.getInstance()
.getNumberSource(Message.POOL_NAME, Constants.POOL_REALM).popNumber());
}
The domain layer then simply calls me().nextMessageNumber() without knowing or
caring which tier executed it. On a client, the call travels over
TRIP and runs on the server; in a
single-tier or test setup, the very same code runs in-process. This is the
multi-tier cascade doing its job: one round-trip
per number, and the locking stays next to the database.
The pool entities are a different matter — NumberPool and NumberRange are
declared remote and have TRIP delegates, so administering pools (creating them,
adding ranges, taking them offline, inspecting them) works perfectly well from a
remote client. It is only the number-drawing algorithm that insists on being local.
Uplinks and Slave Pools¶
The model anticipates a hierarchy of pools: a central master pool, and slave pools at branch sites that request blocks of numbers from it as they run low. Three attributes describe this:
lowWaterMark— refill when fewer than this many numbers remain;requestSize— how many to request from the uplink;uplink— how to reach the master.
NumberPoolDomain.isSlave()
reports whether a pool is configured as one:
@Override
public boolean isSlave() {
return me().getLowWaterMark() > 0 && me().getRequestSize() > 0;
}
and the model makes the uplink mandatory exactly when it is needed, via a conditional validation:
The default implementation does not implement uplinks. DefaultNumberSource
never reads lowWaterMark, requestSize or uplink; a pool configured as a slave
behaves exactly like a standalone one and simply runs empty. As its Javadoc states,
it "does not implement any logic to access an uplink source, and thus needs to be
extended if an uplink is required."
The attributes, the isSlave() predicate and its validation are the extension
point, not a shipped feature. To implement refilling, subclass
DefaultNumberSource, override the drawing methods to top up from the uplink when
getCount() falls below lowWaterMark, and publish a factory that returns it:
@Service(NumberSourceFactory.class)
public class UplinkNumberSourceFactory implements NumberSourceFactory {
@Override
public NumberSource getNumberSource(String pool, String realm) { ... }
}
Because NumberSourceFactory is resolved through the SPI, application code calling
getNumberSource(...).popNumber() needs no change.
Summary¶
| Concern | How number sources address it |
|---|---|
| Uniqueness | Ranges are merged on push (fixRanges()), so a pool cannot hold — or hand out — a number twice. |
| Concurrency | A database write lock on the pool row plus per-operation transactions serialize poppers across tiers. |
| Staleness | Serial comparison on both pool and range discards caches that another tier invalidated. |
| Contention | Popping a number does not bump the pool's serial, so range churn does not invalidate other tiers' pools. |
| Partitioning | realm splits one pool name into independent number spaces per tenant, application, or year. |
| Placement | The algorithm stays on the database tier; clients reach it through a @RemoteMethod. |
| Extension | NumberSourceFactory is an SPI singleton; uplink/slave pools are modeled but left to the application. |
Number sources are for numbers the domain cares about. For technical object ids, see id sources.