Skip to content

Commit 13a9fdc

Browse files
jbachorikclaude
andcommitted
feat(PROF-15098): add batch reapply-by-id-and-bytes context API
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent b6ab479 commit 13a9fdc

4 files changed

Lines changed: 263 additions & 7 deletions

File tree

ddprof-lib/src/main/java/com/datadoghq/profiler/ContextSetter.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
public class ContextSetter {
99

10+
// Must equal ThreadContext.MAX_CUSTOM_SLOTS — both cap the same physical slot range.
1011
private static final int TAGS_STORAGE_LIMIT = 10;
1112
private final List<String> attributes;
1213
private final JavaProfiler profiler;
@@ -30,7 +31,7 @@ public int[] snapshotTags() {
3031
}
3132

3233
public void snapshotTags(int[] snapshot) {
33-
if (snapshot.length <= attributes.size()) {
34+
if (snapshot.length >= attributes.size()) {
3435
profiler.copyTags(snapshot);
3536
}
3637
}
@@ -50,6 +51,17 @@ public boolean setContextValue(int offset, String value) {
5051
return false;
5152
}
5253

54+
/**
55+
* Re-applies attribute values from precomputed constant IDs and UTF-8 bytes, indexed by slot
56+
* (as produced by {@link #snapshotTags(int[])}). Restores both the DD sidecar encoding and the
57+
* OTEP attrs_data value for every slot whose constantId is {@code > 0}, in a single atomic
58+
* publish — no String allocation, hashing, or cache lookup. Intended for re-applying
59+
* application-managed context after a {@code setContext} span activation wipes the slots.
60+
*/
61+
public boolean setContextValuesByIdAndBytes(int[] constantIds, byte[][] utf8) {
62+
return profiler.setContextAttributesByIdAndBytes(constantIds, utf8);
63+
}
64+
5365
public boolean clearContextValue(String attribute) {
5466
return clearContextValue(offsetOf(attribute));
5567
}

ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ public void clearContextAttribute(int offset) {
221221
tlsContextStorage.get().clearContextAttribute(offset);
222222
}
223223

224+
public boolean setContextAttributesByIdAndBytes(int[] constantIds, byte[][] utf8) {
225+
return tlsContextStorage.get().setContextAttributesByIdAndBytes(constantIds, utf8);
226+
}
227+
224228
void copyTags(int[] snapshot) {
225229
tlsContextStorage.get().copyCustoms(snapshot);
226230
}

ddprof-lib/src/main/java/com/datadoghq/profiler/ThreadContext.java

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* for minimal overhead. Only little-endian platforms are supported.
2929
*/
3030
public final class ThreadContext {
31+
// Must equal ContextSetter.TAGS_STORAGE_LIMIT — both cap the same physical slot range.
3132
private static final int MAX_CUSTOM_SLOTS = 10;
3233
// Max UTF-8 byte length for a custom attribute value. Matches the 1-byte length
3334
// field in the OTEP attrs_data entry header. Enforced up front in setContextAttribute
@@ -313,17 +314,89 @@ private boolean setContextAttributeDirect(int keyIndex, String value) {
313314

314315
// Write both sidecar and OTEP attrs_data inside the detach/attach window
315316
// so a signal handler never sees a new sidecar encoding alongside old attrs_data.
316-
int otepKeyIndex = keyIndex + 1;
317317
detach();
318+
boolean written = writeSlot(keyIndex, encoding, utf8);
319+
attach();
320+
return written;
321+
}
322+
323+
/**
324+
* Writes one slot's sidecar encoding and OTEP attrs_data value. The caller must already hold
325+
* the detach/attach window. On attrs_data overflow the old entry was compacted out and the new
326+
* one couldn't fit, so the sidecar is zeroed to keep both views agreeing there is no value.
327+
*
328+
* @return true if the value was written; false on attrs_data overflow (sidecar left zeroed)
329+
*/
330+
private boolean writeSlot(int keyIndex, int encoding, byte[] utf8) {
318331
ctxBuffer.putInt(TAG_ENCODINGS_OFFSET + keyIndex * Integer.BYTES, encoding);
319-
boolean written = replaceOtepAttribute(otepKeyIndex, utf8);
320-
if (!written) {
321-
// attrs_data overflow: the old entry was compacted out and the new one
322-
// couldn't fit. Zero the sidecar so both views agree there is no value.
332+
if (!replaceOtepAttribute(keyIndex + 1, utf8)) {
323333
ctxBuffer.putInt(TAG_ENCODINGS_OFFSET + keyIndex * Integer.BYTES, 0);
334+
return false;
335+
}
336+
return true;
337+
}
338+
339+
/**
340+
* Re-applies multiple custom attributes from precomputed constant IDs and UTF-8 bytes in a
341+
* single detach/attach window, without Dictionary lookups or per-thread cache access.
342+
*
343+
* <p>Both arrays are indexed by key slot, matching the layout produced by
344+
* {@link #copyCustoms(int[])}. For each slot {@code i} with {@code constantIds[i] > 0}, the
345+
* sidecar encoding (DD signal handler) and the OTEP attrs_data value (external profilers) are
346+
* written together; slots with {@code constantIds[i] <= 0} are left untouched. Performing all
347+
* writes inside one detach/attach window means a signal handler never observes a partially
348+
* re-applied set.
349+
*
350+
* <p>Intended for the reapply-app-context hot path: the caller already holds the constant IDs
351+
* (from {@link #copyCustoms(int[])}) and the UTF-8 bytes (from the original Strings), so this
352+
* does no String allocation, hashing, or cache lookup. The record is re-published only if it
353+
* was valid before the call, so a cleared (span-less) record is not resurrected.
354+
*
355+
* <p><b>Caller contract.</b> {@code constantIds[i]} must be an ID previously returned for the
356+
* same value via {@link #copyCustoms(int[])} within the current profiler session, and
357+
* {@code utf8[i]} must be that value's UTF-8 bytes. The (id, bytes) pairing is not verifiable
358+
* here; a mismatch silently diverges the sidecar and attrs_data views.
359+
*
360+
* @param constantIds per-slot Dictionary constant IDs; entries {@code <= 0} are skipped
361+
* @param utf8 per-slot UTF-8 value bytes; must be non-null and at most
362+
* {@value #MAX_VALUE_BYTES} bytes for every slot whose constantId {@code > 0}
363+
* @return true if every slot with {@code constantId > 0} was written; false on invalid
364+
* arguments (null arrays, length mismatch, or a missing/oversized {@code utf8} entry),
365+
* if the record was not valid before the call (nothing published), or if any slot
366+
* overflowed attrs_data (that slot's sidecar is zeroed)
367+
*/
368+
public boolean setContextAttributesByIdAndBytes(int[] constantIds, byte[][] utf8) {
369+
if (constantIds == null || utf8 == null || constantIds.length != utf8.length) {
370+
return false;
371+
}
372+
int len = Math.min(constantIds.length, MAX_CUSTOM_SLOTS);
373+
// Validate before mutating so malformed input never leaves a half-written record.
374+
for (int i = 0; i < len; i++) {
375+
if (constantIds[i] > 0) {
376+
byte[] bytes = utf8[i];
377+
if (bytes == null || bytes.length > MAX_VALUE_BYTES) {
378+
return false;
379+
}
380+
}
381+
}
382+
// Never resurrect a cleared (span-less) record: valid=0 means no reader can observe
383+
// what we write, and re-publishing would expose a record with no trace/span context.
384+
if (ctxBuffer.get(validOffset) == 0) {
385+
return false;
386+
}
387+
detach();
388+
boolean allWritten = true;
389+
for (int i = 0; i < len; i++) {
390+
int constantId = constantIds[i];
391+
if (constantId <= 0) {
392+
continue;
393+
}
394+
if (!writeSlot(i, constantId, utf8[i])) {
395+
allWritten = false;
396+
}
324397
}
325398
attach();
326-
return written;
399+
return allWritten;
327400
}
328401

329402
/**

ddprof-test/src/test/java/com/datadoghq/profiler/context/TagContextTest.java

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.datadoghq.profiler.context;
22

3+
import java.nio.charset.StandardCharsets;
34
import java.util.Arrays;
45
import java.util.ArrayList;
56
import java.util.HashMap;
@@ -239,6 +240,172 @@ public void testCrossSlotIsolation() throws Exception {
239240
assertNull(readTag(contextSetter, "tag2"));
240241
}
241242

243+
@Test
244+
public void testReapplyByIdAndBytes() throws Exception {
245+
Assumptions.assumeTrue(!Platform.isJ9());
246+
registerCurrentThreadForWallClockProfiling();
247+
ContextSetter contextSetter = new ContextSetter(profiler, Arrays.asList("tag1", "tag2"));
248+
int slot = contextSetter.offsetOf("tag1");
249+
String value = "app-managed";
250+
251+
// Set the attribute the normal way, then capture both the constant ID (sidecar) and the
252+
// UTF-8 bytes — exactly what dd-trace-java retains for the reapply hot path.
253+
assertTrue(contextSetter.setContextValue("tag1", value));
254+
int[] ids = contextSetter.snapshotTags();
255+
int savedId = ids[slot];
256+
assertNotEquals(0, savedId);
257+
byte[][] bytes = new byte[ids.length][];
258+
bytes[slot] = value.getBytes(StandardCharsets.UTF_8);
259+
260+
// setContext (span activation) wipes both views.
261+
profiler.setContext(1L, 42L, 0L, 42L);
262+
assertNull(readTag(contextSetter, "tag1"), "attrs_data must be wiped by setContext");
263+
assertEquals(0, contextSetter.snapshotTags()[slot], "sidecar must be wiped by setContext");
264+
265+
// Reapply by ID + bytes restores BOTH views.
266+
assertTrue(contextSetter.setContextValuesByIdAndBytes(ids, bytes));
267+
assertEquals(value, readTag(contextSetter, "tag1"), "attrs_data must be restored");
268+
assertEquals(savedId, contextSetter.snapshotTags()[slot], "sidecar must be restored");
269+
}
270+
271+
@Test
272+
public void testReapplyByIdAndBytesRejectsBadArgs() throws Exception {
273+
Assumptions.assumeTrue(!Platform.isJ9());
274+
registerCurrentThreadForWallClockProfiling();
275+
ContextSetter contextSetter = new ContextSetter(profiler, Arrays.asList("tag1", "tag2"));
276+
int slot = contextSetter.offsetOf("tag1");
277+
278+
assertTrue(contextSetter.setContextValue("tag1", "v"));
279+
int id = contextSetter.snapshotTags()[slot];
280+
assertNotEquals(0, id);
281+
282+
// Null arrays and length mismatch.
283+
assertFalse(contextSetter.setContextValuesByIdAndBytes(null, new byte[1][]));
284+
assertFalse(contextSetter.setContextValuesByIdAndBytes(new int[1], null));
285+
assertFalse(contextSetter.setContextValuesByIdAndBytes(new int[2], new byte[3][]));
286+
287+
// A slot with constantId > 0 requires non-null bytes within the size limit.
288+
assertFalse(contextSetter.setContextValuesByIdAndBytes(
289+
new int[] {id, 0}, new byte[][] {null, null}));
290+
assertFalse(contextSetter.setContextValuesByIdAndBytes(
291+
new int[] {id, 0}, new byte[][] {new byte[256], null}));
292+
293+
// 255 bytes is the boundary and must be accepted.
294+
byte[] ok255 = new byte[255];
295+
Arrays.fill(ok255, (byte) 'x');
296+
assertTrue(contextSetter.setContextValuesByIdAndBytes(
297+
new int[] {id, 0}, new byte[][] {ok255, null}));
298+
}
299+
300+
@Test
301+
public void testReapplyByIdAndBytesReplacesExistingValue() throws Exception {
302+
Assumptions.assumeTrue(!Platform.isJ9());
303+
registerCurrentThreadForWallClockProfiling();
304+
ContextSetter contextSetter = new ContextSetter(profiler, Arrays.asList("tag1", "tag2"));
305+
int slot = contextSetter.offsetOf("tag1");
306+
307+
// Capture the ID + bytes for "first".
308+
assertTrue(contextSetter.setContextValue("tag1", "first"));
309+
int[] idsFirst = contextSetter.snapshotTags();
310+
byte[][] bytesFirst = new byte[idsFirst.length][];
311+
bytesFirst[slot] = "first".getBytes(StandardCharsets.UTF_8);
312+
313+
// Overwrite the live slot with a different value.
314+
assertTrue(contextSetter.setContextValue("tag1", "second"));
315+
assertEquals("second", readTag(contextSetter, "tag1"));
316+
317+
// Reapply "first" by ID + bytes over the live "second" — exercises the
318+
// compact-then-insert path in replaceOtepAttribute.
319+
assertTrue(contextSetter.setContextValuesByIdAndBytes(idsFirst, bytesFirst));
320+
assertEquals("first", readTag(contextSetter, "tag1"));
321+
assertEquals(idsFirst[slot], contextSetter.snapshotTags()[slot]);
322+
}
323+
324+
@Test
325+
public void testReapplyByIdAndBytesAfterClear() throws Exception {
326+
Assumptions.assumeTrue(!Platform.isJ9());
327+
registerCurrentThreadForWallClockProfiling();
328+
ContextSetter contextSetter = new ContextSetter(profiler, Arrays.asList("tag1", "tag2"));
329+
int slot = contextSetter.offsetOf("tag1");
330+
331+
assertTrue(contextSetter.setContextValue("tag1", "live"));
332+
int[] ids = contextSetter.snapshotTags();
333+
byte[][] bytes = new byte[ids.length][];
334+
bytes[slot] = "live".getBytes(StandardCharsets.UTF_8);
335+
336+
assertTrue(contextSetter.clearContextValue("tag1"));
337+
assertNull(readTag(contextSetter, "tag1"));
338+
assertEquals(0, contextSetter.snapshotTags()[slot]);
339+
340+
assertTrue(contextSetter.setContextValuesByIdAndBytes(ids, bytes));
341+
assertEquals("live", readTag(contextSetter, "tag1"));
342+
assertEquals(ids[slot], contextSetter.snapshotTags()[slot]);
343+
}
344+
345+
@Test
346+
public void testReapplyByIdAndBytesClearedRecord() throws Exception {
347+
// Verifies that setContextValuesByIdAndBytes never resurrects a cleared (span-less) record.
348+
// A cleared record has valid=0 and no trace/span context; re-publishing it would expose
349+
// attribute values with no associated trace, which is meaningless to the signal handler.
350+
Assumptions.assumeTrue(!Platform.isJ9());
351+
registerCurrentThreadForWallClockProfiling();
352+
ContextSetter contextSetter = new ContextSetter(profiler, Arrays.asList("tag1", "tag2"));
353+
int slot = contextSetter.offsetOf("tag1");
354+
355+
// Establish a live record with tag1 set.
356+
profiler.setContext(1L, 42L, 0L, 42L);
357+
assertTrue(contextSetter.setContextValue("tag1", "will-be-cleared"));
358+
int[] ids = contextSetter.snapshotTags();
359+
byte[][] bytes = new byte[ids.length][];
360+
bytes[slot] = "will-be-cleared".getBytes(StandardCharsets.UTF_8);
361+
362+
// Drive valid=0 via the all-zero clear path (clearContext → put(0,0,0,0) → no attach()).
363+
profiler.clearContext();
364+
// readContextAttribute respects valid=0 and returns null, confirming the record is dark.
365+
assertNull(readTag(contextSetter, "tag1"));
366+
367+
// Reapply must return false and must not resurrect the cleared record.
368+
assertFalse(contextSetter.setContextValuesByIdAndBytes(ids, bytes),
369+
"setContextValuesByIdAndBytes must return false when the record is cleared (valid=0)");
370+
assertNull(readTag(contextSetter, "tag1"),
371+
"cleared record must not be resurrected by setContextValuesByIdAndBytes");
372+
}
373+
374+
@Test
375+
public void testReapplyByIdAndBytesOverflowRollback() throws Exception {
376+
Assumptions.assumeTrue(!Platform.isJ9());
377+
registerCurrentThreadForWallClockProfiling();
378+
List<String> attrs = new ArrayList<>();
379+
for (int i = 1; i <= 10; i++) {
380+
attrs.add("tag" + i);
381+
}
382+
ContextSetter contextSetter = new ContextSetter(profiler, attrs);
383+
384+
// Register one 255-byte value to obtain a valid constant ID.
385+
char[] chars = new char[255];
386+
Arrays.fill(chars, 'x');
387+
String bigValue = new String(chars);
388+
assertTrue(contextSetter.setContextValue("tag1", bigValue));
389+
int bigId = contextSetter.snapshotTags()[contextSetter.offsetOf("tag1")];
390+
assertNotEquals(0, bigId);
391+
byte[] bigBytes = bigValue.getBytes(StandardCharsets.UTF_8);
392+
393+
// Reapply the same 255-byte value to all 10 slots — attrs_data cannot hold them all.
394+
int[] ids = new int[10];
395+
byte[][] bytes = new byte[10][];
396+
Arrays.fill(ids, bigId);
397+
Arrays.fill(bytes, bigBytes);
398+
assertFalse(contextSetter.setContextValuesByIdAndBytes(ids, bytes),
399+
"10 x 255-byte values must overflow attrs_data");
400+
401+
// The last slot certainly overflowed: its sidecar must be zeroed and attrs_data empty.
402+
int lastSlot = contextSetter.offsetOf("tag10");
403+
assertEquals(0, contextSetter.snapshotTags()[lastSlot],
404+
"overflowed slot's sidecar must be zeroed");
405+
assertNull(readTag(contextSetter, "tag10"),
406+
"overflowed slot must read null — the entry never landed in attrs_data");
407+
}
408+
242409
private void work(ContextSetter contextSetter, String contextAttribute, String contextValue)
243410
throws InterruptedException {
244411
assertTrue(contextSetter.setContextValue(contextAttribute, contextValue));

0 commit comments

Comments
 (0)