Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to generate a time-ordered uid in Python?

Is this possible? I've heard Cassandra has something similar : https://datastax.github.io/python-driver/api/cassandra/util.html

I have been using a ISO timestamp concatenated with a uuid4, but that ended up way too large (58 characters) and probably overkill.

Keeping a sequential number doesn't work in my context (DynamoDB NoSQL)

Worth noticing that for my application it doesn't matter if items created in batch/same second are in a random order, as long as the uid don't collapse.

I have no specific restriction on maximum length, ideally I would like to see the different collision chance for different lengths, but it needs to be smaller than 58 (my original attempt)

This is to use with DynamoDB(NoSQL Database) as Sort-key

like image 481
Mojimi Avatar asked May 13 '19 20:05

Mojimi


People also ask

How do you generate multiple UUIDs in Python?

This module provides immutable UUID objects (the UUID class) and the functions uuid1() , uuid3() , uuid4() , uuid5() for generating version 1, 3, 4, and 5 UUIDs as specified in RFC 4122. If all you want is a unique ID, you should probably call uuid1() or uuid4() .

What is UUID uuid4 () hex?

Well, as you can see above, str(uuid4()) returns a string representation of the UUID with the dashes included, while uuid4().hex returns "The UUID as a 32-character hexadecimal string"

Are UUIDs sequential?

UUID are supposed to be in-sequential, so that someone can not predict the other value. If you need sequence then UUID is not a right choice.


2 Answers

Why uuid.uuid1 is not sequential

