From 3150bfa876829d38f337345088372941f00f6d64 Mon Sep 17 00:00:00 2001 From: Areek Zillur Date: Wed, 6 Jul 2016 17:09:20 -0400 Subject: [PATCH] Simplify expiry callback code Original commit: elastic/x-pack-elasticsearch@56e8e19048e8f76141d6fa6c5084b48903f58644 --- .../plugin/core/ExpirationCallback.java | 176 +++++++++--------- .../plugin/core/ExpirationCallbackTests.java | 162 +++++++++------- .../plugin/core/LicenseSchedulingTests.java | 56 ------ 3 files changed, 179 insertions(+), 215 deletions(-) delete mode 100644 elasticsearch/x-pack/license-plugin/src/test/java/org/elasticsearch/license/plugin/core/LicenseSchedulingTests.java diff --git a/elasticsearch/x-pack/license-plugin/src/main/java/org/elasticsearch/license/plugin/core/ExpirationCallback.java b/elasticsearch/x-pack/license-plugin/src/main/java/org/elasticsearch/license/plugin/core/ExpirationCallback.java index d63539c2fb7..83f6b49d775 100644 --- a/elasticsearch/x-pack/license-plugin/src/main/java/org/elasticsearch/license/plugin/core/ExpirationCallback.java +++ b/elasticsearch/x-pack/license-plugin/src/main/java/org/elasticsearch/license/plugin/core/ExpirationCallback.java @@ -8,7 +8,6 @@ package org.elasticsearch.license.plugin.core; import org.elasticsearch.common.logging.LoggerMessageFormat; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.license.core.License; -import org.elasticsearch.xpack.scheduler.SchedulerEngine; import java.util.UUID; @@ -30,22 +29,6 @@ public abstract class ExpirationCallback { public Pre(TimeValue min, TimeValue max, TimeValue frequency) { super(Orientation.PRE, min, max, frequency); } - - @Override - public boolean matches(long expirationDate, long now) { - long expiryDuration = expirationDate - now; - if (expiryDuration > 0L) { - if (expiryDuration <= max.getMillis()) { - return expiryDuration >= min.getMillis(); - } - } - return false; - } - - @Override - public TimeValue delay(long expirationDate, long now) { - return TimeValue.timeValueMillis((expirationDate - now) - max.getMillis()); - } } public abstract static class Post extends ExpirationCallback { @@ -60,97 +43,114 @@ public abstract class ExpirationCallback { public Post(TimeValue min, TimeValue max, TimeValue frequency) { super(Orientation.POST, min, max, frequency); } - - @Override - public boolean matches(long expirationDate, long now) { - long postExpiryDuration = now - expirationDate; - if (postExpiryDuration > 0L) { - if (postExpiryDuration <= max.getMillis()) { - return postExpiryDuration >= min.getMillis(); - } - } - return false; - } - - @Override - public TimeValue delay(long expirationDate, long now) { - long expiryDuration = expirationDate - now; - final long delay; - if (expiryDuration >= 0L) { - delay = expiryDuration + min.getMillis(); - } else { - delay = (-1L * expiryDuration) - min.getMillis(); - } - if (delay > 0L) { - return TimeValue.timeValueMillis(delay); - } else { - return null; - } - } } private final String id; - protected final Orientation orientation; - protected final TimeValue min; - protected final TimeValue max; - private final TimeValue frequency; + private final Orientation orientation; + private final long min; + private final long max; + private final long frequency; private ExpirationCallback(Orientation orientation, TimeValue min, TimeValue max, TimeValue frequency) { this.orientation = orientation; - this.min = (min == null) ? TimeValue.timeValueMillis(0) : min; - this.max = (max == null) ? TimeValue.timeValueMillis(Long.MAX_VALUE) : max; - this.frequency = frequency; + this.min = (min == null) ? 0 : min.getMillis(); + this.max = (max == null) ? Long.MAX_VALUE : max.getMillis(); + this.frequency = frequency.getMillis(); this.id = String.join("", EXPIRATION_JOB_PREFIX, UUID.randomUUID().toString()); } - public String getId() { + public final String getId() { return id; } - public TimeValue frequency() { + public final long getFrequency() { return frequency; } - public abstract TimeValue delay(long expirationDate, long now); - - public abstract boolean matches(long expirationDate, long now); - - public abstract void on(License license); - - public SchedulerEngine.Schedule schedule(long expiryDate) { - return new ExpirySchedule(expiryDate); - } - - public String toString() { - return LoggerMessageFormat.format(null, "ExpirationCallback:(orientation [{}], min [{}], max [{}], freq [{}])", - orientation.name(), min, max, frequency); - } - - private class ExpirySchedule implements SchedulerEngine.Schedule { - - private final long expiryDate; - - private ExpirySchedule(long expiryDate) { - this.expiryDate = expiryDate; - } - - @Override - public long nextScheduledTimeAfter(long startTime, long time) { - if (matches(expiryDate, time)) { - if (startTime == time) { - return time; + /** + * The delay for the first notification, when the current time + * is not in the valid time bracket for this callback + * @param expirationDate license expiry date in milliseconds + * @param now current time in milliseconds + * @return time delay for the first notification + */ + final TimeValue delay(long expirationDate, long now) { + final TimeValue delay; + switch (orientation) { + case PRE: + if (expirationDate >= now) { + // license not yet expired + long preExpiryDuration = expirationDate - now; + if (preExpiryDuration > max) { + // license duration is longer than maximum duration, delay it to the first match time + delay = TimeValue.timeValueMillis(preExpiryDuration - max); + } else if (preExpiryDuration <= max && preExpiryDuration >= min) { + // no delay in valid time bracket + delay = TimeValue.timeValueMillis(0); + } else { + // passed last match time + delay = null; + } } else { - return time + frequency().getMillis(); + // invalid after license expiry + delay = null; } - } else { - if (startTime == time) { - final TimeValue delay = delay(expiryDate, time); - if (delay != null) { - return time + delay.getMillis(); + break; + case POST: + if (expirationDate >= now) { + // license not yet expired, delay it to the first match time + delay = TimeValue.timeValueMillis(expirationDate - now + min); + } else { + // license has expired + long expiredDuration = now - expirationDate; + if (expiredDuration < min) { + // license expiry duration is shorter than minimum duration, delay it to the first match time + delay = TimeValue.timeValueMillis(min - expiredDuration); + } else if (expiredDuration >= min && expiredDuration <= max) { + // no delay in valid time bracket + delay = TimeValue.timeValueMillis(0); + } else { + // passed last match time + delay = null; } } - return -1; + break; + default: + throw new IllegalStateException("orientation [" + orientation + "] unknown"); + } + return delay; + } + + public final long nextScheduledTimeForExpiry(long expiryDate, long startTime, long time) { + TimeValue delay = delay(expiryDate, time); + if (delay != null) { + long delayInMillis = delay.getMillis(); + if (delayInMillis == 0L) { + if (startTime == time) { + // initial trigger and in time bracket, schedule immediately + return time; + } else { + // in time bracket, add frequency + return time + frequency; + } + } else { + // not in time bracket + return time + delayInMillis; } } + return -1; + } + + /** + * Code to execute when the expiry callback is triggered in a valid + * time bracket + * @param license license to operate on + */ + public abstract void on(License license); + + public final String toString() { + return LoggerMessageFormat.format(null, "ExpirationCallback:(orientation [{}], min [{}], max [{}], freq [{}])", + orientation.name(), TimeValue.timeValueMillis(min), TimeValue.timeValueMillis(max), + TimeValue.timeValueMillis(frequency)); } } \ No newline at end of file diff --git a/elasticsearch/x-pack/license-plugin/src/test/java/org/elasticsearch/license/plugin/core/ExpirationCallbackTests.java b/elasticsearch/x-pack/license-plugin/src/test/java/org/elasticsearch/license/plugin/core/ExpirationCallbackTests.java index 6b44c891b5d..0e5bc9c46a5 100644 --- a/elasticsearch/x-pack/license-plugin/src/test/java/org/elasticsearch/license/plugin/core/ExpirationCallbackTests.java +++ b/elasticsearch/x-pack/license-plugin/src/test/java/org/elasticsearch/license/plugin/core/ExpirationCallbackTests.java @@ -8,23 +8,41 @@ package org.elasticsearch.license.plugin.core; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.license.core.License; import org.elasticsearch.test.ESTestCase; -import org.elasticsearch.xpack.scheduler.SchedulerEngine; import static org.elasticsearch.common.unit.TimeValue.timeValueMillis; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; public class ExpirationCallbackTests extends ESTestCase { - public void testPostExpiration() throws Exception { - int postExpirySeconds = randomIntBetween(5, 10); - TimeValue postExpiryDuration = TimeValue.timeValueSeconds(postExpirySeconds); - TimeValue min = TimeValue.timeValueSeconds(postExpirySeconds - randomIntBetween(1, 3)); - TimeValue max = TimeValue.timeValueSeconds(postExpirySeconds + randomIntBetween(1, 10)); - - final ExpirationCallback.Post post = new NoopPostExpirationCallback(min, max, timeValueMillis(10)); + public void testPostExpirationDelay() throws Exception { + TimeValue expiryDuration = TimeValue.timeValueSeconds(randomIntBetween(5, 10)); + TimeValue min = TimeValue.timeValueSeconds(1); + TimeValue max = TimeValue.timeValueSeconds(4); + TimeValue frequency = TimeValue.timeValueSeconds(1); + NoopPostExpirationCallback post = new NoopPostExpirationCallback(min, max, frequency); long now = System.currentTimeMillis(); - assertThat(post.matches(now - postExpiryDuration.millis(), now), equalTo(true)); - assertThat(post.matches(now + postExpiryDuration.getMillis(), now), equalTo(false)); + long expiryDate = now + expiryDuration.getMillis(); + assertThat(post.delay(expiryDate, now), + equalTo(TimeValue.timeValueMillis(expiryDuration.getMillis() + min.getMillis()))); // before license expiry + assertThat(post.delay(expiryDate, expiryDate), equalTo(min)); // on license expiry + int latestValidTriggerDelay = (int) (expiryDuration.getMillis() + max.getMillis()); + int earliestValidTriggerDelay = (int) (expiryDuration.getMillis() + min.getMillis()); + assertExpirationCallbackDelay(post, expiryDuration.millis(), latestValidTriggerDelay, earliestValidTriggerDelay); + } + + public void testPreExpirationDelay() throws Exception { + TimeValue expiryDuration = TimeValue.timeValueSeconds(randomIntBetween(5, 10)); + TimeValue min = TimeValue.timeValueSeconds(1); + TimeValue max = TimeValue.timeValueSeconds(4); + TimeValue frequency = TimeValue.timeValueSeconds(1); + NoopPreExpirationCallback pre = new NoopPreExpirationCallback(min, max, frequency); + long now = System.currentTimeMillis(); + long expiryDate = now + expiryDuration.getMillis(); + assertThat(pre.delay(expiryDate, expiryDate), nullValue()); // on license expiry + int latestValidTriggerDelay = (int) (expiryDuration.getMillis() - min.getMillis()); + int earliestValidTriggerDelay = (int) (expiryDuration.getMillis() - max.getMillis()); + assertExpirationCallbackDelay(pre, expiryDuration.millis(), latestValidTriggerDelay, earliestValidTriggerDelay); } public void testPostExpirationWithNullMax() throws Exception { @@ -34,7 +52,7 @@ public class ExpirationCallbackTests extends ESTestCase { final ExpirationCallback.Post post = new NoopPostExpirationCallback(min, null, timeValueMillis(10)); long now = System.currentTimeMillis(); - assertThat(post.matches(now - postExpiryDuration.millis(), now), equalTo(true)); + assertThat(post.delay(now - postExpiryDuration.millis(), now), equalTo(TimeValue.timeValueMillis(0))); } public void testPreExpirationWithNullMin() throws Exception { @@ -44,73 +62,75 @@ public class ExpirationCallbackTests extends ESTestCase { final ExpirationCallback.Pre pre = new NoopPreExpirationCallback(null, max, timeValueMillis(10)); long now = System.currentTimeMillis(); - assertThat(pre.matches(expiryDuration.millis() + now, now), equalTo(true)); + assertThat(pre.delay(expiryDuration.millis() + now, now), equalTo(TimeValue.timeValueMillis(0))); } - public void testPreExpiration() throws Exception { - int expirySeconds = randomIntBetween(5, 10); - TimeValue expiryDuration = TimeValue.timeValueSeconds(expirySeconds); - TimeValue min = TimeValue.timeValueSeconds(expirySeconds - randomIntBetween(0, 3)); - TimeValue max = TimeValue.timeValueSeconds(expirySeconds + randomIntBetween(1, 10)); - final ExpirationCallback.Pre pre = new NoopPreExpirationCallback(min, max, timeValueMillis(10)); + public void testPreExpirationScheduleTime() throws Exception { + TimeValue expiryDuration = TimeValue.timeValueSeconds(randomIntBetween(5, 10)); + TimeValue min = TimeValue.timeValueSeconds(1); + TimeValue max = TimeValue.timeValueSeconds(4); + TimeValue frequency = TimeValue.timeValueSeconds(1); + NoopPreExpirationCallback pre = new NoopPreExpirationCallback(min, max, frequency); + int latestValidTriggerDelay = (int) (expiryDuration.getMillis() - min.getMillis()); + int earliestValidTriggerDelay = (int) (expiryDuration.getMillis() - max.getMillis()); + assertExpirationCallbackScheduleTime(pre, expiryDuration.millis(), latestValidTriggerDelay, earliestValidTriggerDelay); + } + + public void testPostExpirationScheduleTime() throws Exception { + TimeValue expiryDuration = TimeValue.timeValueSeconds(randomIntBetween(5, 10)); + TimeValue min = TimeValue.timeValueSeconds(1); + TimeValue max = TimeValue.timeValueSeconds(4); + TimeValue frequency = TimeValue.timeValueSeconds(1); + NoopPostExpirationCallback pre = new NoopPostExpirationCallback(min, max, frequency); + int latestValidTriggerDelay = (int) (expiryDuration.getMillis() + max.getMillis()); + int earliestValidTriggerDelay = (int) (expiryDuration.getMillis() + min.getMillis()); + assertExpirationCallbackScheduleTime(pre, expiryDuration.millis(), latestValidTriggerDelay, earliestValidTriggerDelay); + } + + private void assertExpirationCallbackDelay(ExpirationCallback expirationCallback, long expiryDuration, + int latestValidTriggerDelay, int earliestValidTriggerDelay) { long now = System.currentTimeMillis(); - assertThat(pre.matches(expiryDuration.millis() + now, now), equalTo(true)); - assertThat(pre.matches(now - expiryDuration.getMillis(), now), equalTo(false)); + long expiryDate = now + expiryDuration; + // bounds + assertThat(expirationCallback.delay(expiryDate, now + earliestValidTriggerDelay), equalTo(TimeValue.timeValueMillis(0))); + assertThat(expirationCallback.delay(expiryDate, now + latestValidTriggerDelay), equalTo(TimeValue.timeValueMillis(0))); + // in match + assertThat(expirationCallback.delay(expiryDate, + now + randomIntBetween(earliestValidTriggerDelay, latestValidTriggerDelay)), + equalTo(TimeValue.timeValueMillis(0))); + // out of bounds + int deltaBeforeEarliestMatch = between(1, earliestValidTriggerDelay); + assertThat(expirationCallback.delay(expiryDate, now + deltaBeforeEarliestMatch), + equalTo(TimeValue.timeValueMillis(earliestValidTriggerDelay - deltaBeforeEarliestMatch))); + int deltaAfterLatestMatch = between(latestValidTriggerDelay + 1, Integer.MAX_VALUE); // after expiry and after max + assertThat(expirationCallback.delay(expiryDate, expiryDate + deltaAfterLatestMatch), nullValue()); } - public void testPreExpirationMatchSchedule() throws Exception { - long expirySeconds = randomIntBetween(5, 10); - TimeValue expiryDuration = TimeValue.timeValueSeconds(expirySeconds); - TimeValue min = TimeValue.timeValueSeconds(expirySeconds - randomIntBetween(0, 3)); - TimeValue max = TimeValue.timeValueSeconds(expirySeconds + randomIntBetween(1, 10)); - final ExpirationCallback.Pre pre = new NoopPreExpirationCallback(min, max, timeValueMillis(10)); - long expiryDate = System.currentTimeMillis() + expiryDuration.getMillis(); - final SchedulerEngine.Schedule schedule = pre.schedule(expiryDate); - final long now = expiryDate - max.millis() + randomIntBetween(1, ((int) min.getMillis())); - assertThat(schedule.nextScheduledTimeAfter(0, now), equalTo(now + pre.frequency().getMillis())); - assertThat(schedule.nextScheduledTimeAfter(now, now), equalTo(now)); - } + public void assertExpirationCallbackScheduleTime(ExpirationCallback expirationCallback, long expiryDuration, + int latestValidTriggerDelay, int earliestValidTriggerDelay) { + long now = System.currentTimeMillis(); + long expiryDate = now + expiryDuration; + int validTriggerInterval = between(earliestValidTriggerDelay, latestValidTriggerDelay); + assertThat(expirationCallback.nextScheduledTimeForExpiry(expiryDate, + now + validTriggerInterval, now + validTriggerInterval), + equalTo(now + validTriggerInterval)); + assertThat(expirationCallback.nextScheduledTimeForExpiry(expiryDate, now, now + validTriggerInterval), + equalTo(now + validTriggerInterval + expirationCallback.getFrequency())); - public void testPreExpirationNotMatchSchedule() throws Exception { - long expirySeconds = randomIntBetween(5, 10); - TimeValue expiryDuration = TimeValue.timeValueSeconds(expirySeconds); - TimeValue min = TimeValue.timeValueSeconds(expirySeconds - randomIntBetween(0, 3)); - TimeValue max = TimeValue.timeValueSeconds(expirySeconds + randomIntBetween(1, 10)); - final ExpirationCallback.Pre pre = new NoopPreExpirationCallback(min, max, timeValueMillis(10)); - long expiryDate = System.currentTimeMillis() + expiryDuration.getMillis(); - final SchedulerEngine.Schedule schedule = pre.schedule(expiryDate); - int delta = randomIntBetween(1, 1000); - final long now = expiryDate - max.millis() - delta; - assertThat(schedule.nextScheduledTimeAfter(now, now), equalTo(now + delta)); - assertThat(schedule.nextScheduledTimeAfter(1, now), equalTo(-1L)); - } + int deltaBeforeEarliestMatch = between(1, earliestValidTriggerDelay); + assertThat(expirationCallback.nextScheduledTimeForExpiry(expiryDate, now, now + deltaBeforeEarliestMatch), + equalTo(now + deltaBeforeEarliestMatch + + expirationCallback.delay(expiryDate, now + deltaBeforeEarliestMatch).getMillis())); + assertThat(expirationCallback.nextScheduledTimeForExpiry(expiryDate, + now + deltaBeforeEarliestMatch, now + deltaBeforeEarliestMatch), + equalTo(now + deltaBeforeEarliestMatch + + expirationCallback.delay(expiryDate, now + deltaBeforeEarliestMatch).getMillis())); - public void testPostExpirationMatchSchedule() throws Exception { - long expirySeconds = randomIntBetween(5, 10); - TimeValue expiryDuration = TimeValue.timeValueSeconds(expirySeconds); - TimeValue min = TimeValue.timeValueSeconds(expirySeconds - randomIntBetween(0, 3)); - TimeValue max = TimeValue.timeValueSeconds(expirySeconds + randomIntBetween(1, 10)); - final ExpirationCallback.Post post = new NoopPostExpirationCallback(min, max, timeValueMillis(10)); - long expiryDate = System.currentTimeMillis() + expiryDuration.getMillis(); - final SchedulerEngine.Schedule schedule = post.schedule(expiryDate); - final long now = expiryDate + min.millis() + randomIntBetween(1, ((int) (max.getMillis() - min.getMillis()))); - assertThat(schedule.nextScheduledTimeAfter(0, now), equalTo(now + post.frequency().getMillis())); - assertThat(schedule.nextScheduledTimeAfter(now, now), equalTo(now)); - } - - public void testPostExpirationNotMatchSchedule() throws Exception { - long expirySeconds = randomIntBetween(5, 10); - TimeValue expiryDuration = TimeValue.timeValueSeconds(expirySeconds); - TimeValue min = TimeValue.timeValueSeconds(expirySeconds - randomIntBetween(0, 3)); - TimeValue max = TimeValue.timeValueSeconds(expirySeconds + randomIntBetween(1, 10)); - final ExpirationCallback.Post post = new NoopPostExpirationCallback(min, max, timeValueMillis(10)); - long expiryDate = System.currentTimeMillis() + expiryDuration.getMillis(); - final SchedulerEngine.Schedule schedule = post.schedule(expiryDate); - int delta = randomIntBetween(1, 1000); - final long now = expiryDate - delta; - assertThat(schedule.nextScheduledTimeAfter(expiryDate, expiryDate), equalTo(expiryDate + min.getMillis())); - assertThat(schedule.nextScheduledTimeAfter(now, now), equalTo(expiryDate + min.getMillis())); - assertThat(schedule.nextScheduledTimeAfter(1, now), equalTo(-1L)); + int deltaAfterLatestMatch = between(latestValidTriggerDelay + 1, Integer.MAX_VALUE); // after expiry and after max + assertThat(expirationCallback.nextScheduledTimeForExpiry(expiryDate, now, now + deltaAfterLatestMatch), equalTo(-1L)); + assertThat(expirationCallback.nextScheduledTimeForExpiry(expiryDate, + now + deltaAfterLatestMatch, now + deltaAfterLatestMatch), + equalTo(-1L)); } private static class NoopPostExpirationCallback extends ExpirationCallback.Post { diff --git a/elasticsearch/x-pack/license-plugin/src/test/java/org/elasticsearch/license/plugin/core/LicenseSchedulingTests.java b/elasticsearch/x-pack/license-plugin/src/test/java/org/elasticsearch/license/plugin/core/LicenseSchedulingTests.java deleted file mode 100644 index bb11cc13e92..00000000000 --- a/elasticsearch/x-pack/license-plugin/src/test/java/org/elasticsearch/license/plugin/core/LicenseSchedulingTests.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -package org.elasticsearch.license.plugin.core; - -import org.elasticsearch.common.unit.TimeValue; -import org.elasticsearch.license.core.License; -import org.elasticsearch.license.plugin.TestUtils; -import org.elasticsearch.xpack.scheduler.SchedulerEngine; - -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -public class LicenseSchedulingTests extends AbstractLicenseServiceTestCase { - - public void testSchedulingOnRegistration() throws Exception { - final License license = TestUtils.generateSignedLicense(TimeValue.timeValueHours(48)); - setInitialState(license); - licensesService.start(); - int nLicensee = randomIntBetween(1, 3); - TestUtils.AssertingLicensee[] assertingLicensees = new TestUtils.AssertingLicensee[nLicensee]; - for (int i = 0; i < assertingLicensees.length; i++) { - assertingLicensees[i] = new TestUtils.AssertingLicensee("testLicenseNotification" + i, logger); - licensesService.register(assertingLicensees[i]); - } - verify(schedulerEngine, times(0)).add(any(SchedulerEngine.Job.class)); - licensesService.stop(); - } - - public void testSchedulingSameLicense() throws Exception { - final License license = TestUtils.generateSignedLicense(TimeValue.timeValueHours(48)); - setInitialState(license); - licensesService.start(); - final TestUtils.AssertingLicensee licensee = new TestUtils.AssertingLicensee("testLicenseNotification", logger); - licensesService.register(licensee); - licensesService.onUpdate(new LicensesMetaData(license)); - verify(schedulerEngine, times(4)).add(any(SchedulerEngine.Job.class)); - licensesService.stop(); - } - - public void testSchedulingNewLicense() throws Exception { - final License license = TestUtils.generateSignedLicense(TimeValue.timeValueHours(2)); - setInitialState(null); - licensesService.start(); - licensesService.onUpdate(new LicensesMetaData(license)); - License newLicense = TestUtils.generateSignedLicense(TimeValue.timeValueHours(2)); - licensesService.onUpdate(new LicensesMetaData(newLicense)); - verify(schedulerEngine, times(8)).add(any(SchedulerEngine.Job.class)); - licensesService.stop(); - } - -} \ No newline at end of file