Reladynamo · storage layout
The same bitemporal rows twice: as the relational table Reladomo already knows, and as the DynamoDB items Reladynamo writes, byte for byte.
Reladynamo does not reshape your data. Each Reladomo object gets its own table, each row becomes one item, and each column becomes one attribute with the same name. DynamoDB also needs an address for every item, a partition key and a sort key, and Reladynamo builds both from values that are already in the row.
Nothing is summarised, nested or denormalised. If you can read the SQL table, you can read the items. The key is the only new thing to learn.
Take the Balance object from the README. Two AsOfAttributes make it
bitemporal, and the XML names every column:
<AsOfAttribute name="businessDate" fromColumnName="FROM_Z" toColumnName="THRU_Z" … isProcessingDate="false"
futureExpiringRowsExist="true" …/>
<AsOfAttribute name="processingDate" fromColumnName="IN_Z" toColumnName="OUT_Z" … isProcessingDate="true" …/>
<Attribute name="balanceId" javaType="int" columnName="BALANCE_ID" primaryKey="true"/>
<Attribute name="quantity" javaType="double" columnName="QUANTITY"/>
The README's snippet leaves futureExpiringRowsExist off; this page sets
it, because it changes the rows below. Without it, when Reladomo closes an open-ended row that has
already started it also cuts that row's THRU_Z back to the transaction time
plus one day (GenericBiTemporalDirector.inactivateObject), so row A would carry
THRU_Z 2026-06-16 14:02:31.250 instead of infinity. The CRM demo's
Customer omits the flag, and its history shows exactly that.
A balance of 10 is entered on 5 January, true from 1 January. On 15 June someone learns it
should have been 99 from 1 March. Reladomo's TemporalDirector, not the adapter,
leaves three rows behind:
| Row | BALANCE_ID | QUANTITY | FROM_Z | THRU_Z | IN_Z | OUT_Z |
|---|---|---|---|---|---|---|
| A | 42 | 10.0 | 2026-01-01 00:00:00.000 | 9999-12-01 23:59:00.000 | 2026-01-05 09:30:00.000 | 2026-06-15 14:02:31.250 |
| B | 42 | 10.0 | 2026-01-01 00:00:00.000 | 2026-03-01 00:00:00.000 | 2026-06-15 14:02:31.250 | 9999-12-01 23:59:00.000 |
| C | 42 | 99.0 | 2026-03-01 00:00:00.000 | 9999-12-01 23:59:00.000 | 2026-06-15 14:02:31.250 | 9999-12-01 23:59:00.000 |
9999-12-01 23:59:00.000 is Reladomo's infinity. Row A is the original
belief, closed at the moment the correction was recorded. B and C are what is believed now.
This is what DynamoDbWriter puts in table BALANCE for each row. It is
shown in DynamoDB's JSON wire format, in the order the writer assembles the item, with each
column lined up against the attribute it became.
Relational row
-- row A -- no column BALANCE_ID 42 QUANTITY 10.0 FROM_Z 2026-01-01 00:00:00.000 THRU_Z 9999-12-01 23:59:00.000 IN_Z 2026-01-05 09:30:00.000 OUT_Z 2026-06-15 14:02:31.250 -- built from BALANCE_ID -- built from IN_Z, FROM_Z
DynamoDB item
{
"_rd_v": {"N": "1"},
"BALANCE_ID": {"N": "42"},
"QUANTITY": {"B": "QCQAAAAAAAA="},
"FROM_Z": {"S": "20260101000000000"},
"THRU_Z": {"S": "99991201235900000"},
"IN_Z": {"S": "20260105093000000"},
"OUT_Z": {"S": "20260615140231250"},
"pk": {"S": "v1#BALANCE#42"},
"sk": {"S": "v1#P#20260105093000000#B#20260101000000000"}
}Relational row
-- row B -- no column BALANCE_ID 42 QUANTITY 10.0 FROM_Z 2026-01-01 00:00:00.000 THRU_Z 2026-03-01 00:00:00.000 IN_Z 2026-06-15 14:02:31.250 OUT_Z 9999-12-01 23:59:00.000 -- built from BALANCE_ID -- built from IN_Z, FROM_Z
DynamoDB item
{
"_rd_v": {"N": "1"},
"BALANCE_ID": {"N": "42"},
"QUANTITY": {"B": "QCQAAAAAAAA="},
"FROM_Z": {"S": "20260101000000000"},
"THRU_Z": {"S": "20260301000000000"},
"IN_Z": {"S": "20260615140231250"},
"OUT_Z": {"S": "99991201235900000"},
"pk": {"S": "v1#BALANCE#42"},
"sk": {"S": "v1#P#20260615140231250#B#20260101000000000"}
}Relational row
-- row C -- no column BALANCE_ID 42 QUANTITY 99.0 FROM_Z 2026-03-01 00:00:00.000 THRU_Z 9999-12-01 23:59:00.000 IN_Z 2026-06-15 14:02:31.250 OUT_Z 9999-12-01 23:59:00.000 -- built from BALANCE_ID -- built from IN_Z, FROM_Z
DynamoDB item
{
"_rd_v": {"N": "1"},
"BALANCE_ID": {"N": "42"},
"QUANTITY": {"B": "QFjAAAAAAAA="},
"FROM_Z": {"S": "20260301000000000"},
"THRU_Z": {"S": "99991201235900000"},
"IN_Z": {"S": "20260615140231250"},
"OUT_Z": {"S": "99991201235900000"},
"pk": {"S": "v1#BALANCE#42"},
"sk": {"S": "v1#P#20260615140231250#B#20260301000000000"}
}THRU_Z and OUT_Z are ordinary attributes, not part of the key. So when
June's correction closed row A, the write went to A's existing item: a Put conditioned on that
item still holding exactly what Reladomo last read. B and C are new items, written only if
attribute_not_exists(pk).
Note that QUANTITY is not stored as a number. A double is stored as its
eight IEEE-754 bits, which DynamoDB JSON shows as base64: QCQAAAAAAAA= is
0x4024000000000000, exactly 10.0. The reason is further down.
Partition key · pk
v1key grammar version#BALANCEsimple class name, upper-cased#42balanceId, the only primary key
Sort key · sk (row C)
v1same version#P#20260615140231250IN_Z, processing-from#B#20260301000000000FROM_Z, business-from
One timestamp (IN_Z)
2026year06month15day14hour02minute31second250millis, from getNanos()
Balance the two coincide — then adds each
primary-key value in XML order. The sort key is processing-from, then business-from. No
separator appears inside a component: a # in a key value is refused
(RELADYNAMO-CFG-008).Every timestamp is 17 digits: the UTC calendar fields yyyyMMddHHmmssSSS, zero-padded,
with milliseconds taken from getNanos(). It is not the familiar ISO-8601 form, and not epoch millis.
Because the width is fixed, byte order is time order. Because the digits are calendar fields
rather than a signed epoch offset, that stays true before 1970. Infinity has no special token:
99991201235900000 is an ordinary timestamp that sorts after every real date, and it
decodes back to the configured sentinel by value.
Timestamp columns in the item use the same form, so the FROM_Z attribute holds exactly
the string that ends the sort key. A payload Timestamp with sub-millisecond nanos is
refused, not truncated.
| Case | Key |
|---|---|
| Composite primary key, XML order | v1#POSITION#42#7 |
boolean / BigDecimal / byte[] component | T · 100 (from 1E+2) · 0a1bff00 |
A # inside a key value · float or double key | refused |
| Business-only / audit-only sort key | v1#B#<FROM> · v1#P#<IN> |
| Non-dated sort key | v1#ND |
| Size limits, checked before the write | pk ≤ 2048 bytes · sk ≤ 1024 bytes |
Look at where the three rows begin. A and B start on the same business date. B and C were recorded at the same instant. Neither clock alone can tell them apart.
| Row | Business-from only | Processing-from only | Both: what is written |
|---|---|---|---|
| A | v1#B#20260101000000000 | v1#P#20260105093000000 | v1#P#20260105093000000#B#20260101000000000 |
| B | v1#B#20260101000000000 = A | v1#P#20260615140231250 | v1#P#20260615140231250#B#20260101000000000 |
| C | v1#B#20260301000000000 | v1#P#20260615140231250 = B | v1#P#20260615140231250#B#20260301000000000 |
Objects with only one clock need only one component, so business-only and audit-only objects get
v1#B#… and v1#P#…. A bitemporal row needs both. Putting processing time
first means a Query on pk = v1#BALANCE#42 returns A, B, C in exactly this order: by when
they became known, then by business date. The cost is that a predicate on business date alone becomes a
filter, as explained in Planning a bitemporal query.
A non-dated object has no rectangles and one row per key, but it still gets a sort key. Here is
the Car object from the car-classifier demo, as its seed inserts the 1985 MR2:
Relational row · CAR
-- row -- no column CAR_ID 1 CAR_YEAR 1985 MAKE Toyota MODEL MR2 WHEEL_COUNT 4 COLOR_CODE RED BODY_STYLE COUPE ENGINE_CODE 4CYL -- built from CAR_ID -- constant: no dates
DynamoDB item
{
"_rd_v": {"N": "1"},
"CAR_ID": {"N": "1"},
"CAR_YEAR": {"N": "1985"},
"MAKE": {"S": "Toyota"},
"MODEL": {"S": "MR2"},
"WHEEL_COUNT": {"N": "4"},
"COLOR_CODE": {"S": "RED"},
"BODY_STYLE": {"S": "COUPE"},
"ENGINE_CODE": {"S": "4CYL"},
"pk": {"S": "v1#CAR#1"},
"sk": {"S": "v1#ND"}
}v1#ND is a constant. It gives every Reladynamo table the same key schema,
pk plus sk, so table creation, the planner and the executor never have to
branch on whether an object is dated. A lookup by carId addresses
(v1#CAR#1, v1#ND).
Each attribute's DynamoDB type follows from the javaType in the XML. The examples
below are the literal values the codec tests expect:
| javaType | Stored as | Example |
|---|---|---|
int long short byte | N | {"N": "-128"} |
boolean | BOOL | {"BOOL": false} |
String, char | S (a lone surrogate char: N) | {"S": ""} · {"S": "A"} |
Timestamp | S, 17 digits UTC | {"S": "20260615140231250"} |
Date · Time | S, UTC | {"S": "2026-09-13"} · {"S": "14:30:00.123"} |
BigDecimal | S, plain string | {"S": "1.10"} |
double · float | B, raw IEEE-754 bits | {"B": "QCQAAAAAAAA="} |
byte[] | B | {"B": "AAH/"} |
| any null value | NULL | {"NULL": true} |
BigDecimal is a string because DynamoDB's N type trims trailing
zeros. 1.10 would come back as 1.1, which DynamoDB considers the same
number but Java does not: the scale drops from 2 to 1, equals fails, and money read
back no longer matches what H2 returns. toPlainString() keeps the scale. The value
must still fit N: more than 38 significant digits, a magnitude outside
1E-130 to 9.99…E+125, or a negative scale is refused rather than written.
A double is bits because N cannot hold NaN, either infinity or
−0.0, and its exponent stops at 125 while a double reaches 308. Eight big-endian bytes round-trip
every value. The consequence: a condition on a double, like one on a
BigDecimal, is evaluated in memory after the read, not by DynamoDB.
Null is written, never implied. A null column becomes {"NULL": true},
never 0 or false, and an empty string stays {"S": ""}.
QUANTITY above has no nullable flag, so the parser treats it as nullable,
and a null quantity would be stored exactly that way. A null or empty primary-key value is refused.
On read, a missing attribute decodes as null if the column is nullable, so a later schema can add
columns. The only attributes left off when null are GSI keys, which is how an item drops out of an
index.
Exactly three attributes are not columns: pk, sk and _rd_v,
the codec version ({"N": "1"} today). A reader refuses an item with no
_rd_v, or with a version it does not know, rather than guessing at its layout. If you
configure a GSI, its key attributes are added too; these examples have none. Nothing else is
added: no type tag and no write timestamp.
Attribute names come from the XML columnName, or the Java attribute name if a column
has none. A mapping that puts a column on pk, sk, _rd_v or a
GSI key name is refused when it is built. DynamoDB's reserved words need no special handling: every
expression refers to attributes through placeholders (#a0, #a1, …,
#rd_v), whether or not the word is reserved today. The whole item, keys included, is
sized before it is sent, and anything over 400 KB is refused before it reaches DynamoDB.
These examples assume the writing JVM runs in UTC. The adapter stores the
Timestamp Reladomo hands it as UTC calendar fields, and Reladomo's
DefaultInfinityTimestamp is 9999-12-01 23:59:00 in the JVM's
default timezone. From a JVM in America/Denver, the same infinity is written as
99991202065900000 and midnight on 1 March as 20260301070000000. Writers
in different timezones store different bytes for the same data.