uuid.uuid1(node=None, clock_seq=None) is effectively:

  • 60 bits of timestamp (representing number of 100-ns intervals after 1582-10-15 00:00:00)
  • 14 bits of "clock sequence"
  • 48 bits of "Node info" (generated from network card's mac-address or from hostname or from RNG).

If you don't provide any arguments, then System function is called to generate uuid. In that case:

  • It's unclear if "clock sequence" is sequential or random.
  • It's unclear if it's safe to be used in multiple processes (can clock_seq be repeated in different processes or not?). In Python 3.7 this info is now available.

If you provide clock_seq or node, then "pure python implementation is used". IN this case even with "fixed value" for clock_seq:

  • timestamp part is guaranteed to be sequential for all the calls in current process even in threaded execution.
  • clock_seq part is randomly generated. But that is not critical annymore because timestamp is sequential and unique.
  • It's NOT safe for multiple processes (processes that call uuid1 with the same clock_seq, node might return conflicting values if called during the "same 100-ns time interval")

Solution that reuses uuid.uuid1

It's easy to see, that you can make uuid1 sequential by providing clock_seq or node arguments (to use python implementation).

import time

from uuid import uuid1, getnode

_my_clock_seq = getrandbits(14)
_my_node = getnode()


def sequential_uuid(node=None):
    return uuid1(node=node, clock_seq=_my_clock_seq)
    # .hex attribute of this value is 32-characters long string


def alt_sequential_uuid(clock_seq=None):
    return uuid1(node=_my_node, clock_seq=clock_seq)


if __name__ == '__main__':
    from itertools import count
    old_n = uuid1()  # "Native"
    old_s = sequential_uuid()  # Sequential

    native_conflict_index = None

    t_0 = time.time()

    for x in count():
        new_n = uuid1()
        new_s = sequential_uuid()

        if old_n > new_n and not native_conflict_index:
            native_conflict_index = x

        if old_s >= new_s:
            print("OOops: non-sequential results for `sequential_uuid()`")
            break

        if (x >= 10*0x3fff and time.time() - t_0 > 30) or (native_conflict_index and x > 2*native_conflict_index):
            print('No issues for `sequential_uuid()`')
            break

        old_n = new_n
        old_s = new_s

    print(f'Conflicts for `uuid.uuid1()`: {bool(native_conflict_index)}')

Multiple processes issues

BUT if you are running some parallel processes on the same machine, then:

  • node which defaults to uuid.get_node() will be the same for all the processes;
  • clock_seq has small chance to be the same for some processes (chance of 1/16384)

That might lead to conflicts! That is general concern for using uuid.uuid1 in parallel processes on the same machine unless you have access to SafeUUID from Python3.7.

If you make sure to also set node to unique value for each parallel process that runs this code, then conflicts should not happen.

Even if you are using SafeUUID, and set unique node, it's still possible to have non-sequential (but unique) ids if they are generated in different processes.

If some lock-related overhead is acceptable, then you can store clock_seq in some external atomic storage (for example in "locked" file) and increment it with each call: this allows to have same value for node on all parallel processes and also will make id-s sequential. For cases when all parallel processes are subprocesses created using multiprocessing: clock_seq can be "shared" using multiprocessing.Value

As a result you always have to remember:

  • If you are running multiple processes on the same machine, then you must:

    • Ensure uniqueness of node. The problem for this solution: you can't be sure to have sequential ids from different processes generated during the same 100-ns interval. But this is very "light" operation executed once on process startup and achieved by: "adding" something to default node, e.g. int(time.time()*1e9) - 0x118494406d1cc000, or by adding some counter from machine-level atomic db.

    • Ensure "machine-level atomic clock_seq" and the same node for all processes on one machine. That way you'll have some overhead for "locking" clock_seq, but id-s are guaranteed to be sequential even if generated in different processes during the same 100-ns interval (unless you are calling uuid from several threads in the same process).

  • For processes on different machines:

    • either you have to use some "global counter service";

    • or it's not possible to have sequential ids generated on different machines during the same 100-ns interval.

Reducing size of the id

General approach to generate UUIDs is quite simple, so it's easy to implement something similar from scratch, and for example use less bits for node_info part:

import time
from random import getrandbits

_my_clock_seq = getrandbits(14)
_last_timestamp_part = 0
_used_clock_seq = 0


timestamp_multiplier = 1e7  # I'd recommend to use this value

# Next values are enough up to year 2116:
if timestamp_multiplier == 1e9:
    time_bits = 62  # Up to year 2116, also reduces chances for non-sequential id-s generated in different processes
elif timestamp_multiplier == 1e8:
    time_bits = 60  # up to year 2335
elif timestamp_multiplier == 1e7:
    time_bits = 56  # Up to year 2198.
else:
    raise ValueError('Please calculate and set time_bits')

time_mask = 2**time_bits - 1

seq_bits = 16
seq_mask = 2**seq_bits - 1

node_bits = 12
node_mask = 2**node_bits - 1

max_hex_len = len(hex(2**(node_bits+seq_bits+time_bits) - 1)) - 2  # 21

_default_node_number = getrandbits(node_bits)  # or `uuid.getnode() & node_mask`


def sequential_uuid(node_number=None):
    """Return 21-characters long hex string that is sequential and unique for each call in current process.

    Results from different processes may "overlap" but are guaranteed to
    be unique if `node_number` is different in each process.

    """
    global _my_clock_seq
    global _last_timestamp_part
    global _used_clock_seq
    if node_number is None:
        node_number = _default_node_number
    if not 0 <= node_number <= node_mask:
        raise ValueError("Node number out of range")

    timestamp_part = int(time.time() * timestamp_multiplier) & time_mask
    _my_clock_seq = (_my_clock_seq + 1) & seq_mask

    if _last_timestamp_part >= timestamp_part:
        timestamp_part = _last_timestamp_part
        if _used_clock_seq == _my_clock_seq:
            timestamp_part = (timestamp_part + 1) & time_mask
    else:
        _used_clock_seq = _my_clock_seq

    _last_timestamp_part = timestamp_part

    return hex(
        (timestamp_part << (node_bits+seq_bits))
        |
        (_my_clock_seq << (node_bits))
        |
        node_number
    )[2:]

Notes:

  • Maybe it's better to simply store integer value (not hex-string) in the database
  • If you are storing it as text/char, then its better to convert integer to base64-string instead of converting it to hex-string. That way it will be shorter (21 chars hex-string → 16 chars b64-encoded string):
from base64 import b64encode

total_bits = time_bits+seq_bits+node_bits
total_bytes = total_bits // 8 + 1 * bool(total_bits % 8)

def int_to_b64(int_value):
    return b64encode(int_value.to_bytes(total_bytes, 'big'))

Collision chances

  • Single process: collisions not possible
  • Multiple processes with manually set unique clock_seq or unique node in each process: collisions not possible
  • Multiple processes with randomly set node (48-bits, "fixed" in time):

    • Chance to have the node collision in several processes:

      • in 2 processes out of 10000: ~0.000018%
      • in 2 processes out of 100000: 0.0018%
    • Chance to have single collision of the id per second in 2 processes with the "colliding" node:

      • for "timestamp" interval of 100-ns (default for uuid.uuid1 , and in my code when timestamp_multiplier == 1e7): proportional to 3.72e-19 * avg_call_frequency²

      • for "timestamp" interval of 10-ns (timestamp_multiplier == 1e8): proportional to 3.72e-21 * avg_call_frequency²

like image 197
imposeren Avatar answered Sep 27 '22 16:09

imposeren


You should be able to encode a timestamp precise to the second for a time range of 135 years in 32 bits. That will only take 8 characters to represent in hex. Added to the hex representation of the uuid (32 hex characters) that will amount to only 40 hex characters.

Encoding the time stamp that way requires that you pick a base year (e.g. 2000) and compute the number of days up to the current date (time stamp). Multiply this number of days by 86400, then add the seconds since midnight. This will give you values that are less than 2^32 until you reach year 2135.

Note that you have to keep leading zeroes in the hex encoded form of the timestamp prefix in order for alphanumeric sorting to preserve the chronology.

With a few bits more in the time stamp, you could increase the time range and/or the precision. With 8 more bits (two hex characters), you could go up to 270 years with a precision to the hundredth of a second.
Note that you don't have to model the fraction of seconds in a base 10 range. You will get optimal bit usage by breaking it down in 128ths instead of 100ths for the same number of characters. With the doubling of the year range, this still fits within 8 bits (2 hex characters)

The collision probability, within the time precision (i.e. per second or per 100th or 128th of a second) is driven by the range of the uuid so it will be 1 in 2^128 for the chosen precision. Increasing the precision of the time stamp has the most impact on reducing the collision chances. It is also the factor that has the lowest impact on total size of the key.

More efficient character encoding: 27 to 29 character keys

You could significantly reduce the size of the key by encoding it in base 64 instead of 16 which would give you 27 to 29 characters (depending on you choice of precision)

Note that, for the timestamp part, you need to use an encoding function that takes an integer as input and that preserves the collating sequence of digit characters.

For example:

def encode64(number, size):
    chars  = "+-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
    result = list()
    for _ in range(size):
        result.append(chars[number%64])
        number //= 64
    return "".join(reversed(result))

a = encode64(1234567890,6) # '-7ZU9G'
b = encode64(9876543210,6) # '7Ag-Pe'
print(a < b)               # True

u = encode64(int(uuid.uuid4()),22) # '1QA2LtMg30ztnugxaokVMk'

key = a+u  # '-7ZU9G1QA2LtMg30ztnugxaokVMk'  (28 characters)

You can save some more characters by combining the time stamp and uuid into a single number before encoding instead of concatenating the two encoded values.

The encode64() function needs one character every 6 bits.

So, for 135 years with precision to the second: (32+128)/6 = 26.7 --> 27 characters

instead of (32/6 = 5.3 --> 6) + (128/6 = 21.3 --> 22) ==> 28 characters

uid       = uuid.uuid4()
timeStamp = daysSince2000 * 86400 + int(secondsSinceMidnight)
key       = encode64( timeStamp<<128 | int(uid) ,27)

with a 270 year span and 128th of a second precision: (40+128)/6 = 28 characters

uid       = uuid.uuid4()
timeStamp = daysSince2000 * 86400 + int(secondsSinceMidnight)
precision = 128
timeStamp = timeStamp * precision + int(factionOfSecond * precision)
key       = encode64( timeStamp<<128 | int(uid) ,28)

With 29 characters you can raise precision to 1024th of a second and year range to 2160 years.

UUID masking: 17 to 19 characters keys

To be even more efficient, you could strip out the first 64 bits of the uuid (which is already a time stamp) and combine it with your own time stamp. This would give you keys with a length of 17 to 19 characters with practically no loss of collision avoidance (depending on your choice of precision).

mask  = (1<<64)-1
key   = encode64( timeStamp<<64 | (int(uid) & mask) ,19)

Integer/Numeric keys ?

As a final note, if your database supports very large integers or numeric fields (140 bits or more) as keys, you don't have to convert the combined number to a string. Just use it directly as the key. The numerical sequence of timeStamp<<128 | int(uid) will respect the chronology.

like image 34
Alain T. Avatar answered Sep 27 '22 15:09

Alain T.