Future-counting test
This commit is contained in:
parent
9970f225cc
commit
05e60a6b74
|
@ -62,6 +62,7 @@ namespace ContinuousTests
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
OverviewLog(" > Starting test. " + FuturesInfo());
|
||||||
RunTestMoments();
|
RunTestMoments();
|
||||||
|
|
||||||
if (!config.KeepPassedTestLogs) fixtureLog.Delete();
|
if (!config.KeepPassedTestLogs) fixtureLog.Delete();
|
||||||
|
@ -112,7 +113,7 @@ namespace ContinuousTests
|
||||||
{
|
{
|
||||||
ThrowFailTest();
|
ThrowFailTest();
|
||||||
}
|
}
|
||||||
OverviewLog(" > Test passed.");
|
OverviewLog(" > Test passed. " + FuturesInfo());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -122,10 +123,19 @@ namespace ContinuousTests
|
||||||
{
|
{
|
||||||
var ex = UnpackException(exceptions.First());
|
var ex = UnpackException(exceptions.First());
|
||||||
Log(ex.ToString());
|
Log(ex.ToString());
|
||||||
OverviewLog(" > Test failed: " + ex.Message);
|
OverviewLog($" > Test failed {FuturesInfo()}: " + ex.Message);
|
||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string FuturesInfo()
|
||||||
|
{
|
||||||
|
var containers = config.CodexDeployment.CodexContainers;
|
||||||
|
var nodes = codexNodeFactory.Create(config, containers, fixtureLog, handle.Test.TimeSet);
|
||||||
|
var f = nodes.Select(n => n.GetDebugFutures().ToString());
|
||||||
|
var msg = $"(Futures: [{string.Join(", ", f)}])";
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
private void DownloadClusterLogs()
|
private void DownloadClusterLogs()
|
||||||
{
|
{
|
||||||
var k8sFactory = new K8sFactory();
|
var k8sFactory = new K8sFactory();
|
||||||
|
|
|
@ -1,99 +1,99 @@
|
||||||
using DistTestCore;
|
//using DistTestCore;
|
||||||
using DistTestCore.Codex;
|
//using DistTestCore.Codex;
|
||||||
using Newtonsoft.Json;
|
//using Newtonsoft.Json;
|
||||||
using NUnit.Framework;
|
//using NUnit.Framework;
|
||||||
using Utils;
|
//using Utils;
|
||||||
|
|
||||||
namespace ContinuousTests.Tests
|
//namespace ContinuousTests.Tests
|
||||||
{
|
//{
|
||||||
public class MarketplaceTest : ContinuousTest
|
// public class MarketplaceTest : ContinuousTest
|
||||||
{
|
// {
|
||||||
public override int RequiredNumberOfNodes => 1;
|
// public override int RequiredNumberOfNodes => 1;
|
||||||
public override TimeSpan RunTestEvery => TimeSpan.FromMinutes(15);
|
// public override TimeSpan RunTestEvery => TimeSpan.FromMinutes(10);
|
||||||
public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure;
|
// public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure;
|
||||||
public override int EthereumAccountIndex => 200;
|
// public override int EthereumAccountIndex => 200;
|
||||||
public override string CustomK8sNamespace => "codex-continuous-marketplace";
|
// public override string CustomK8sNamespace => "codex-continuous-marketplace";
|
||||||
|
|
||||||
private readonly uint numberOfSlots = 3;
|
// private readonly uint numberOfSlots = 3;
|
||||||
private readonly ByteSize fileSize = 10.MB();
|
// private readonly ByteSize fileSize = 10.MB();
|
||||||
private readonly TestToken pricePerSlotPerSecond = 10.TestTokens();
|
// private readonly TestToken pricePerSlotPerSecond = 10.TestTokens();
|
||||||
|
|
||||||
private TestFile file = null!;
|
// private TestFile file = null!;
|
||||||
private ContentId? cid;
|
// private ContentId? cid;
|
||||||
private string purchaseId = string.Empty;
|
// private string purchaseId = string.Empty;
|
||||||
|
|
||||||
[TestMoment(t: Zero)]
|
// [TestMoment(t: Zero)]
|
||||||
public void NodePostsStorageRequest()
|
// public void NodePostsStorageRequest()
|
||||||
{
|
// {
|
||||||
var contractDuration = TimeSpan.FromMinutes(11); //TimeSpan.FromDays(3) + TimeSpan.FromHours(1);
|
// var contractDuration = TimeSpan.FromMinutes(8);
|
||||||
decimal totalDurationSeconds = Convert.ToDecimal(contractDuration.TotalSeconds);
|
// decimal totalDurationSeconds = Convert.ToDecimal(contractDuration.TotalSeconds);
|
||||||
var expectedTotalCost = numberOfSlots * pricePerSlotPerSecond.Amount * (totalDurationSeconds + 1) * 1000000;
|
// var expectedTotalCost = numberOfSlots * pricePerSlotPerSecond.Amount * (totalDurationSeconds + 1) * 1000000;
|
||||||
|
|
||||||
file = FileManager.GenerateTestFile(fileSize);
|
// file = FileManager.GenerateTestFile(fileSize);
|
||||||
|
|
||||||
NodeRunner.RunNode((codexAccess, marketplaceAccess) =>
|
// NodeRunner.RunNode((codexAccess, marketplaceAccess) =>
|
||||||
{
|
// {
|
||||||
cid = UploadFile(codexAccess.Node, file);
|
// cid = UploadFile(codexAccess.Node, file);
|
||||||
Assert.That(cid, Is.Not.Null);
|
// Assert.That(cid, Is.Not.Null);
|
||||||
|
|
||||||
purchaseId = marketplaceAccess.RequestStorage(
|
// purchaseId = marketplaceAccess.RequestStorage(
|
||||||
contentId: cid!,
|
// contentId: cid!,
|
||||||
pricePerSlotPerSecond: pricePerSlotPerSecond,
|
// pricePerSlotPerSecond: pricePerSlotPerSecond,
|
||||||
requiredCollateral: 100.TestTokens(),
|
// requiredCollateral: 100.TestTokens(),
|
||||||
minRequiredNumberOfNodes: numberOfSlots,
|
// minRequiredNumberOfNodes: numberOfSlots,
|
||||||
proofProbability: 10,
|
// proofProbability: 10,
|
||||||
duration: contractDuration);
|
// duration: contractDuration);
|
||||||
|
|
||||||
Assert.That(!string.IsNullOrEmpty(purchaseId));
|
// Assert.That(!string.IsNullOrEmpty(purchaseId));
|
||||||
|
|
||||||
WaitForContractToStart(codexAccess, purchaseId);
|
// WaitForContractToStart(codexAccess, purchaseId);
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
[TestMoment(t: MinuteFive * 2)]
|
// [TestMoment(t: MinuteFive + MinuteOne)]
|
||||||
public void StoredDataIsAvailableAfterThreeDays()
|
// public void StoredDataIsAvailableAfterThreeDays()
|
||||||
{
|
// {
|
||||||
NodeRunner.RunNode((codexAccess, marketplaceAccess) =>
|
// NodeRunner.RunNode((codexAccess, marketplaceAccess) =>
|
||||||
{
|
// {
|
||||||
var result = DownloadFile(codexAccess.Node, cid!);
|
// var result = DownloadFile(codexAccess.Node, cid!);
|
||||||
|
|
||||||
file.AssertIsEqual(result);
|
// file.AssertIsEqual(result);
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
private void WaitForContractToStart(CodexAccess codexAccess, string purchaseId)
|
// private void WaitForContractToStart(CodexAccess codexAccess, string purchaseId)
|
||||||
{
|
// {
|
||||||
var lastState = "";
|
// var lastState = "";
|
||||||
var waitStart = DateTime.UtcNow;
|
// var waitStart = DateTime.UtcNow;
|
||||||
var filesizeInMb = fileSize.SizeInBytes / (1024 * 1024);
|
// var filesizeInMb = fileSize.SizeInBytes / (1024 * 1024);
|
||||||
var maxWaitTime = TimeSpan.FromSeconds(filesizeInMb * 10.0);
|
// var maxWaitTime = TimeSpan.FromSeconds(filesizeInMb * 10.0);
|
||||||
|
|
||||||
Log.Log($"{nameof(WaitForContractToStart)} for {Time.FormatDuration(maxWaitTime)}");
|
// Log.Log($"{nameof(WaitForContractToStart)} for {Time.FormatDuration(maxWaitTime)}");
|
||||||
while (lastState != "started")
|
// while (lastState != "started")
|
||||||
{
|
// {
|
||||||
CancelToken.ThrowIfCancellationRequested();
|
// CancelToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var purchaseStatus = codexAccess.Node.GetPurchaseStatus(purchaseId);
|
// var purchaseStatus = codexAccess.Node.GetPurchaseStatus(purchaseId);
|
||||||
var statusJson = JsonConvert.SerializeObject(purchaseStatus);
|
// var statusJson = JsonConvert.SerializeObject(purchaseStatus);
|
||||||
if (purchaseStatus != null && purchaseStatus.state != lastState)
|
// if (purchaseStatus != null && purchaseStatus.state != lastState)
|
||||||
{
|
// {
|
||||||
lastState = purchaseStatus.state;
|
// lastState = purchaseStatus.state;
|
||||||
Log.Log("Purchase status: " + statusJson);
|
// Log.Log("Purchase status: " + statusJson);
|
||||||
}
|
// }
|
||||||
|
|
||||||
Thread.Sleep(2000);
|
// Thread.Sleep(2000);
|
||||||
|
|
||||||
if (lastState == "errored")
|
// if (lastState == "errored")
|
||||||
{
|
// {
|
||||||
Assert.Fail("Contract start failed: " + statusJson);
|
// Assert.Fail("Contract start failed: " + statusJson);
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (DateTime.UtcNow - waitStart > maxWaitTime)
|
// if (DateTime.UtcNow - waitStart > maxWaitTime)
|
||||||
{
|
// {
|
||||||
Assert.Fail($"Contract was not picked up within {maxWaitTime.TotalSeconds} seconds timeout: {statusJson}");
|
// Assert.Fail($"Contract was not picked up within {maxWaitTime.TotalSeconds} seconds timeout: {statusJson}");
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
Log.Log("Contract started.");
|
// Log.Log("Contract started.");
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
|
@ -1,86 +1,86 @@
|
||||||
using DistTestCore;
|
//using DistTestCore;
|
||||||
using DistTestCore.Codex;
|
//using DistTestCore.Codex;
|
||||||
using NUnit.Framework;
|
//using NUnit.Framework;
|
||||||
|
|
||||||
namespace ContinuousTests.Tests
|
//namespace ContinuousTests.Tests
|
||||||
{
|
//{
|
||||||
public class UploadPerformanceTest : PerformanceTest
|
// public class UploadPerformanceTest : PerformanceTest
|
||||||
{
|
// {
|
||||||
public override int RequiredNumberOfNodes => 1;
|
// public override int RequiredNumberOfNodes => 1;
|
||||||
|
|
||||||
[TestMoment(t: Zero)]
|
// [TestMoment(t: Zero)]
|
||||||
public void UploadTest()
|
// public void UploadTest()
|
||||||
{
|
// {
|
||||||
UploadTest(100, Nodes[0]);
|
// UploadTest(100, Nodes[0]);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
public class DownloadLocalPerformanceTest : PerformanceTest
|
// public class DownloadLocalPerformanceTest : PerformanceTest
|
||||||
{
|
// {
|
||||||
public override int RequiredNumberOfNodes => 1;
|
// public override int RequiredNumberOfNodes => 1;
|
||||||
|
|
||||||
[TestMoment(t: Zero)]
|
// [TestMoment(t: Zero)]
|
||||||
public void DownloadTest()
|
// public void DownloadTest()
|
||||||
{
|
// {
|
||||||
DownloadTest(100, Nodes[0], Nodes[0]);
|
// DownloadTest(100, Nodes[0], Nodes[0]);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
public class DownloadRemotePerformanceTest : PerformanceTest
|
// public class DownloadRemotePerformanceTest : PerformanceTest
|
||||||
{
|
// {
|
||||||
public override int RequiredNumberOfNodes => 2;
|
// public override int RequiredNumberOfNodes => 2;
|
||||||
|
|
||||||
[TestMoment(t: Zero)]
|
// [TestMoment(t: Zero)]
|
||||||
public void DownloadTest()
|
// public void DownloadTest()
|
||||||
{
|
// {
|
||||||
DownloadTest(100, Nodes[0], Nodes[1]);
|
// DownloadTest(100, Nodes[0], Nodes[1]);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
public abstract class PerformanceTest : ContinuousTest
|
// public abstract class PerformanceTest : ContinuousTest
|
||||||
{
|
// {
|
||||||
public override TimeSpan RunTestEvery => TimeSpan.FromHours(1);
|
// public override TimeSpan RunTestEvery => TimeSpan.FromMinutes(10);
|
||||||
public override TestFailMode TestFailMode => TestFailMode.AlwaysRunAllMoments;
|
// public override TestFailMode TestFailMode => TestFailMode.AlwaysRunAllMoments;
|
||||||
|
|
||||||
public void UploadTest(int megabytes, CodexAccess uploadNode)
|
// public void UploadTest(int megabytes, CodexAccess uploadNode)
|
||||||
{
|
// {
|
||||||
var file = FileManager.GenerateTestFile(megabytes.MB());
|
// var file = FileManager.GenerateTestFile(megabytes.MB());
|
||||||
|
|
||||||
var time = Measure(() =>
|
// var time = Measure(() =>
|
||||||
{
|
// {
|
||||||
UploadFile(uploadNode, file);
|
// UploadFile(uploadNode, file);
|
||||||
});
|
// });
|
||||||
|
|
||||||
var timePerMB = time / megabytes;
|
// var timePerMB = time / megabytes;
|
||||||
|
|
||||||
Assert.That(timePerMB, Is.LessThan(CodexContainerRecipe.MaxUploadTimePerMegabyte), "MaxUploadTimePerMegabyte performance threshold breached.");
|
// Assert.That(timePerMB, Is.LessThan(CodexContainerRecipe.MaxUploadTimePerMegabyte), "MaxUploadTimePerMegabyte performance threshold breached.");
|
||||||
}
|
// }
|
||||||
|
|
||||||
public void DownloadTest(int megabytes, CodexAccess uploadNode, CodexAccess downloadNode)
|
// public void DownloadTest(int megabytes, CodexAccess uploadNode, CodexAccess downloadNode)
|
||||||
{
|
// {
|
||||||
var file = FileManager.GenerateTestFile(megabytes.MB());
|
// var file = FileManager.GenerateTestFile(megabytes.MB());
|
||||||
|
|
||||||
var cid = UploadFile(uploadNode, file);
|
// var cid = UploadFile(uploadNode, file);
|
||||||
Assert.That(cid, Is.Not.Null);
|
// Assert.That(cid, Is.Not.Null);
|
||||||
|
|
||||||
TestFile? result = null;
|
// TestFile? result = null;
|
||||||
var time = Measure(() =>
|
// var time = Measure(() =>
|
||||||
{
|
// {
|
||||||
result = DownloadFile(downloadNode, cid!);
|
// result = DownloadFile(downloadNode, cid!);
|
||||||
});
|
// });
|
||||||
|
|
||||||
file.AssertIsEqual(result);
|
// file.AssertIsEqual(result);
|
||||||
|
|
||||||
var timePerMB = time / megabytes;
|
// var timePerMB = time / megabytes;
|
||||||
|
|
||||||
Assert.That(timePerMB, Is.LessThan(CodexContainerRecipe.MaxDownloadTimePerMegabyte), "MaxDownloadTimePerMegabyte performance threshold breached.");
|
// Assert.That(timePerMB, Is.LessThan(CodexContainerRecipe.MaxDownloadTimePerMegabyte), "MaxDownloadTimePerMegabyte performance threshold breached.");
|
||||||
}
|
// }
|
||||||
|
|
||||||
private static TimeSpan Measure(Action action)
|
// private static TimeSpan Measure(Action action)
|
||||||
{
|
// {
|
||||||
var start = DateTime.UtcNow;
|
// var start = DateTime.UtcNow;
|
||||||
action();
|
// action();
|
||||||
return DateTime.UtcNow - start;
|
// return DateTime.UtcNow - start;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
|
@ -1,47 +1,54 @@
|
||||||
using DistTestCore;
|
//using DistTestCore;
|
||||||
using DistTestCore.Codex;
|
//using DistTestCore.Codex;
|
||||||
using NUnit.Framework;
|
//using NUnit.Framework;
|
||||||
|
|
||||||
namespace ContinuousTests.Tests
|
//namespace ContinuousTests.Tests
|
||||||
{
|
//{
|
||||||
public class TransientNodeTest : ContinuousTest
|
// public class TransientNodeTest : ContinuousTest
|
||||||
{
|
// {
|
||||||
public override int RequiredNumberOfNodes => 3;
|
// public override int RequiredNumberOfNodes => 3;
|
||||||
public override TimeSpan RunTestEvery => TimeSpan.FromMinutes(10);
|
// public override TimeSpan RunTestEvery => TimeSpan.FromMinutes(1);
|
||||||
public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure;
|
// public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure;
|
||||||
public override string CustomK8sNamespace => nameof(TransientNodeTest).ToLowerInvariant();
|
// public override string CustomK8sNamespace => nameof(TransientNodeTest).ToLowerInvariant();
|
||||||
public override int EthereumAccountIndex => 201;
|
// public override int EthereumAccountIndex => 201;
|
||||||
|
|
||||||
private TestFile file = null!;
|
// private TestFile file = null!;
|
||||||
private ContentId cid = null!;
|
// private ContentId cid = null!;
|
||||||
|
|
||||||
private CodexAccess UploadBootstapNode { get { return Nodes[0]; } }
|
// private CodexAccess UploadBootstapNode { get { return Nodes[0]; } }
|
||||||
private CodexAccess DownloadBootstapNode { get { return Nodes[1]; } }
|
// private CodexAccess DownloadBootstapNode { get { return Nodes[1]; } }
|
||||||
private CodexAccess IntermediateNode { get { return Nodes[2]; } }
|
// private CodexAccess IntermediateNode { get { return Nodes[2]; } }
|
||||||
|
|
||||||
[TestMoment(t: 0)]
|
// [TestMoment(t: 0)]
|
||||||
public void UploadWithTransientNode()
|
// public void UploadWithTransientNode()
|
||||||
{
|
// {
|
||||||
file = FileManager.GenerateTestFile(10.MB());
|
// file = FileManager.GenerateTestFile(10.MB());
|
||||||
|
|
||||||
NodeRunner.RunNode(UploadBootstapNode, (codexAccess, marketplaceAccess) =>
|
// NodeRunner.RunNode(UploadBootstapNode, (codexAccess, marketplaceAccess, lifecycle) =>
|
||||||
{
|
// {
|
||||||
cid = UploadFile(codexAccess, file)!;
|
// cid = UploadFile(codexAccess, file)!;
|
||||||
Assert.That(cid, Is.Not.Null);
|
// Assert.That(cid, Is.Not.Null);
|
||||||
|
|
||||||
var resultFile = DownloadFile(IntermediateNode, cid);
|
// var dlt = Task.Run(() =>
|
||||||
file.AssertIsEqual(resultFile);
|
// {
|
||||||
});
|
// Thread.Sleep(10000);
|
||||||
}
|
// lifecycle.DownloadLog(codexAccess.Container);
|
||||||
|
// });
|
||||||
|
|
||||||
[TestMoment(t: MinuteFive)]
|
// var resultFile = DownloadFile(IntermediateNode, cid);
|
||||||
public void DownloadWithTransientNode()
|
// dlt.Wait();
|
||||||
{
|
// file.AssertIsEqual(resultFile);
|
||||||
NodeRunner.RunNode(DownloadBootstapNode, (codexAccess, marketplaceAccess) =>
|
// });
|
||||||
{
|
// }
|
||||||
var resultFile = DownloadFile(codexAccess, cid);
|
|
||||||
file.AssertIsEqual(resultFile);
|
// [TestMoment(t: 30)]
|
||||||
});
|
// public void DownloadWithTransientNode()
|
||||||
}
|
// {
|
||||||
}
|
// NodeRunner.RunNode(DownloadBootstapNode, (codexAccess, marketplaceAccess, lifecycle) =>
|
||||||
}
|
// {
|
||||||
|
// var resultFile = DownloadFile(codexAccess, cid);
|
||||||
|
// file.AssertIsEqual(resultFile);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
|
@ -6,7 +6,7 @@ namespace ContinuousTests.Tests
|
||||||
public class TwoClientTest : ContinuousTest
|
public class TwoClientTest : ContinuousTest
|
||||||
{
|
{
|
||||||
public override int RequiredNumberOfNodes => 2;
|
public override int RequiredNumberOfNodes => 2;
|
||||||
public override TimeSpan RunTestEvery => TimeSpan.FromHours(1);
|
public override TimeSpan RunTestEvery => TimeSpan.FromSeconds(30);
|
||||||
public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure;
|
public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure;
|
||||||
|
|
||||||
private ContentId? cid;
|
private ContentId? cid;
|
||||||
|
@ -21,7 +21,7 @@ namespace ContinuousTests.Tests
|
||||||
Assert.That(cid, Is.Not.Null);
|
Assert.That(cid, Is.Not.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMoment(t: MinuteFive)]
|
[TestMoment(t: 10)]
|
||||||
public void DownloadTestFile()
|
public void DownloadTestFile()
|
||||||
{
|
{
|
||||||
var dl = DownloadFile(Nodes[1], cid!);
|
var dl = DownloadFile(Nodes[1], cid!);
|
||||||
|
|
|
@ -48,6 +48,11 @@ namespace DistTestCore.Codex
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int GetDebugFutures()
|
||||||
|
{
|
||||||
|
return Http().HttpGetJson<CodexDebugFutures>("debug/futures").futures;
|
||||||
|
}
|
||||||
|
|
||||||
public string UploadFile(FileStream fileStream)
|
public string UploadFile(FileStream fileStream)
|
||||||
{
|
{
|
||||||
return Http().HttpPostStream("upload", fileStream);
|
return Http().HttpPostStream("upload", fileStream);
|
||||||
|
|
|
@ -16,6 +16,11 @@ namespace DistTestCore.Codex
|
||||||
public CodexDebugTableResponse table { get; set; } = new();
|
public CodexDebugTableResponse table { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class CodexDebugFutures
|
||||||
|
{
|
||||||
|
public int futures { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class CodexDebugTableResponse
|
public class CodexDebugTableResponse
|
||||||
{
|
{
|
||||||
public CodexDebugTableNodeResponse localNode { get; set; } = new();
|
public CodexDebugTableNodeResponse localNode { get; set; } = new();
|
||||||
|
|
Loading…
Reference in New Issue