Skip to content

Commit

Permalink
Zero garbage serialization of ISO timestamps (#77)
Browse files Browse the repository at this point in the history
  • Loading branch information
felixbarny authored May 7, 2018
1 parent 6a3baa7 commit 6ea6440
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public class ErrorCapture implements Recyclable {
* Recorded time of the error, UTC based and formatted as YYYY-MM-DDTHH:mm:ss.sssZ
* (Required)
*/
private final Date timestamp = new Date();
private long timestamp;
/**
* Data for correlating errors with transactions
*/
Expand Down Expand Up @@ -86,12 +86,12 @@ public TransactionId getId() {
* Recorded time of the error, UTC based and formatted as YYYY-MM-DDTHH:mm:ss.sssZ
* (Required)
*/
public Date getTimestamp() {
public long getTimestamp() {
return timestamp;
}

public ErrorCapture withTimestamp(long epochMs) {
this.timestamp.setTime(epochMs);
this.timestamp = epochMs;
return this;
}

Expand All @@ -108,7 +108,7 @@ public void resetState() {
context.resetState();
id.resetState();
transaction.resetState();
timestamp.setTime(0);
timestamp = 0;
tracer = null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public class Transaction implements Recyclable, co.elastic.apm.api.Transaction {
* Recorded time of the transaction, UTC based and formatted as YYYY-MM-DDTHH:mm:ss.sssZ
* (Required)
*/
private final Date timestamp = new Date(0);
private long timestamp;
private final List<Span> spans = new ArrayList<Span>();
/**
* A mark captures the timing of a significant event during the lifetime of a transaction. Marks are organized into groups and can be set by the user or the agent.
Expand Down Expand Up @@ -96,7 +96,7 @@ public class Transaction implements Recyclable, co.elastic.apm.api.Transaction {
public Transaction start(ElasticApmTracer tracer, long startTimestampNanos, Sampler sampler) {
this.tracer = tracer;
this.duration = startTimestampNanos;
this.timestamp.setTime(System.currentTimeMillis());
this.timestamp = System.currentTimeMillis();
this.id.setToRandomValue();
this.sampled = sampler.isSampled(id);
this.noop = false;
Expand Down Expand Up @@ -185,18 +185,10 @@ public Transaction withResult(@Nullable String result) {
* Recorded time of the transaction, UTC based and formatted as YYYY-MM-DDTHH:mm:ss.sssZ
* (Required)
*/
public Date getTimestamp() {
public long getTimestamp() {
return timestamp;
}

public Transaction withTimestamp(long timestampEpoch) {
if (!sampled) {
return this;
}
this.timestamp.setTime(timestampEpoch);
return this;
}

public List<Span> getSpans() {
return spans;
}
Expand Down Expand Up @@ -305,7 +297,7 @@ public void resetState() {
id.resetState();
name.setLength(0);
result = null;
timestamp.setTime(0);
timestamp = 0;
spans.clear();
type = null;
marks.clear();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*-
* #%L
* Elastic APM Java agent
* %%
* Copyright (C) 2018 the original author or authors
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* #L%
*/
package co.elastic.apm.report.serialize;

import com.dslplatform.json.JsonWriter;
import com.dslplatform.json.NumberConverter;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;

/**
* This class serializes an epoch timestamp in milliseconds to a ISO 8601 date time sting,
* for example {@code 1970-01-01T00:00:00.000Z}
* <p>
* The main advantage of this class is that is able to serialize the timestamp in a garbage free way,
* i.e. without object allocations and that it is faster than {@link java.text.DateFormat#format(Date)}.
* </p>
* <p>
* The most complex part when formatting a ISO date is to determine the actual year,
* month and date as you have to account for leap years.
* Leveraging the fact that for a whole day this stays the same
* and that the agent only serializes the current timestamp and not arbitrary ones,
* we offload this task to {@link java.text.DateFormat#format(Date)} and cache the result.
* So we only have to serialize the time part of the ISO timestamp which is easy
* as a day has exactly {@code 1000 * 60 * 60 * 24} milliseconds.
* Also, we don't have to worry about leap seconds when dealing with the epoch timestamp.
* </p>
* <p>
* Note: this class is not thread safe.
* As serializing the payloads is done in a single thread,
* this is no problem though.
* </p>
*/
class DateSerializer {

private static final long MILLIS_PER_SECOND = 1000;
private static final long MILLIS_PER_MINUTE = MILLIS_PER_SECOND * 60;
private static final long MILLIS_PER_HOUR = MILLIS_PER_MINUTE * 60;
private static final long MILLIS_PER_DAY = MILLIS_PER_HOUR * 24;
private static final byte TIME_SEPARATOR = 'T';
private static final byte TIME_ZONE_SEPARATOR = 'Z';
private static final byte COLON = ':';
private static final byte DOT = '.';
private static final byte ZERO = '0';
private final SimpleDateFormat dateFormat;
// initialized in constructor via cacheDate
@SuppressWarnings("NullableProblems")
private String cachedDateIso;
private long startOfCachedDate;
private long endOfCachedDate;

DateSerializer() {
dateFormat = new SimpleDateFormat("yyyy-MM-dd");
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
cacheDate(System.currentTimeMillis());
}

private static long atStartOfDay(long epochTimestamp) {
return epochTimestamp - epochTimestamp % MILLIS_PER_DAY;
}

private static long atEndOfDay(long epochTimestamp) {
return atStartOfDay(epochTimestamp) + MILLIS_PER_DAY - 1;
}

void serializeEpochTimestampAsIsoDateTime(JsonWriter jw, long epochTimestamp) {
if (!isDateCached(epochTimestamp)) {
cacheDate(epochTimestamp);
}
jw.writeAscii(cachedDateIso);

jw.writeByte(TIME_SEPARATOR);

// hours
long remainder = epochTimestamp % MILLIS_PER_DAY;
serializeWithLeadingZero(jw, remainder / MILLIS_PER_HOUR, 2);
jw.writeByte(COLON);

// minutes
remainder %= MILLIS_PER_HOUR;
serializeWithLeadingZero(jw, remainder / MILLIS_PER_MINUTE, 2);
jw.writeByte(COLON);

// seconds
remainder %= MILLIS_PER_MINUTE;
serializeWithLeadingZero(jw, remainder / MILLIS_PER_SECOND, 2);
jw.writeByte(DOT);

// milliseconds
remainder %= MILLIS_PER_SECOND;
serializeWithLeadingZero(jw, remainder, 3);

jw.writeByte(TIME_ZONE_SEPARATOR);
}

private void serializeWithLeadingZero(JsonWriter jw, long value, int minLength) {
for (int i = minLength - 1; i > 0; i--) {
if (value < Math.pow(10, i)) {
jw.writeByte(ZERO);
}
}
NumberConverter.serialize(value, jw);
}

private void cacheDate(long epochTimestamp) {
cachedDateIso = dateFormat.format(new Date(epochTimestamp));
startOfCachedDate = atStartOfDay(epochTimestamp);
endOfCachedDate = atEndOfDay(epochTimestamp);
}

private boolean isDateCached(long epochTimestamp) {
return epochTimestamp >= startOfCachedDate && epochTimestamp <= endOfCachedDate;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,16 @@
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;

import static com.dslplatform.json.JsonWriter.ARRAY_END;
import static com.dslplatform.json.JsonWriter.ARRAY_START;
import static com.dslplatform.json.JsonWriter.COMMA;
import static com.dslplatform.json.JsonWriter.OBJECT_END;
import static com.dslplatform.json.JsonWriter.OBJECT_START;
import static com.dslplatform.json.JsonWriter.QUOTE;

public class DslJsonSerializer implements PayloadSerializer {

Expand All @@ -77,13 +75,12 @@ public class DslJsonSerializer implements PayloadSerializer {

// visible for testing
final JsonWriter jw;
private final DateFormat dateFormat;
private final StringBuilder replaceBuilder = new StringBuilder(MAX_VALUE_LENGTH);
private final DateSerializer dateSerializer;

public DslJsonSerializer() {
jw = new DslJson<>().newWriter();
dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
dateSerializer = new DateSerializer();
}

@Override
Expand Down Expand Up @@ -129,16 +126,13 @@ private void serializeError(ErrorCapture errorCapture) {
jw.writeByte(JsonWriter.OBJECT_START);

final TransactionId id = errorCapture.getId();
final String fieldName = "id";
writeField(fieldName, id);
writeField("id", id);
writeDateField("timestamp", errorCapture.getTimestamp());

serializeTransactionReference(errorCapture);
serializeContext(errorCapture.getContext());
serializeException(errorCapture.getException());

// TODO date formatting allocates objects
// writeLastField("timestamp", errorCapture.getTimestamp().getTime());
writeLastField("timestamp", dateFormat.format(errorCapture.getTimestamp()));
jw.writeByte(JsonWriter.OBJECT_END);
}

Expand All @@ -162,7 +156,6 @@ private void serializeException(ExceptionInfo exception) {
serializeStacktrace(exception.getStacktrace());
writeLastField("type", exception.getType());
jw.writeByte(JsonWriter.OBJECT_END);
jw.writeByte(COMMA);
}

public String toJsonString(final Payload payload) {
Expand Down Expand Up @@ -314,9 +307,7 @@ private void serializeTransactions(final List<Transaction> transactions) {

private void serializeTransaction(final Transaction transaction) {
jw.writeByte(OBJECT_START);
// TODO date formatting allocates objects
// writeField("timestamp", transaction.getTimestamp().getTime());
writeField("timestamp", dateFormat.format(transaction.getTimestamp()));
writeDateField("timestamp", transaction.getTimestamp());
writeField("name", transaction.getName());
writeField("id", transaction.getId());
writeField("type", transaction.getType());
Expand Down Expand Up @@ -681,4 +672,12 @@ private void writeField(String fieldName, TransactionId id) {
UUIDConverter.serialize(id.getMostSignificantBits(), id.getLeastSignificantBits(), jw);
jw.writeByte(COMMA);
}

private void writeDateField(final String fieldName, final long timestamp) {
writeFieldName(fieldName);
jw.writeByte(QUOTE);
dateSerializer.serializeEpochTimestampAsIsoDateTime(jw, timestamp);
jw.writeByte(QUOTE);
jw.writeByte(COMMA);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*-
* #%L
* Elastic APM Java agent
* %%
* Copyright (C) 2018 the original author or authors
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* #L%
*/
package co.elastic.apm.report.serialize;

import com.dslplatform.json.DslJson;
import com.dslplatform.json.JsonWriter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

import static org.assertj.core.api.Assertions.assertThat;

class DateSerializerTest {

private DateSerializer dateSerializer;
private JsonWriter jsonWriter;
private DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneId.of("UTC"));


@BeforeEach
void setUp() {
jsonWriter = new DslJson<>().newWriter();
dateSerializer = new DateSerializer();
}

@Test
void testSerializeEpochTimestampAsIsoDateTime() {
long timestamp = 0;
long lastTimestampToCheck = LocalDateTime.now()
.plus(1, ChronoUnit.YEARS)
.toInstant(ZoneOffset.UTC)
.toEpochMilli();
// interval is approximately a hour but not exactly
// to get different values for the minutes, seconds and milliseconds
long interval = 997 * 61 * 61;
for (; timestamp <= lastTimestampToCheck; timestamp += interval) {
assertDateFormattingIsCorrect(Instant.ofEpochMilli(timestamp));
}
}

private void assertDateFormattingIsCorrect(Instant instant) {
jsonWriter.reset();
dateSerializer.serializeEpochTimestampAsIsoDateTime(jsonWriter, instant.toEpochMilli());
assertThat(jsonWriter.toString()).isEqualTo(dateTimeFormatter.format(instant));
}
}

0 comments on commit 6ea6440

Please sign in to comment.