diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc24af2..07fd69d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## 1.9.0 [unreleased] +### Features + +1. [#269](https://github.com/InfluxCommunity/influxdb3-csharp/pull/269): Add partial writes support and default writes to the V2 API endpoint. + - `NoSync` requires `UseV2Api=false` and the V3 API endpoint. + - `AcceptPartial` applies only when writes are sent to the V3 API endpoint and is ignored when using the V2 API endpoint. + See [Partial writes](https://docs.influxdata.com/influxdb3/core/write-data/http-api/v3-write-lp/#partial-writes) for more. + ## 1.8.0 [2026-04-23] ### Features diff --git a/Client.Test.Integration/QueryWriteTest.cs b/Client.Test.Integration/QueryWriteTest.cs index dca72dfb..925e9f20 100644 --- a/Client.Test.Integration/QueryWriteTest.cs +++ b/Client.Test.Integration/QueryWriteTest.cs @@ -53,6 +53,46 @@ public async Task QueryWrite() Assert.That(points.First().GetTag("type"), Is.EqualTo("used")); } + [TestCase(true, TestName = "QueryWriteWithUseV2Api_True")] + [TestCase(false, TestName = "QueryWriteWithUseV2Api_False")] + public async Task QueryWriteWithUseV2Api(bool useV2Api) + { + using var client = new InfluxDBClient(new ClientConfig + { + Host = Host, + Token = Token, + Database = Database, + WriteOptions = new WriteOptions + { + UseV2Api = useV2Api + } + }); + + const string measurement = "integration_test_write_api"; + var testId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + await client.WriteRecordAsync($"{measurement},type=used value=321.0,testId={testId}"); + + var sql = $"SELECT value FROM {measurement} where \"testId\" = {testId}"; + List results = new(); + for (var i = 0; i < 50; i++) + { + results = await client.Query(sql).ToListAsync(); + if (results.Count > 0) + { + break; + } + + await Task.Delay(100); + } + + Assert.Multiple((Action)(() => + { + Assert.That(results, Has.Count.EqualTo(1), $"No rows returned for UseV2Api={useV2Api}"); + Assert.That(results[0], Has.Length.EqualTo(1)); + Assert.That(results[0][0], Is.EqualTo(321.0)); + })); + } + [Test] public void QueryNotAuthorized() { @@ -328,4 +368,4 @@ private static void TestQueryPoints(InfluxDBClient client, TimeSpan? timeout = n })); Assert.That(ex.StatusCode, Is.EqualTo(StatusCode.DeadlineExceeded)); } -} \ No newline at end of file +} diff --git a/Client.Test.Integration/WriteTest.cs b/Client.Test.Integration/WriteTest.cs index dc59a67d..077f6642 100644 --- a/Client.Test.Integration/WriteTest.cs +++ b/Client.Test.Integration/WriteTest.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using InfluxDB3.Client.Config; using NUnit.Framework; +using WriteOptions = InfluxDB3.Client.Config.WriteOptions; namespace InfluxDB3.Client.Test.Integration; @@ -25,9 +26,8 @@ public async Task WriteWithError() } catch (Exception ex) { - if (ex is InfluxDBApiException) + if (ex is InfluxDBApiException iaex) { - var iaex = (InfluxDBApiException)ex; Assert.Multiple((Action)(() => { Assert.That(iaex.Message, @@ -45,4 +45,48 @@ public async Task WriteWithError() } } } -} \ No newline at end of file + + [TestCase(false, true, true, TestName = "WritePartialBatch_WithV3Api_ReturnsStructuredPartialWriteError")] + [TestCase(true, false, true, TestName = "WritePartialBatch_WithV2Api_ReturnsGenericApiError")] + [TestCase(false, true, false, TestName = "WritePartialBatch_WithV3Api_AcceptPartialFalse_ReturnsStructuredPartialWriteError")] + [TestCase(true, false, false, TestName = "WritePartialBatch_WithV2Api_AcceptPartialFalse_ReturnsGenericApiError")] + public void WritePartialBatchBehaviorByWriteApi(bool useV2Api, bool expectStructuredPartialError, bool acceptPartial) + { + using var client = new InfluxDBClient(new ClientConfig + { + Host = Host, + Token = Token, + Database = Database, + WriteOptions = new WriteOptions + { + UseV2Api = useV2Api, + AcceptPartial = acceptPartial + } + }); + + const string validLine = "home,room=Sunroom temp=96 1735545600"; + const string invalidLine = "home,room=Sunroom temp=\"hi\" 1735549200"; + + var ae = Assert.CatchAsync((Func)(async () => + { + await client.WriteRecordsAsync(new[] { validLine, invalidLine }); + })); + + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + + if (expectStructuredPartialError) + { + Assert.That(ae, Is.InstanceOf()); + var pwe = (InfluxDBPartialWriteException)ae!; + Assert.That(pwe.LineErrors, Is.Not.Empty); + Assert.That(ae.Message, + Does.Contain("partial write of line protocol occurred") + .Or.Contain("parsing failed for write_lp endpoint")); + } + else + { + Assert.That(ae, Is.Not.InstanceOf()); + } + } +} diff --git a/Client.Test/Config/ClientConfigTest.cs b/Client.Test/Config/ClientConfigTest.cs index ed3baedc..1a389d10 100644 --- a/Client.Test/Config/ClientConfigTest.cs +++ b/Client.Test/Config/ClientConfigTest.cs @@ -69,7 +69,7 @@ public void CreateFromConnectionStringWithAuthScheme() [Test] public void CreateFromConnectionStringWithWriteOptions() { - var cfg = new ClientConfig("http://localhost:8086?token=my-token&org=my-org&database=my-database&precision=s&gzipThreshold=64&writeNoSync=true"); + var cfg = new ClientConfig("http://localhost:8086?token=my-token&org=my-org&database=my-database&precision=s&gzipThreshold=64&writeNoSync=true&writeAcceptPartial=false&writeUseV2Api=true"); Assert.That(cfg, Is.Not.Null); cfg.Validate(); Assert.Multiple(() => @@ -82,6 +82,8 @@ public void CreateFromConnectionStringWithWriteOptions() Assert.That(cfg.WriteOptions.Precision, Is.EqualTo(WritePrecision.S)); Assert.That(cfg.WriteOptions.GzipThreshold, Is.EqualTo(64)); Assert.That(cfg.WriteOptions.NoSync, Is.EqualTo(true)); + Assert.That(cfg.WriteOptions.AcceptPartial, Is.EqualTo(false)); + Assert.That(cfg.WriteOptions.UseV2Api, Is.EqualTo(true)); }); } @@ -221,6 +223,8 @@ public void CreateFromEnvWithWriteOptions() {"INFLUX_PRECISION", "s"}, {"INFLUX_GZIP_THRESHOLD", "64"}, {"INFLUX_WRITE_NO_SYNC", "true"}, + {"INFLUX_WRITE_ACCEPT_PARTIAL", "false"}, + {"INFLUX_WRITE_USE_V2_API", "true"}, }; TestUtils.SetEnv(env); var cfg = new ClientConfig(env); @@ -236,6 +240,8 @@ public void CreateFromEnvWithWriteOptions() Assert.That(cfg.WriteOptions.Precision, Is.EqualTo(WritePrecision.S)); Assert.That(cfg.WriteOptions.GzipThreshold, Is.EqualTo(64)); Assert.That(cfg.WriteOptions.NoSync, Is.EqualTo(true)); + Assert.That(cfg.WriteOptions.AcceptPartial, Is.EqualTo(false)); + Assert.That(cfg.WriteOptions.UseV2Api, Is.EqualTo(true)); }); } diff --git a/Client.Test/InfluxDBApiExceptionTest.cs b/Client.Test/InfluxDBApiExceptionTest.cs index 3ee58982..b511fb0d 100644 --- a/Client.Test/InfluxDBApiExceptionTest.cs +++ b/Client.Test/InfluxDBApiExceptionTest.cs @@ -83,4 +83,4 @@ public async Task GeneratedInfluxDbException() } } } -} \ No newline at end of file +} diff --git a/Client.Test/InfluxDBClientHttpsTest.cs b/Client.Test/InfluxDBClientHttpsTest.cs index 33fe2525..4e9f61c9 100644 --- a/Client.Test/InfluxDBClientHttpsTest.cs +++ b/Client.Test/InfluxDBClientHttpsTest.cs @@ -262,4 +262,4 @@ private async Task QueryData() var query = "SELECT 1"; await _client.Query(query).ToListAsync(); } -} \ No newline at end of file +} diff --git a/Client.Test/InfluxDBClientWriteTest.cs b/Client.Test/InfluxDBClientWriteTest.cs index b643a417..7b1e77d6 100644 --- a/Client.Test/InfluxDBClientWriteTest.cs +++ b/Client.Test/InfluxDBClientWriteTest.cs @@ -452,31 +452,56 @@ private async Task WriteData() await _client.WriteRecordAsync("mem,tag=a field=1"); } - [Test] - public async Task WriteNoSyncFalse() + private static IEnumerable WriteV3OptionCases() + { + yield return new TestCaseData(new WriteOptions { UseV2Api = false, NoSync = false }, false, false) + .SetName("WriteToV3_NoSyncFalse_OmitsNoSyncAndAcceptPartial"); + yield return new TestCaseData(new WriteOptions { UseV2Api = false, AcceptPartial = false }, false, true) + .SetName("WriteToV3_AcceptPartialFalse_AddsAcceptPartialQueryParam"); + yield return new TestCaseData(new WriteOptions { UseV2Api = false, NoSync = true }, true, false) + .SetName("WriteToV3_NoSyncTrue_AddsNoSyncQueryParam"); + } + + [TestCaseSource(nameof(WriteV3OptionCases))] + public async Task WriteToV3OptionCases(WriteOptions writeOptions, bool expectNoSync, bool expectAcceptPartialFalse) { _client = new InfluxDBClient(new ClientConfig { Host = MockServerUrl, Token = "my-token", Database = "my-database", - WriteOptions = new WriteOptions - { - NoSync = false - } + WriteOptions = writeOptions }); MockServer - .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) .RespondWith(Response.Create().WithStatusCode(204)); await _client.WriteRecordAsync("mem,tag=a field=1"); - var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Path, Is.EqualTo("/api/v2/write")); + using (Assert.EnterMultipleScope()) + { + Assert.That(requests[0].RequestMessage.Path, Is.EqualTo("/api/v3/write_lp")); + if (expectNoSync) + { + Assert.That(requests[0].RequestMessage.Query?["no_sync"].First(), Is.EqualTo("true")); + } + else + { + Assert.That(requests[0].RequestMessage.Query, Does.Not.ContainKey("no_sync")); + } + if (expectAcceptPartialFalse) + { + Assert.That(requests[0].RequestMessage.Query?["accept_partial"].First(), Is.EqualTo("false")); + } + else + { + Assert.That(requests[0].RequestMessage.Query, Does.Not.ContainKey("accept_partial")); + } + } } [Test] - public async Task WriteNoSyncTrueSupported() + public void WriteNoSyncTrueWithUseV2ApiValidationError() { _client = new InfluxDBClient(new ClientConfig { @@ -485,21 +510,26 @@ public async Task WriteNoSyncTrueSupported() Database = "my-database", WriteOptions = new WriteOptions { - NoSync = true + NoSync = true, + UseV2Api = true } }); - MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) - .RespondWith(Response.Create().WithStatusCode(204)); - await _client.WriteRecordAsync("mem,tag=a field=1"); + var ae = Assert.ThrowsAsync(async () => + { + await _client.WriteRecordAsync("mem,tag=a field=1"); + }); - var requests = MockServer.LogEntries.ToList(); - Assert.That(requests[0].RequestMessage.Query?["no_sync"].First(), Is.EqualTo("true")); + Assert.Multiple(() => + { + Assert.That(ae, Is.Not.Null); + Assert.That(ae.Message, Is.EqualTo("Invalid write options: NoSync requires UseV2Api=false")); + }); } - [Test] - public void WriteNoSyncTrueNotSupported() + [TestCase(true)] + [TestCase(false)] + public async Task WriteUseV2ApiRoutesToV2AndIgnoresAcceptPartial(bool acceptPartial) { _client = new InfluxDBClient(new ClientConfig { @@ -508,27 +538,61 @@ public void WriteNoSyncTrueNotSupported() Database = "my-database", WriteOptions = new WriteOptions { - NoSync = true + UseV2Api = true, + AcceptPartial = acceptPartial } }); MockServer - .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) - .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.MethodNotAllowed)); + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(204)); + await _client.WriteRecordAsync("mem,tag=a field=1"); - var ae = Assert.ThrowsAsync(async () => + var requests = MockServer.LogEntries.ToList(); + using (Assert.EnterMultipleScope()) { - await _client.WriteRecordAsync("mem,tag=a field=1"); - }); + Assert.That(requests[0].RequestMessage.Path, Is.EqualTo("/api/v2/write")); + Assert.That(requests[0].RequestMessage.Query?["bucket"].First(), Is.EqualTo("my-database")); + Assert.That(requests[0].RequestMessage.Query?["precision"].First(), Is.EqualTo("ns")); + Assert.That(requests[0].RequestMessage.Query, Does.Not.ContainKey("accept_partial")); + Assert.That(requests[0].RequestMessage.Query, Does.Not.ContainKey("no_sync")); + } + } - Assert.Multiple(() => + [Test] + public async Task WriteDefaultV2ToV3OnlyBackendReturnsGuidance() + { + _client = new InfluxDBClient(MockServerUrl, token: "my-token", database: "my-database"); + MockServer + .Given(Request.Create().WithPath("/api/v2/write").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(405)); + + var ae = Assert.ThrowsAsync(async () => { await _client.WriteRecordAsync("mem,tag=a field=1"); }); + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.Message, Is.EqualTo( + "Server doesn't support the V2 API endpoint (/api/v2/write) " + + "(set UseV2Api=false; write options: {UseV2Api:true,NoSync:false,AcceptPartial:true})")); + } + + [Test] + public async Task WriteV3ToV2OnlyBackendReturnsGuidance() + { + _client = new InfluxDBClient(new ClientConfig { - Assert.That(ae, Is.Not.Null); - Assert.That(ae.HttpResponseMessage, Is.Not.Null); - Assert.That(ae.Message, - Does.Contain( - "Server doesn't support write with NoSync=true (supported by InfluxDB 3 Core/Enterprise servers only).")); + Host = MockServerUrl, + Token = "my-token", + Database = "my-database", + WriteOptions = new WriteOptions { UseV2Api = false } }); + MockServer + .Given(Request.Create().WithPath("/api/v3/write_lp").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(405)); + + var ae = Assert.ThrowsAsync(async () => { await _client.WriteRecordAsync("mem,tag=a field=1"); }); + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.Message, Is.EqualTo( + "Server doesn't support the V3 API endpoint (/api/v3/write_lp) " + + "(set UseV2Api=true; write options: {UseV2Api:false,NoSync:false,AcceptPartial:true})")); } [Test] @@ -619,6 +683,7 @@ public async Task WriteFromUrl() parameters.Add("org", org); parameters.Add("precision", precision); parameters.Add("writeNoSync", writeNoSync.ToString().ToLower()); + parameters.Add("writeUseV2Api", "false"); parameters.Add("gzipThreshold", gzipThreshold.ToString()); ; var uriBuilder = new UriBuilder(MockServerUrl); uriBuilder.Query = parameters.ToString()!; @@ -673,6 +738,7 @@ public async Task WriteFromEnvVars() { ClientConfig.EnvInfluxPrecision, precision }, { ClientConfig.EnvInfluxAuthScheme, authSchema }, { ClientConfig.EnvInfluxWriteNoSync, writeNoSync.ToString().ToLower() }, + { ClientConfig.EnvInfluxWriteUseV2Api, "false" }, { ClientConfig.EnvInfluxGzipThreshold, gzipThreshold.ToString() } }; diff --git a/Client.Test/Internal/RestClientTest.cs b/Client.Test/Internal/RestClientTest.cs index e20db9aa..ecfdb289 100644 --- a/Client.Test/Internal/RestClientTest.cs +++ b/Client.Test/Internal/RestClientTest.cs @@ -238,7 +238,8 @@ public void ErrorJsonBodyV3WithDataObject() { Assert.That(ae, Is.Not.Null); Assert.That(ae.HttpResponseMessage, Is.Not.Null); - Assert.That(ae.Message, Is.EqualTo("invalid field value in line protocol for field 'value' on line 0")); + Assert.That(ae.Message, Is.EqualTo( + "parsing failed: invalid field value in line protocol for field 'value' on line 0")); }); } @@ -271,6 +272,55 @@ public void ErrorJsonBodyV3WithoutData() }); } + [Test] + public void ErrorJsonBodyV3WithDataErrorMessageCombinesMessages() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"parsing failed\",\"data\":{\"error_message\":\"invalid field value\"}}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.Message, Is.EqualTo("parsing failed: invalid field value")); + } + + [Test] + public void ErrorJsonBodyWithNonObjectRootFallsBackToHeaders() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithHeader("X-Influx-Error", "fallback header message") + .WithBody("[1,2,3]") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.That(ae, Is.Not.Null); + Assert.That(ae!.Message, Is.EqualTo("fallback header message")); + } + [Test] public void ErrorJsonBodyV3WithDataArray() { @@ -284,10 +334,10 @@ public void ErrorJsonBodyV3WithDataArray() .RespondWith(Response.Create() .WithHeader("Content-Type", "application/json") .WithHeader("X-Influx-Error", "not used") - .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":[{\"error_message\":\"invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float\",\"line_number\":2,\"original_line\":\"testa6a3ad v=1 17702\"}]}") + .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":[{\"error_message\":\"invalid column type for column 'temp', expected iox::column_type::field::float, got iox::column_type::field::string\",\"line_number\":2,\"original_line\":\"home,room=Sunroom temp=hi 1735549200\"}]}") .WithStatusCode(400)); - var ae = Assert.ThrowsAsync(async () => + var ae = Assert.ThrowsAsync(async () => { await _client.Request("api", HttpMethod.Post); }); @@ -295,11 +345,130 @@ public void ErrorJsonBodyV3WithDataArray() Assert.Multiple(() => { Assert.That(ae, Is.Not.Null); + Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); + Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(2)); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid column type for column 'temp', expected iox::column_type::field::float, got iox::column_type::field::string")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.EqualTo("home,room=Sunroom temp=hi 1735549200")); Assert.That(ae.HttpResponseMessage, Is.Not.Null); - Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tline 2: invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float (testa6a3ad v=1 17702)")); + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tline 2: invalid column type for column 'temp', expected iox::column_type::field::float, got iox::column_type::field::string (home,room=Sunroom temp=hi 1735549200)")); + }); + } + + [Test] + public void ErrorJsonBodyV3WithDataArrayUntypedFallback() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":[\"bad line\",true,3]}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\t\"bad line\"\n\ttrue\n\t3")); } + [Test] + public void ErrorJsonBodyV3ParsingFailedWriteLpWithDataObject() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"parsing failed for write_lp endpoint\",\"data\":{\"error_message\":\"invalid field value\",\"line_number\":2,\"original_line\":\"home,room=Sunroom temp=hi 1735549200\"}}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.Multiple(() => + { + Assert.That(ae, Is.Not.Null); + Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); + Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(2)); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid field value")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.EqualTo("home,room=Sunroom temp=hi 1735549200")); + Assert.That(ae.Message, Is.EqualTo("parsing failed for write_lp endpoint:\n\tline 2: invalid field value (home,room=Sunroom temp=hi 1735549200)")); + }); + } + + [Test] + public void ErrorJsonBodyV3PartialWriteWithDataObjectErrorMessageOnly() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":{\"error_message\":\"invalid field value\"}}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.Multiple(() => + { + Assert.That(ae, Is.Not.Null); + Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); + Assert.That(ae.LineErrors[0].LineNumber, Is.Null); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid field value")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.Null); + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tinvalid field value")); + }); + } + + [Test] + public void ErrorJsonBodyV3PartialWriteWithLineNumberWithoutOriginalLine() + { + CreateAndConfigureRestClient(new ClientConfig + { + Host = MockServerUrl, + }); + + MockServer + .Given(Request.Create().WithPath("/api").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBody("{\"error\":\"partial write of line protocol occurred\",\"data\":{\"error_message\":\"invalid field value\",\"line_number\":2}}") + .WithStatusCode(400)); + + var ae = Assert.ThrowsAsync(async () => + { + await _client.Request("api", HttpMethod.Post); + }); + + Assert.Multiple(() => + { + Assert.That(ae, Is.Not.Null); + Assert.That(ae.LineErrors, Has.Count.EqualTo(1)); + Assert.That(ae.LineErrors[0].LineNumber, Is.EqualTo(2)); + Assert.That(ae.LineErrors[0].ErrorMessage, Is.EqualTo("invalid field value")); + Assert.That(ae.LineErrors[0].OriginalLine, Is.Null); + Assert.That(ae.Message, Is.EqualTo("partial write of line protocol occurred:\n\tline 2: invalid field value")); + }); + } [Test] public void ErrorReason() diff --git a/Client/Client.csproj b/Client/Client.csproj index b8f552e4..b03a6b38 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -42,6 +42,7 @@ + diff --git a/Client/Config/ClientConfig.cs b/Client/Config/ClientConfig.cs index 7808de51..79ab8366 100644 --- a/Client/Config/ClientConfig.cs +++ b/Client/Config/ClientConfig.cs @@ -70,6 +70,8 @@ public class ClientConfig internal const string EnvInfluxPrecision = "INFLUX_PRECISION"; internal const string EnvInfluxGzipThreshold = "INFLUX_GZIP_THRESHOLD"; internal const string EnvInfluxWriteNoSync = "INFLUX_WRITE_NO_SYNC"; + internal const string EnvInfluxWriteAcceptPartial = "INFLUX_WRITE_ACCEPT_PARTIAL"; + internal const string EnvInfluxWriteUseV2Api = "INFLUX_WRITE_USE_V2_API"; internal const string EnvInfluxDisableGrpcCompression = "INFLUX_DISABLE_GRPC_COMPRESSION"; private string _host = ""; @@ -98,6 +100,8 @@ public ClientConfig(string connectionString) ParsePrecision(values.Get("precision")); ParseGzipThreshold(values.Get("gzipThreshold")); ParseWriteNoSync(values.Get("writeNoSync")); + ParseWriteAcceptPartial(values.Get("writeAcceptPartial")); + ParseWriteUseV2Api(values.Get("writeUseV2Api")); ParseDisableGrpcCompression(values.Get("disableGrpcCompression")); } @@ -115,6 +119,8 @@ public ClientConfig(IDictionary env) ParsePrecision(env[EnvInfluxPrecision] as string); ParseGzipThreshold(env[EnvInfluxGzipThreshold] as string); ParseWriteNoSync(env[EnvInfluxWriteNoSync] as string); + ParseWriteAcceptPartial(env[EnvInfluxWriteAcceptPartial] as string); + ParseWriteUseV2Api(env[EnvInfluxWriteUseV2Api] as string); ParseDisableGrpcCompression(env[EnvInfluxDisableGrpcCompression] as string); } @@ -284,6 +290,26 @@ private void ParseWriteNoSync(string? strVal) } } + private void ParseWriteAcceptPartial(string? strVal) + { + if (strVal != null) + { + var acceptPartial = bool.Parse(strVal); + WriteOptions ??= (WriteOptions)WriteOptions.DefaultOptions.Clone(); + WriteOptions.AcceptPartial = acceptPartial; + } + } + + private void ParseWriteUseV2Api(string? strVal) + { + if (strVal != null) + { + var useV2Api = bool.Parse(strVal); + WriteOptions ??= (WriteOptions)WriteOptions.DefaultOptions.Clone(); + WriteOptions.UseV2Api = useV2Api; + } + } + private void ParseDisableGrpcCompression(string? strVal) { if (strVal != null) @@ -292,4 +318,4 @@ private void ParseDisableGrpcCompression(string? strVal) QueryOptions.DisableGrpcCompression = disable; } } -} \ No newline at end of file +} diff --git a/Client/Config/WriteOptions.cs b/Client/Config/WriteOptions.cs index ed706d57..81ab386c 100644 --- a/Client/Config/WriteOptions.cs +++ b/Client/Config/WriteOptions.cs @@ -12,6 +12,8 @@ namespace InfluxDB3.Client.Config; /// - GzipThreshold: The threshold in bytes for gzipping the body. The default value is 1000. /// - TagOrder: Preferred tag order for line protocol serialization. /// - NoSync: Bool value whether to skip waiting for WAL persistence on write. The default value is false. +/// - AcceptPartial: Allow partial writes on the V3 API endpoint. The default value is true. +/// - UseV2Api: Route writes to the V2 API endpoint (/api/v2/write). The default value is true. /// /// If you want create client with custom options, you can use the following code: /// @@ -74,29 +76,53 @@ public class WriteOptions : ICloneable /// /// The threshold in bytes for gzipping the body. /// - public int GzipThreshold { get; set; } + public int GzipThreshold { get; set; } = 1000; /// /// Instructs the server whether to wait with the response until WAL persistence completes. /// NoSync=true means faster write but without the confirmation that the data was persisted. /// /// Note: This option is supported by InfluxDB 3 Core and Enterprise servers only. - /// For other InfluxDB 3 server types (InfluxDB Clustered, InfluxDB Clould Serverless/Dedicated) + /// For other InfluxDB 3 server types (InfluxDB Clustered, InfluxDB Cloud Dedicated/Serverless) /// the write operation will fail with an error. /// /// Default value: false. /// public bool NoSync { get; set; } + /// + /// Controls partial-write behavior on the V3 API endpoint. + /// true (default): allow partial writes (server default behavior) + /// false: reject the full batch when any line fails. + /// This option is ignored for writes sent to the V2 API endpoint (UseV2Api=true). + /// + public bool AcceptPartial { get; set; } = true; + + /// + /// Routes writes to the V2 API endpoint (/api/v2/write). + /// Default value: true. + /// + public bool UseV2Api { get; set; } = true; + public object Clone() { return this.MemberwiseClone(); } + internal void Validate() + { + if (UseV2Api && NoSync) + { + throw new InvalidOperationException("Invalid write options: NoSync requires UseV2Api=false"); + } + } + internal static readonly WriteOptions DefaultOptions = new() { Precision = WritePrecision.Ns, GzipThreshold = 1000, NoSync = false, + AcceptPartial = true, + UseV2Api = true, }; } diff --git a/Client/InfluxDBClient.cs b/Client/InfluxDBClient.cs index 2f5b5773..555ef16d 100644 --- a/Client/InfluxDBClient.cs +++ b/Client/InfluxDBClient.cs @@ -352,7 +352,13 @@ public InfluxDBClient(ClientConfig config) /// precision - timestamp precision when writing data (ns (default), us, ms, s) /// /// - /// gzipThreshold - threshold for gzip data when writing (default is 1000) + /// gzipThreshold - threshold for gzip data when writing (default is 1000). + /// + /// + /// writeAcceptPartial - allow partial writes on V3 API endpoint (default is true) + /// + /// + /// writeUseV2Api - route writes to V2 API endpoint (default is true) /// /// /// writeNoSync - bool value whether to skip waiting for WAL persistence on write (default is false) @@ -395,6 +401,12 @@ public InfluxDBClient(string connectionString) : this(new ClientConfig(connectio /// INFLUX_GZIP_THRESHOLD - threshold for gzipping data when writing (default is 1000) /// /// + /// INFLUX_WRITE_ACCEPT_PARTIAL - allow partial writes on V3 API endpoint (default is true) + /// + /// + /// INFLUX_WRITE_USE_V2_API - route writes to V2 API endpoint (default is true) + /// + /// /// INFLUX_WRITE_NO_SYNC - bool value whether to skip waiting for WAL persistence on write (default is false) /// /// @@ -765,32 +777,41 @@ private async Task WriteData(IEnumerable data, string? database = null, var body = sb.ToString(); var content = _gzipHandler.Process(body) ?? new StringContent(body, Encoding.UTF8, "text/plain"); + var writeOptions = _config.WriteOptions ?? WriteOptions.DefaultOptions; + writeOptions.Validate(); + string path; Dictionary queryParams; var databaseNotNull = (database ?? _config.Database) ?? throw new InvalidOperationException(OptionMessage("database")); - if (_config.WriteNoSync) + if (writeOptions.UseV2Api) { - // Setting no_sync=true is supported only in the v3 API. - path = "api/v3/write_lp"; + path = "api/v2/write"; queryParams = new Dictionary() { { "org", _config.Organization }, - { "db", databaseNotNull }, - { "precision", WritePrecisionConverter.ToV3ApiString(precisionNotNull) }, - { "no_sync", "true" } + { "bucket", databaseNotNull }, + { "precision", WritePrecisionConverter.ToV2ApiString(precisionNotNull) }, }; } else { - // By default, use the v2 API. - path = "api/v2/write"; + path = "api/v3/write_lp"; queryParams = new Dictionary() { { "org", _config.Organization }, - { "bucket", databaseNotNull }, - { "precision", WritePrecisionConverter.ToV2ApiString(precisionNotNull) }, + { "db", databaseNotNull }, + { "precision", WritePrecisionConverter.ToV3ApiString(precisionNotNull) }, }; + + if (writeOptions.NoSync) + { + queryParams["no_sync"] = "true"; + } + if (!writeOptions.AcceptPartial) + { + queryParams["accept_partial"] = "false"; + } } var cancelToken = new CancellationTokenSource(_config.Timeout).Token; // Just for compatibility with the old API @@ -809,13 +830,24 @@ await _restClient .Request(path, HttpMethod.Post, content, queryParams, headers, cancelToken) .ConfigureAwait(false); } - catch (InfluxDBApiException e) + catch (InfluxDBApiException ex) when (ex.StatusCode == HttpStatusCode.MethodNotAllowed) { - if (_config.WriteNoSync && e.StatusCode == HttpStatusCode.MethodNotAllowed) + if (writeOptions.UseV2Api && path == "api/v2/write") { - // Server does not support the v3 write API, can't use the NoSync option. - throw new InfluxDBApiException("Server doesn't support write with NoSync=true (supported by InfluxDB 3 Core/Enterprise servers only).", e.HttpResponseMessage!); + throw new InfluxDBApiException( + $"Server doesn't support the V2 API endpoint (/api/v2/write) " + + $"(set UseV2Api=false; write options: {{UseV2Api:true,NoSync:{writeOptions.NoSync.ToString().ToLowerInvariant()},AcceptPartial:{writeOptions.AcceptPartial.ToString().ToLowerInvariant()}}})", + ex.HttpResponseMessage!); } + + if (!writeOptions.UseV2Api && path == "api/v3/write_lp") + { + throw new InfluxDBApiException( + $"Server doesn't support the V3 API endpoint (/api/v3/write_lp) " + + $"(set UseV2Api=true; write options: {{UseV2Api:false,NoSync:{writeOptions.NoSync.ToString().ToLowerInvariant()},AcceptPartial:{writeOptions.AcceptPartial.ToString().ToLowerInvariant()}}})", + ex.HttpResponseMessage!); + } + throw; } } diff --git a/Client/InfluxDBPartialWriteException.cs b/Client/InfluxDBPartialWriteException.cs new file mode 100644 index 00000000..e4028e29 --- /dev/null +++ b/Client/InfluxDBPartialWriteException.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Net.Http; + +namespace InfluxDB3.Client; + +/// +/// API exception with structured per-line details for v3 partial-write errors. +/// +public class InfluxDBPartialWriteException : InfluxDBApiException +{ + public sealed class LineError + { + public LineError(int? lineNumber, string errorMessage, string? originalLine) + { + LineNumber = lineNumber; + ErrorMessage = errorMessage; + OriginalLine = originalLine; + } + + public int? LineNumber { get; } + + public string ErrorMessage { get; } + + public string? OriginalLine { get; } + } + + internal InfluxDBPartialWriteException( + string message, + HttpResponseMessage httpResponseMessage, + IReadOnlyList lineErrors) : base(message, httpResponseMessage) + { + LineErrors = lineErrors; + } + + /// + /// Structured line-level errors returned by the v3 write endpoint. + /// + public IReadOnlyList LineErrors { get; } +} diff --git a/Client/Internal/RestClient.cs b/Client/Internal/RestClient.cs index 32046aa2..3ed6217c 100644 --- a/Client/Internal/RestClient.cs +++ b/Client/Internal/RestClient.cs @@ -1,12 +1,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; using System.Net.Http; using System.Runtime.Serialization; -using System.Runtime.Serialization.Json; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Web; @@ -82,10 +81,11 @@ internal async Task Request(string path, HttpMethod method, var result = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!result.IsSuccessStatusCode) { - string? message = null; var body = await result.Content.ReadAsStringAsync().ConfigureAwait(false); var contentType = result.Content?.Headers?.ContentType?.ToString(); - message = FormatErrorMessage(body, contentType); + var parsed = ParseErrorMessage(body, contentType); + var message = parsed.Message; + var partialLineErrors = parsed.PartialLineErrors; // from header if (string.IsNullOrEmpty(message)) @@ -108,138 +108,271 @@ internal async Task Request(string path, HttpMethod method, message = result.ReasonPhrase; } + if (partialLineErrors?.Count > 0) + { + throw new InfluxDBPartialWriteException(message ?? "Cannot write data to InfluxDB.", result, partialLineErrors); + } + throw new InfluxDBApiException(message ?? "Cannot write data to InfluxDB.", result); } return result; } - private static string? FormatErrorMessage(string body, string? contentType) + private static ParsedErrorMessage ParseErrorMessage(string body, string? contentType) { - if (string.IsNullOrEmpty(body)) + if (string.IsNullOrWhiteSpace(body)) { - return null; + return ParsedErrorMessage.Empty; } + if (!string.IsNullOrEmpty(contentType) && !contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) { - return null; + return ParsedErrorMessage.Empty; } - string? message = null; try { - using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(body)); - if (new DataContractJsonSerializer(typeof(ErrorBody)).ReadObject(memoryStream) is ErrorBody errorBody) + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) { - if (!string.IsNullOrEmpty(errorBody.Message)) // Cloud - { - message = errorBody.Message; - } - else if ((errorBody.Data is not null) && !string.IsNullOrEmpty(errorBody.Data.ErrorMessage)) // v3/Core/Enterprise (legacy object form) - { - message = errorBody.Data.ErrorMessage; - } - else if (!string.IsNullOrEmpty(errorBody.Error)) // v3/Core/Enterprise - { - message = errorBody.Error; - } + return ParsedErrorMessage.Empty; } + + var cloudMessage = GetStringProperty(root, "message"); + var topLevelError = GetStringProperty(root, "error"); + var data = GetProperty(root, "data"); + + var legacyDataMessage = GetLegacyDataErrorMessage(data); + var message = cloudMessage ?? + CombineMessages(topLevelError, legacyDataMessage) ?? + topLevelError ?? + legacyDataMessage; + var partial = ParsePartialWrite(topLevelError, data); + if (partial is not null) + { + return partial; + } + + return new ParsedErrorMessage(message, null); } - catch (SerializationException se) + catch (JsonException se) { - Debug.WriteLine($"Cannot parse error response as legacy JSON format: {body}. {se}"); + Debug.WriteLine($"Cannot parse error response as JSON format: {body}. {se}"); + return ParsedErrorMessage.Empty; } + } - try + private static ParsedErrorMessage? ParsePartialWrite(string? topLevelError, JsonElement? data) + { + if (string.IsNullOrEmpty(topLevelError) || !IsPartialWriteError(topLevelError)) { - using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(body)); - if (new DataContractJsonSerializer(typeof(V3ErrorBody)).ReadObject(memoryStream) is V3ErrorBody v3ErrorBody) - { - var v3Message = BuildV3ErrorMessage(v3ErrorBody); - var hasV3Details = v3ErrorBody.Data?.Any(detail => !string.IsNullOrEmpty(detail.ErrorMessage)) == true; - if (!string.IsNullOrEmpty(v3Message) && (string.IsNullOrEmpty(message) || hasV3Details)) - { - message = v3Message; - } - } + return null; } - catch (SerializationException se) + if (data is null) { - Debug.WriteLine($"Cannot parse error response as v3 JSON format: {body}. {se}"); + return null; } - return message; - } + if (data.Value.ValueKind == JsonValueKind.Array) + { + if (TryParseTypedLineErrors(data.Value, out var lineErrors)) + { + return new ParsedErrorMessage(BuildPartialWriteMessage(topLevelError, lineErrors), lineErrors); + } - private static string? BuildV3ErrorMessage(V3ErrorBody v3ErrorBody) - { - if (string.IsNullOrEmpty(v3ErrorBody.Error)) + var details = data.Value.EnumerateArray() + .Where(item => item.ValueKind != JsonValueKind.Null) + .Select(ToDetailString) + .Where(item => !string.IsNullOrEmpty(item)) + .ToList(); + + if (details.Count > 0) + { + return new ParsedErrorMessage($"{topLevelError}:\n\t{string.Join("\n\t", details)}", null); + } + + return null; + } + + if (data.Value.ValueKind == JsonValueKind.Object) { + if (TryParseTypedLineError(data.Value, out var lineError)) + { + var lineErrors = new List { lineError }; + return new ParsedErrorMessage(BuildPartialWriteMessage(topLevelError, lineErrors), lineErrors); + } + return null; } - var message = new StringBuilder(v3ErrorBody.Error); + return null; + } + + private static string BuildPartialWriteMessage( + string baseMessage, + IEnumerable lineErrors) + { + var message = new StringBuilder(baseMessage); var hasDetails = false; - foreach (var detail in (v3ErrorBody.Data ?? Enumerable.Empty()) - .Where(detail => !string.IsNullOrEmpty(detail.ErrorMessage))) + foreach (var detail in lineErrors) { if (!hasDetails) { message.Append(':'); hasDetails = true; } - var lineNumber = detail.LineNumber?.ToString() ?? "?"; - message.Append($"\n\tline {lineNumber}: {detail.ErrorMessage}"); - if (!string.IsNullOrEmpty(detail.OriginalLine)) + if (detail.LineNumber.HasValue) + { + var lineNumber = detail.LineNumber.Value; + if (!string.IsNullOrEmpty(detail.OriginalLine)) + { + message.Append($"\n\tline {lineNumber}: {detail.ErrorMessage} ({detail.OriginalLine})"); + } + else + { + message.Append($"\n\tline {lineNumber}: {detail.ErrorMessage}"); + } + } + else if (!string.IsNullOrEmpty(detail.ErrorMessage)) { - message.Append($" ({detail.OriginalLine})"); + message.Append($"\n\t{detail.ErrorMessage}"); } } + return message.ToString(); } -} -[DataContract] -internal class ErrorBody -{ - [DataMember(Name = "message")] - public string? Message { get; set; } + private static bool IsPartialWriteError(string topLevelError) + { + return topLevelError.IndexOf("partial write of line protocol occurred", StringComparison.OrdinalIgnoreCase) >= 0 || + topLevelError.IndexOf("parsing failed for write_lp endpoint", StringComparison.OrdinalIgnoreCase) >= 0; + } - [DataMember(Name = "error")] - public string? Error { get; set; } + private static string? CombineMessages(string? topLevelError, string? dataMessage) + { + if (string.IsNullOrEmpty(topLevelError) || string.IsNullOrEmpty(dataMessage)) + { + return null; + } - [DataMember(Name = "data")] - public ErrorData? Data { get; set; } + if (string.Equals(topLevelError, dataMessage, StringComparison.Ordinal)) + { + return topLevelError; + } + + return $"{topLevelError}: {dataMessage}"; + } - [DataContract] - internal class ErrorData + private static bool TryParseTypedLineErrors( + JsonElement data, + out List lineErrors) { - [DataMember(Name = "error_message")] - public string? ErrorMessage { get; set; } + lineErrors = new List(); + if (data.ValueKind != JsonValueKind.Array) + { + return false; + } + + foreach (var item in data.EnumerateArray()) + { + if (!TryParseTypedLineError(item, out var lineError)) + { + lineErrors.Clear(); + return false; + } + + lineErrors.Add(lineError); + } + + return lineErrors.Count > 0; } -} -[DataContract] -internal class V3ErrorBody -{ - [DataMember(Name = "error")] - public string? Error { get; set; } + private static bool TryParseTypedLineError(JsonElement item, out InfluxDBPartialWriteException.LineError lineError) + { + lineError = null!; + if (item.ValueKind != JsonValueKind.Object) + { + return false; + } + + var errorMessage = GetProperty(item, "error_message"); + var lineNumber = GetProperty(item, "line_number"); + var originalLine = GetProperty(item, "original_line"); + if (errorMessage?.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(errorMessage.Value.GetString())) + { + return false; + } + + int? number = null; + if (lineNumber is not null) + { + if (lineNumber.Value.ValueKind != JsonValueKind.Number || !lineNumber.Value.TryGetInt32(out var parsedNumber)) + { + return false; + } + + number = parsedNumber; + } + + string? original = null; + if (originalLine is not null) + { + if (originalLine.Value.ValueKind != JsonValueKind.String) + { + return false; + } + + original = originalLine.Value.GetString(); + } + + lineError = new InfluxDBPartialWriteException.LineError( + number, + errorMessage.Value.GetString()!, + original); + return true; + } + + private static JsonElement? GetProperty(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) ? value : null; + } - [DataMember(Name = "data")] - public List? Data { get; set; } + private static string? GetStringProperty(JsonElement element, string propertyName) + { + return GetProperty(element, propertyName) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null; + } + + private static string? GetLegacyDataErrorMessage(JsonElement? data) + { + if (data is null || data.Value.ValueKind != JsonValueKind.Object) + { + return null; + } - [DataContract] - internal class V3ErrorData + var errorMessage = GetProperty(data.Value, "error_message"); + return errorMessage?.ValueKind == JsonValueKind.String ? errorMessage?.GetString() : null; + } + + private static string ToDetailString(JsonElement element) { - [DataMember(Name = "error_message")] - public string? ErrorMessage { get; set; } + return element.GetRawText(); + } - [DataMember(Name = "line_number")] - public int? LineNumber { get; set; } + private sealed class ParsedErrorMessage + { + internal static readonly ParsedErrorMessage Empty = new(null, null); + + internal ParsedErrorMessage(string? message, List? partialLineErrors) + { + Message = message; + PartialLineErrors = partialLineErrors; + } - [DataMember(Name = "original_line")] - public string? OriginalLine { get; set; } + internal string? Message { get; } + internal List? PartialLineErrors { get; } } } diff --git a/README.md b/README.md index 10534cdf..dcd3a980 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ public class IOxExample } ``` -to insert data, you can use code like this: +### Write + +To write data, you can use code like this: ```csharp // @@ -101,7 +103,71 @@ const string record = "temperature,location=north value=60.0"; await client.WriteRecordAsync(record: record); ``` -to query your data, you can use code like this: +#### Partial Writes + +Use `WriteOptions.AcceptPartial` to control whether a V3 write can partially succeed when some lines fail. +Default is `true` (server default). When `false`, the full batch is rejected. + +```csharp +using var client = new InfluxDBClient(new ClientConfig +{ + Host = host, + Token = token, + Database = database, + WriteOptions = new WriteOptions + { + UseV2Api = false + } +}); + +var lp = + "home,room=Sunroom temp=96 1735545600\n" + + "home,room=Sunroom temp=\"hi\" 1735549200"; + +try +{ + await client.WriteRecordAsync(lp); +} +catch (InfluxDBPartialWriteException e) +{ + foreach (var lineErr in e.LineErrors) + { + Console.WriteLine( + $"line {lineErr.LineNumber} failed: {lineErr.ErrorMessage} ({lineErr.OriginalLine})"); + } +} +catch (InfluxDBApiException e) +{ + Console.WriteLine(e.Message); +} +``` + +See [Partial writes](https://docs.influxdata.com/influxdb3/core/write-data/http-api/v3-write-lp/#partial-writes) for more. + +#### Compatibility with InfluxDB Clustered and InfluxDB Cloud Dedicated/Serverless + +Writes use the V2 API endpoint by default, so no additional configuration is required for these products. + +`UseV2Api` can be configured in three ways: + +1. `WriteOptions.UseV2Api` +2. Connection string `writeUseV2Api` +3. Environment variable `INFLUX_WRITE_USE_V2_API` + +`AcceptPartial` can be configured in three ways: + +1. `WriteOptions.AcceptPartial` +2. Connection string `writeAcceptPartial` +3. Environment variable `INFLUX_WRITE_ACCEPT_PARTIAL` + +`NoSync` requires the V3 API endpoint, which is available with InfluxDB 3 Core/Enterprise. +`AcceptPartial` applies only when writes are sent to the V3 API endpoint and is ignored when using the V2 API endpoint. +To use `NoSync`, set `UseV2Api=false` (or equivalent via connection string/env var). +Note: when writes use the V2 API endpoint, `NoSync=true` returns a validation error. + +### Query + +To query your data, you can use code like this: ```csharp //