Skip to content

Commit d3f5c5f

Browse files
authored
Monotonicity state moved into GenerationOptions instance making the state not globally shared (#63)
* Monotonicity state moved into `GenerationOptions` instance. Identical performance. * Monotonic generation tests simplified as state is now held inside the GenerationOptions instance.
1 parent e1c6ba5 commit d3f5c5f

4 files changed

Lines changed: 199 additions & 212 deletions

File tree

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ A high-performance, fully compliant .NET implementation of ULIDs (Universally Un
2525

2626
[<img align="right" width="100px" src="assets/logo_ulid.png" />](https://www.nuget.org/packages/ByteAether.Ulid/)
2727

28-
ULIDs (Universally Unique Lexicographically Sortable Identifiers) offer a modern, human-readable alternative to traditional GUIDs, optimized specifically for distributed systems and time-ordered data. **ByteAether.Ulid** delivers a high-performance, specification-compliant .NET implementation engineered to resolve critical concurrency and persistence edge cases left unaddressed by alternative libraries.
28+
ULIDs (Universally Unique Lexicographically Sortable Identifiers) offer a modern, human-readable alternative to traditional GUIDs, optimized specifically for distributed systems and time-ordered data. **ByteAether.Ulid** delivers a high-performance, specification-compliant .NET implementation engineered to resolve critical concurrency and persistence edge cases unaddressed by alternative libraries.
2929

3030
### Resilient Concurrency & Monotonic Overflow Handling
3131

@@ -150,7 +150,11 @@ You can customize ULID generation by providing `GenerationOptions`. This allows
150150

151151
#### Example: Monotonic ULID with Random Increments
152152

153-
To generate ULIDs that are monotonically increasing with a random increment, you can specify the `Monotonicity` option.
153+
The monotonicity state (last generated timestamp and 80-bit random payload) is bound directly to the lifecycle of the `GenerationOptions` instance.
154+
155+
* **Instance Reuse (Recommended for Sequences):** Reusing a single `GenerationOptions` instance across calls guarantees strict, cross-thread monotonic ordering via lock-free atomic compare-and-exchange (CAS) operations.
156+
* **Instance Isolation:** Passing a new `GenerationOptions` instance on each call isolates state, disabling monotonic sequence tracking between calls and eliminating CAS contention.
157+
154158
```csharp
155159
using System;
156160
using ByteAether.Ulid;
@@ -241,13 +245,13 @@ The `Ulid` implementation provides the following properties and methods:
241245
### Properties
242246

243247
- `Ulid.MinValue`\
244-
Represents an empty ULID, equivalent to `default(Ulid)` and `Ulid.New(new byte[16])`.
248+
Represents an empty ULID, equivalent to `default(Ulid)` or `Ulid.New(new byte[16])`.
245249
- `Ulid.MaxValue`\
246250
Represents the maximum possible value for a ULID (all bytes set to `0xFF`).
247251
- `Ulid.Empty`\
248252
Alias for `Ulid.MinValue`.
249253
- `Ulid.DefaultGenerationOptions`\
250-
Default configuration for ULID generation when no options are provided by the `Ulid.New(...)` call.
254+
Gets or sets the global default `GenerationOptions` configuration for ULID generation when no options are provided by the `Ulid.New(...)` call.
251255
- `.Time`\
252256
Gets the timestamp component of the ULID as a `DateTimeOffset`.
253257
- `.TimeBytes`\
@@ -278,10 +282,15 @@ The `Ulid` implementation provides the following properties and methods:
278282

279283
### GenerationOptions
280284

281-
The `GenerationOptions` class provides detailed configuration for ULID generation, with the following key properties:
285+
The `GenerationOptions` class encapsulates generation strategy, state retention, and lock-free thread synchronization for monotonic ULID generation.
286+
287+
Configurable properties:
282288

283289
- `Monotonicity`\
284-
Controls the behavior of ULID generation when multiple identifiers are created within the same millisecond. It determines whether ULIDs are strictly increasing or allow for random ordering within that millisecond. Available options include: `NonMonotonic`, `MonotonicIncrement` (default), `MonotonicRandom1Byte`, `MonotonicRandom2Byte`, `MonotonicRandom3Byte`, `MonotonicRandom4Byte`.
290+
Defines the monotonic strategy when generating multiple ULIDs within the same millisecond. Each instance maintains an atomic state machine using lock-free Compare-And-Swap (CAS) primitives to guarantee strict sequential ordering without mutex locking. Options include:
291+
- `NonMonotonic`: Generates fully random 80-bit payloads without state tracking.
292+
- `MonotonicIncrement` (Default): Increments the least significant bit of the random payload upon sub-millisecond collisions.
293+
- `MonotonicRandom1Byte`, `MonotonicRandom2Byte`, `MonotonicRandom3Byte`, `MonotonicRandom4Byte`: Adds a random integer increment within the specified byte range to the payload, strengthening entropy against enumeration attacks while maintaining monotonicity.
285294

286295
- `InitialRandomSource`\
287296
An `IRandomProvider` for generating the random bytes of a ULID. The default `CryptographicallySecureRandomProvider` ensures robust, unpredictable ULIDs using a cryptographically secure generator.

src/Ulid.Tests/Ulid.New.Tests.cs

Lines changed: 127 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,6 @@
22

33
public class UlidNewTests
44
{
5-
// A lock object to synchronize tests that rely on shared static state in the Ulid class,
6-
// preventing race conditions and ensuring test isolation.
7-
8-
// ReSharper disable once ChangeFieldTypeToSystemThreadingLock for older .net compatibility
9-
private static readonly object _staticStateLock = new();
10-
11-
// We need safe DateTimeOffset values for monotonicity
12-
private static readonly DateTimeOffset _lastTimestamp = DateTimeOffset.UtcNow.AddMinutes(1);
13-
private static int _timestampOffsetCounter;
14-
15-
private static DateTimeOffset GetDateTimeOffset() => _lastTimestamp.AddSeconds(++_timestampOffsetCounter);
16-
175
/// <summary>
186
/// A controllable random provider for testing purposes. It returns pre-configured byte sequences.
197
/// </summary>
@@ -93,67 +81,61 @@ public void New_WithUnixTimestampMillisecondsAndRandom_ShouldGenerateCorrectTime
9381
[Fact]
9482
public void New_NonMonotonic_CanProduceSmallerUlids()
9583
{
96-
lock (_staticStateLock)
84+
// Arrange
85+
var timestamp = DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds();
86+
var random1 = new byte[] { 2, 2, 2, 2, 2, 2, 2, 2, 2, 2 };
87+
var random2 = new byte[] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 };
88+
89+
var initialRandomProvider = new ControllableRandomProvider(random1, random2);
90+
var options = new Ulid.GenerationOptions
9791
{
98-
// Arrange
99-
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
100-
var random1 = new byte[] { 2, 2, 2, 2, 2, 2, 2, 2, 2, 2 };
101-
var random2 = new byte[] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 };
102-
103-
var initialRandomProvider = new ControllableRandomProvider(random1, random2);
104-
var options = new Ulid.GenerationOptions
105-
{
106-
Monotonicity = Ulid.GenerationOptions.MonotonicityOptions.NonMonotonic,
107-
InitialRandomSource = initialRandomProvider
108-
};
109-
110-
// Act
111-
var ulid1 = Ulid.New(timestamp, options);
112-
var ulid2 = Ulid.New(timestamp, options);
113-
114-
// Assert
115-
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
116-
Assert.Equal(random1, ulid1.Random.ToArray());
117-
118-
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
119-
Assert.Equal(random2, ulid2.Random.ToArray());
120-
}
92+
Monotonicity = Ulid.GenerationOptions.MonotonicityOptions.NonMonotonic,
93+
InitialRandomSource = initialRandomProvider
94+
};
95+
96+
// Act
97+
var ulid1 = Ulid.New(timestamp, options);
98+
var ulid2 = Ulid.New(timestamp, options);
99+
100+
// Assert
101+
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
102+
Assert.Equal(random1, ulid1.Random.ToArray());
103+
104+
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
105+
Assert.Equal(random2, ulid2.Random.ToArray());
121106
}
122107

123108
[Fact]
124109
public void New_MonotonicIncrement_ShouldOverflowToTimestamp()
125110
{
126-
lock (_staticStateLock)
111+
// Arrange
112+
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
113+
var initialRandom = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD };
114+
115+
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
116+
117+
var options = new Ulid.GenerationOptions
127118
{
128-
// Arrange
129-
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
130-
var initialRandom = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD };
131-
132-
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
133-
134-
var options = new Ulid.GenerationOptions
135-
{
136-
Monotonicity = Ulid.GenerationOptions.MonotonicityOptions.MonotonicIncrement,
137-
InitialRandomSource = initialRandomProvider
138-
};
139-
140-
// Act
141-
var ulid1 = Ulid.New(timestamp, options); // Random ...FD
142-
var ulid2 = Ulid.New(timestamp, options); // Random ...FE (incremented)
143-
var ulid3 = Ulid.New(timestamp, options); // Random ...FF (incremented)
144-
var ulid4 = Ulid.New(timestamp, options); // Overflow
145-
146-
// Assert
147-
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
148-
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
149-
Assert.Equal(timestamp, ulid3.Time.ToUnixTimeMilliseconds());
150-
Assert.Equal(timestamp + 1, ulid4.Time.ToUnixTimeMilliseconds());
151-
152-
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD }, ulid1.Random.ToArray());
153-
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE }, ulid2.Random.ToArray());
154-
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, ulid3.Random.ToArray());
155-
Assert.Equal(new byte[10], ulid4.Random.ToArray());
156-
}
119+
Monotonicity = Ulid.GenerationOptions.MonotonicityOptions.MonotonicIncrement,
120+
InitialRandomSource = initialRandomProvider
121+
};
122+
123+
// Act
124+
var ulid1 = Ulid.New(timestamp, options); // Random ...FD
125+
var ulid2 = Ulid.New(timestamp, options); // Random ...FE (incremented)
126+
var ulid3 = Ulid.New(timestamp, options); // Random ...FF (incremented)
127+
var ulid4 = Ulid.New(timestamp, options); // Overflow
128+
129+
// Assert
130+
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
131+
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
132+
Assert.Equal(timestamp, ulid3.Time.ToUnixTimeMilliseconds());
133+
Assert.Equal(timestamp + 1, ulid4.Time.ToUnixTimeMilliseconds());
134+
135+
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD }, ulid1.Random.ToArray());
136+
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE }, ulid2.Random.ToArray());
137+
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }, ulid3.Random.ToArray());
138+
Assert.Equal(new byte[10], ulid4.Random.ToArray());
157139
}
158140

159141
[Theory]
@@ -166,41 +148,38 @@ public void New_MonotonicRandom_ShouldIncrementCorrectly(
166148
int incrementSize
167149
)
168150
{
169-
lock (_staticStateLock)
170-
{
171-
// Arrange
172-
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
151+
// Arrange
152+
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
173153

174-
var initialRandom = new byte[10];
175-
initialRandom[9] = 0xA; // 10
154+
var initialRandom = new byte[10];
155+
initialRandom[9] = 0xA; // 10
176156

177-
var increment = new byte[incrementSize];
178-
increment[0] = 4; // 10 + 4
157+
var increment = new byte[incrementSize];
158+
increment[0] = 4; // 10 + 4
179159

180-
var incrementedRandom = new byte[10];
181-
incrementedRandom[9] = 15; // 10 + 4 + 1 : +1 comes from base implementation of Ulid
160+
var incrementedRandom = new byte[10];
161+
incrementedRandom[9] = 15; // 10 + 4 + 1 : +1 comes from base implementation of Ulid
182162

183-
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
184-
var incrementRandomProvider = new ControllableRandomProvider(increment);
163+
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
164+
var incrementRandomProvider = new ControllableRandomProvider(increment);
185165

186-
var options = new Ulid.GenerationOptions
187-
{
188-
Monotonicity = monotonicity,
189-
InitialRandomSource = initialRandomProvider,
190-
IncrementRandomSource = incrementRandomProvider
191-
};
166+
var options = new Ulid.GenerationOptions
167+
{
168+
Monotonicity = monotonicity,
169+
InitialRandomSource = initialRandomProvider,
170+
IncrementRandomSource = incrementRandomProvider
171+
};
192172

193-
// Act
194-
var ulid1 = Ulid.New(timestamp, options);
195-
var ulid2 = Ulid.New(timestamp, options);
173+
// Act
174+
var ulid1 = Ulid.New(timestamp, options);
175+
var ulid2 = Ulid.New(timestamp, options);
196176

197-
// Assert
198-
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
199-
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
177+
// Assert
178+
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
179+
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
200180

201-
Assert.Equal(initialRandom, ulid1.Random.ToArray());
202-
Assert.Equal(incrementedRandom, ulid2.Random.ToArray());
203-
}
181+
Assert.Equal(initialRandom, ulid1.Random.ToArray());
182+
Assert.Equal(incrementedRandom, ulid2.Random.ToArray());
204183
}
205184

206185
[Theory]
@@ -212,42 +191,39 @@ public void New_MonotonicRandom_ShouldCarryOverIncrement(
212191
Ulid.GenerationOptions.MonotonicityOptions monotonicity, int incrementSize
213192
)
214193
{
215-
lock (_staticStateLock)
216-
{
217-
// Arrange
218-
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
194+
// Arrange
195+
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
219196

220-
var initialRandom = new byte[10];
221-
initialRandom[9] = 0xFE; // Max - 2
197+
var initialRandom = new byte[10];
198+
initialRandom[9] = 0xFE; // Max - 2
222199

223-
var increment = new byte[incrementSize];
224-
increment[0] = 0x01; // 1 as the other +1 comes from base implementation
200+
var increment = new byte[incrementSize];
201+
increment[0] = 0x01; // 1 as the other +1 comes from base implementation
225202

226-
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
227-
var incrementRandomProvider = new ControllableRandomProvider(increment);
203+
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
204+
var incrementRandomProvider = new ControllableRandomProvider(increment);
228205

229-
var options = new Ulid.GenerationOptions
230-
{
231-
Monotonicity = monotonicity,
232-
InitialRandomSource = initialRandomProvider,
233-
IncrementRandomSource = incrementRandomProvider
234-
};
206+
var options = new Ulid.GenerationOptions
207+
{
208+
Monotonicity = monotonicity,
209+
InitialRandomSource = initialRandomProvider,
210+
IncrementRandomSource = incrementRandomProvider
211+
};
235212

236-
// Act
237-
var ulid1 = Ulid.New(timestamp, options);
238-
var ulid2 = Ulid.New(timestamp, options);
213+
// Act
214+
var ulid1 = Ulid.New(timestamp, options);
215+
var ulid2 = Ulid.New(timestamp, options);
239216

240-
// Assert
241-
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
242-
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
243-
Assert.Equal(initialRandom, ulid1.Random.ToArray());
217+
// Assert
218+
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
219+
Assert.Equal(timestamp, ulid2.Time.ToUnixTimeMilliseconds());
220+
Assert.Equal(initialRandom, ulid1.Random.ToArray());
244221

245-
var expectedRandom = new byte[10];
246-
expectedRandom[8] = 0x01; // ...0100
247-
expectedRandom[9] = 0x00;
222+
var expectedRandom = new byte[10];
223+
expectedRandom[8] = 0x01; // ...0100
224+
expectedRandom[9] = 0x00;
248225

249-
Assert.Equal(expectedRandom, ulid2.Random.ToArray());
250-
}
226+
Assert.Equal(expectedRandom, ulid2.Random.ToArray());
251227
}
252228

253229

@@ -260,40 +236,37 @@ public void New_MonotonicRandom_ShouldOverflowToTimestamp(
260236
Ulid.GenerationOptions.MonotonicityOptions monotonicity, int incrementSize
261237
)
262238
{
263-
lock (_staticStateLock)
239+
// Arrange
240+
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
241+
var initialRandom = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
242+
//Set all the last bytes to 0x00 that should be incremented later
243+
for(var i = initialRandom.Length - incrementSize; i < initialRandom.Length; i++)
264244
{
265-
// Arrange
266-
var timestamp = GetDateTimeOffset().ToUnixTimeMilliseconds();
267-
var initialRandom = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
268-
//Set all the last bytes to 0x00 that should be incremented later
269-
for(var i = initialRandom.Length - incrementSize; i < initialRandom.Length; i++)
270-
{
271-
initialRandom[i] = 0x00;
272-
}
273-
274-
var increment = Enumerable.Repeat<byte>(0xFF, incrementSize).ToArray();
275-
// overflow +1 comes from the base implementation of Ulid
276-
277-
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
278-
var incrementRandomProvider = new ControllableRandomProvider(increment);
279-
280-
var options = new Ulid.GenerationOptions
281-
{
282-
Monotonicity = monotonicity,
283-
InitialRandomSource = initialRandomProvider,
284-
IncrementRandomSource = incrementRandomProvider
285-
};
286-
287-
// Act
288-
var ulid1 = Ulid.New(timestamp, options); // Random is all 0xFF
289-
var ulid2 = Ulid.New(timestamp, options); // This should overflow
290-
291-
// Assert
292-
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
293-
Assert.Equal(initialRandom, ulid1.Random.ToArray());
294-
295-
Assert.Equal(timestamp + 1, ulid2.Time.ToUnixTimeMilliseconds());
296-
Assert.Equal(new byte[10], ulid2.Random.ToArray());
245+
initialRandom[i] = 0x00;
297246
}
247+
248+
var increment = Enumerable.Repeat<byte>(0xFF, incrementSize).ToArray();
249+
// overflow +1 comes from the base implementation of Ulid
250+
251+
var initialRandomProvider = new ControllableRandomProvider(initialRandom);
252+
var incrementRandomProvider = new ControllableRandomProvider(increment);
253+
254+
var options = new Ulid.GenerationOptions
255+
{
256+
Monotonicity = monotonicity,
257+
InitialRandomSource = initialRandomProvider,
258+
IncrementRandomSource = incrementRandomProvider
259+
};
260+
261+
// Act
262+
var ulid1 = Ulid.New(timestamp, options); // Random is all 0xFF
263+
var ulid2 = Ulid.New(timestamp, options); // This should overflow
264+
265+
// Assert
266+
Assert.Equal(timestamp, ulid1.Time.ToUnixTimeMilliseconds());
267+
Assert.Equal(initialRandom, ulid1.Random.ToArray());
268+
269+
Assert.Equal(timestamp + 1, ulid2.Time.ToUnixTimeMilliseconds());
270+
Assert.Equal(new byte[10], ulid2.Random.ToArray());
298271
}
299272
}

0 commit comments

Comments
 (0)