Skip to content

Commit cbcaecd

Browse files
committed
DS-1644: Fix bug in FeatureWriter related to writing to composite layers with enabled conflictDetection
Additionally, improve the following: - Fix error message of retryable write conflicts in DatabaseWriter - Add new field "internalDetails" to ErrorResponseException to be used by connector implementations optionally - Enable logging internal details of connector exceptions (at least as WARNs) - Fix plv8 emulation: - enable the proper search_path so that DB functions are working - Fully resubstitute all SQL placeholders to not use prepared statements, as NPM module "pg" does not support using multiple statements within one prepared statement (e.g., as it's needed for #insertHistoryRow()) Signed-off-by: Benjamin Rögner <benjamin.roegner@here.com>
1 parent fb01f45 commit cbcaecd

File tree

8 files changed

+112
-40
lines changed

8 files changed

+112
-40
lines changed

xyz-connectors/src/main/java/com/here/xyz/connectors/EntryConnectorHandler.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ public void handleRequest(InputStream input, OutputStream output, Context contex
103103
String className = "com.here.xyz.psql.PSQLXyzConnector";
104104
if (event.getConnectorParams() != null && event.getConnectorParams().containsKey("className"))
105105
className = event.getConnectorParams().get("className").toString();
106-
//TBD: read from connector config
107106
final Class<?> mainClass = Class.forName(className);
108107
final AbstractConnectorHandler reqHandler = (AbstractConnectorHandler) mainClass.getDeclaredConstructor().newInstance();
109108

@@ -118,8 +117,12 @@ public void handleRequest(InputStream input, OutputStream output, Context contex
118117
}
119118
catch (ErrorResponseException e) {
120119
switch (e.getErrorResponse().getError()) {
121-
case EXCEPTION, BAD_GATEWAY, TIMEOUT -> logger.error("{} Exception in Connector:", streamId, e);
120+
case EXCEPTION, BAD_GATEWAY, TIMEOUT -> logger.error("[{}] Unexpected exception in connector:", streamId, e);
121+
default -> logger.warn("[{}] Exception in connector:", streamId, e);
122122
}
123+
if (e.getInternalDetails() != null)
124+
logger.warn("[{}] Internal details of exception: {}", streamId, e.getInternalDetails());
125+
123126
e.getErrorResponse().setStreamId(streamId);
124127
dataOut = e.getErrorResponse();
125128
}

xyz-connectors/src/main/java/com/here/xyz/connectors/ErrorResponseException.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2017-2023 HERE Europe B.V.
2+
* Copyright (C) 2017-2025 HERE Europe B.V.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -31,6 +31,11 @@ public class ErrorResponseException extends Exception {
3131

3232
private ErrorResponse errorResponse;
3333

34+
/**
35+
* Additional details about the error, that will be logged but won't be added to the (user-facing) error message.
36+
*/
37+
private String internalDetails;
38+
3439
public ErrorResponseException(XyzError xyzError, String errorMessage) {
3540
super(errorMessage);
3641
createErrorResponse(xyzError, errorMessage);
@@ -78,4 +83,17 @@ private void createErrorResponse(XyzError xyzError, String errorMessage) {
7883
public ErrorResponse getErrorResponse() {
7984
return errorResponse;
8085
}
86+
87+
public String getInternalDetails() {
88+
return internalDetails;
89+
}
90+
91+
public void setInternalDetails(String internalDetails) {
92+
this.internalDetails = internalDetails;
93+
}
94+
95+
public ErrorResponseException withInternalDetails(String internalDetails) {
96+
setInternalDetails(internalDetails);
97+
return this;
98+
}
8199
}

xyz-hub-test/src/test/java/com/here/xyz/hub/rest/TestSpaceWithFeature.java

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2017-2023 HERE Europe B.V.
2+
* Copyright (C) 2017-2025 HERE Europe B.V.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,19 +19,25 @@
1919

2020
package com.here.xyz.hub.rest;
2121

22-
import com.fasterxml.jackson.core.JsonProcessingException;
2322
import static com.here.xyz.hub.auth.TestAuthenticator.AuthProfile.ACCESS_ALL;
24-
import com.here.xyz.models.geojson.coordinates.PointCoordinates;
25-
import com.here.xyz.models.geojson.implementation.Feature;
26-
import com.here.xyz.models.geojson.implementation.FeatureCollection;
27-
import com.here.xyz.models.geojson.implementation.Point;
28-
import com.here.xyz.models.geojson.implementation.Properties;
2923
import static com.here.xyz.util.service.BaseHttpServerVerticle.HeaderValues.APPLICATION_GEO_JSON;
3024
import static com.here.xyz.util.service.BaseHttpServerVerticle.HeaderValues.APPLICATION_JSON;
3125
import static com.here.xyz.util.service.BaseHttpServerVerticle.HeaderValues.APPLICATION_VND_HERE_FEATURE_MODIFICATION_LIST;
3226
import static io.netty.handler.codec.http.HttpResponseStatus.NO_CONTENT;
3327
import static io.netty.handler.codec.http.HttpResponseStatus.OK;
3428
import static io.restassured.RestAssured.given;
29+
import static org.hamcrest.Matchers.equalTo;
30+
import static org.hamcrest.Matchers.everyItem;
31+
import static org.hamcrest.Matchers.isIn;
32+
import static org.junit.Assert.assertEquals;
33+
import static org.junit.Assert.assertThat;
34+
35+
import com.fasterxml.jackson.core.JsonProcessingException;
36+
import com.here.xyz.models.geojson.coordinates.PointCoordinates;
37+
import com.here.xyz.models.geojson.implementation.Feature;
38+
import com.here.xyz.models.geojson.implementation.FeatureCollection;
39+
import com.here.xyz.models.geojson.implementation.Point;
40+
import com.here.xyz.models.geojson.implementation.Properties;
3541
import io.restassured.response.ValidatableResponse;
3642
import io.vertx.core.json.JsonObject;
3743
import java.util.ArrayList;
@@ -47,11 +53,6 @@
4753
import java.util.stream.Collectors;
4854
import java.util.stream.Stream;
4955
import org.apache.commons.lang3.RandomStringUtils;
50-
import static org.hamcrest.Matchers.equalTo;
51-
import static org.hamcrest.Matchers.everyItem;
52-
import static org.hamcrest.Matchers.isIn;
53-
import static org.junit.Assert.assertEquals;
54-
import static org.junit.Assert.assertThat;
5556

5657
public class TestSpaceWithFeature extends TestWithSpaceCleanup {
5758
protected static String embeddedStorageId = "psql";
@@ -63,8 +64,8 @@ protected static void remove() {
6364
remove("x-psql-test");
6465
}
6566

66-
protected static void remove(String spaceId) {
67-
TestWithSpaceCleanup.removeSpace(spaceId);
67+
protected static void remove(String spaceId) {
68+
TestWithSpaceCleanup.removeSpace(spaceId);
6869
}
6970

7071
public static ValidatableResponse createSpace(String content) {
@@ -498,11 +499,11 @@ public static void postFeatures(String spaceId, FeatureCollection features, Auth
498499
.post(getSpacesPath() + "/"+ spaceId +"/features");
499500
}
500501

501-
protected static void makeComposite(String spaceId, String newExtendingId) {
502+
protected static void makeComposite(String spaceId, String extendedSpaceId) {
502503
given()
503504
.contentType(APPLICATION_JSON)
504505
.headers(getAuthHeaders(AuthProfile.ACCESS_OWNER_1_ADMIN))
505-
.body("{\"extends\":{\"spaceId\":\"" + newExtendingId + "\"}}")
506+
.body(new JsonObject().put("extends", new JsonObject().put("spaceId", extendedSpaceId)).toString())
506507
.when()
507508
.patch("/spaces/" + spaceId)
508509
.then()

xyz-hub-test/src/test/java/com/here/xyz/hub/rest/VersioningCompositeGetFeaturesIT.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.here.xyz.models.geojson.coordinates.PointCoordinates;
2727
import com.here.xyz.models.geojson.implementation.Point;
2828
import com.here.xyz.models.geojson.implementation.Properties;
29+
import com.here.xyz.models.geojson.implementation.XyzNamespace;
2930
import org.junit.After;
3031
import org.junit.Before;
3132
import org.junit.FixMethodOrder;
@@ -167,4 +168,14 @@ public void testGetFeatureVersionStarCompositeVersionsBeforeAndAfterEdit() {
167168
.body("features[0].properties.@ns:com:here:xyz.version", equalTo(0))
168169
.body("features[1].properties.@ns:com:here:xyz.version", equalTo(3));
169170
}
171+
172+
@Test
173+
public void testWriteFeatureWithConflictDetection() {
174+
postFeature(BASE, newFeature().withId("f2"), ACCESS_ALL);
175+
postFeature(DELTA, newFeature()
176+
.withId("f2")
177+
.withProperties(new Properties()
178+
.withXyzNamespace(new XyzNamespace().withVersion(0))
179+
.with("otherKey", "otherValue")), ACCESS_ALL, true);
180+
}
170181
}

xyz-psql-connector/src/main/java/com/here/xyz/psql/query/WriteFeatures.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import static com.here.xyz.responses.XyzError.CONFLICT;
2424
import static com.here.xyz.responses.XyzError.EXCEPTION;
2525
import static com.here.xyz.responses.XyzError.NOT_FOUND;
26+
import static com.here.xyz.util.db.pg.SQLError.FEATURE_EXISTS;
27+
import static com.here.xyz.util.db.pg.SQLError.FEATURE_NOT_EXISTS;
2628
import static com.here.xyz.util.db.pg.SQLError.RETRYABLE_VERSION_CONFLICT;
2729
import static com.here.xyz.util.db.pg.XyzSpaceTableHelper.PARTITION_SIZE;
2830

@@ -113,12 +115,16 @@ protected FeatureCollection run(DataSourceProvider dataSourceProvider) throws Er
113115
catch (SQLException e) {
114116
final String message = e.getMessage();
115117
String cleanMessage = message.contains("\n") ? message.substring(0, message.indexOf("\n")) : message;
116-
throw switch (SQLError.fromErrorCode(e.getSQLState())) {
117-
case FEATURE_EXISTS, VERSION_CONFLICT_ERROR, MERGE_CONFLICT_ERROR, RETRYABLE_VERSION_CONFLICT -> new ErrorResponseException(CONFLICT, cleanMessage, e);
118-
case DUPLICATE_KEY -> new ErrorResponseException(CONFLICT, "Conflict while writing features.", e); //TODO: Handle all conflicts in FeatureWriter properly
119-
case FEATURE_NOT_EXISTS -> new ErrorResponseException(NOT_FOUND, cleanMessage, e);
120-
case ILLEGAL_ARGUMENT -> new ErrorResponseException(XyzError.ILLEGAL_ARGUMENT, cleanMessage, e);
121-
case XYZ_EXCEPTION, UNKNOWN -> new ErrorResponseException(EXCEPTION, e.getMessage(), e);
118+
SQLError sqlError = SQLError.fromErrorCode(e.getSQLState());
119+
String details = message.contains("\n") && sqlError != FEATURE_EXISTS && sqlError != FEATURE_NOT_EXISTS
120+
? message.substring(message.indexOf("\n") + 1)
121+
: null;
122+
throw switch (sqlError) {
123+
case FEATURE_EXISTS, VERSION_CONFLICT_ERROR, MERGE_CONFLICT_ERROR, RETRYABLE_VERSION_CONFLICT -> new ErrorResponseException(CONFLICT, cleanMessage, e).withInternalDetails(details);
124+
case DUPLICATE_KEY -> new ErrorResponseException(CONFLICT, "Conflict while writing features.", e).withInternalDetails(details); //TODO: Handle all conflicts in FeatureWriter properly
125+
case FEATURE_NOT_EXISTS -> new ErrorResponseException(NOT_FOUND, cleanMessage, e).withInternalDetails(details);
126+
case ILLEGAL_ARGUMENT -> new ErrorResponseException(XyzError.ILLEGAL_ARGUMENT, cleanMessage, e).withInternalDetails(details);
127+
case XYZ_EXCEPTION, UNKNOWN -> new ErrorResponseException(EXCEPTION, e.getMessage(), e).withInternalDetails(details);
122128
};
123129
}
124130
}

xyz-util/src/main/resources/sql/DatabaseWriter.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,17 @@ class DatabaseWriter {
8181
* Executes a prepared query using the specified parameterSet and returns the results after parsing them using the
8282
* specified resultParser.
8383
* @param {PreparedPlan} plan
84-
* @param {object[][]} parameterSet
84+
* @param {object[][]} parameterSets
8585
* @param {(function(object[]) : FeatureModificationExecutionResult)[]} resultParsers
8686
* @param {object} queryOptions
8787
* @returns {FeatureModificationExecutionResult[]}
8888
* @private
8989
*/
90-
_executePlan(plan, parameterSet, resultParsers, queryOptions) {
90+
_executePlan(plan, parameterSets, resultParsers, queryOptions) {
9191
let results = [];
92-
for (let index in parameterSet) {
92+
for (let index in parameterSets) {
9393
try {
94-
let result = resultParsers[index](plan.execute(parameterSet[index]));
94+
let result = resultParsers[index](plan.execute(parameterSets[index]));
9595
if (result != null)
9696
results.push(result);
9797
}
@@ -100,9 +100,9 @@ class DatabaseWriter {
100100
let exceptionToThrow;
101101
let onVersionConflict = queryOptions?.onVersionConflict;
102102
if (!onVersionConflict || onVersionConflict == "ERROR")
103-
exceptionToThrow = new VersionConflictError(`Version conflict while trying to write feature with ID ${parameterSet[0]} in version ${parameterSet[1]}.`);
103+
exceptionToThrow = new VersionConflictError(`Version conflict while trying to write feature with ID ${parameterSets[index][0]} in version ${parameterSets[index][1]}.`);
104104
else
105-
exceptionToThrow = new RetryableVersionConflictError(`Retryable version conflict while trying to write feature with ID ${parameterSet[0]} in version ${parameterSet[1]}.`);
105+
exceptionToThrow = new RetryableVersionConflictError(`Retryable version conflict while trying to write feature with ID ${parameterSets[index][0]} in version ${parameterSets[index][1]}.`);
106106
throw exceptionToThrow.withHint("The feature has been modified in the meantime.");
107107
}
108108
else

xyz-util/src/main/resources/sql/FeatureWriter.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -586,15 +586,18 @@ class FeatureWriter {
586586
else
587587
feature = resultSet[0].jsondata;
588588

589+
let dataset = resultSet[0].dataset;
590+
let version = FeatureWriter._isComposite() && dataset < this.tables.length - 1 ? 0 : Number(resultSet[0].version);
591+
589592
feature.id = resultSet[0].id;
590593
feature.geometry = resultSet[0].geo;
591-
feature.properties[XYZ_NS].version = Number(resultSet[0].version);
594+
feature.properties[XYZ_NS].version = version;
592595
Object.defineProperty(feature, "operation", {
593596
value: resultSet[0].operation,
594597
enumerable: false
595598
});
596599
Object.defineProperty(feature, "dataset", {
597-
value: resultSet[0].dataset,
600+
value: dataset,
598601
enumerable: false
599602
});
600603
Object.defineProperty(feature, "containingDatasets", {
@@ -825,7 +828,7 @@ class FeatureWriter {
825828

826829
_loadFeature(id, version, tables) {
827830
let tableAliases = tables.map((table, i) => "t" + (tables.length - i - 1));
828-
let branchTableMaxVersion = i => i == tables.length - 1 || FeatureWriter._isComposite() ? "" : `AND version <= ${this.tableBaseVersions[i + 1] - this.tableBaseVersions[i]}`;
831+
let branchTableMaxVersion = i => i == tables.length - 1 || FeatureWriter._isComposite() ? "" : `AND version <= ${this.tableBaseVersions[i + 1] - this.tableBaseVersions[i]}`;
829832
let whereConditions = tables.map((table, i) => `WHERE id = $1 AND ${version == "HEAD" ? `next_version = ${MAX_BIG_INT}` : `version = ${version - this.tableBaseVersions[i]}`} ${branchTableMaxVersion(i)} AND operation != $2`).reverse();
830833
let tableBaseVersions = tables.map((table, i) => this.tableBaseVersions[i]).reverse();
831834

xyz-util/src/test/js/plv8.js

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ let pgClient = new Client({
2424
host: "localhost",
2525
user: "postgres",
2626
password: "password",
27-
database: "postgres"
27+
database: "postgres",
28+
options: `-c search_path=public,topology,hub.common,hub.geo,hub.feature_writer,hub.h3,hub.ext`
2829
});
2930

3031
let clientConnected = false;
31-
pgClient.connect().then(err => {
32-
//pgClient.query(`SET search_path = "public", "topology", "hub.common", "hub.geo", "hub.feature_writer", "hub.h3", "hub.ext"`);
32+
pgClient.connect().then(result => {
3333
clientConnected = true;
34-
console.log(err);
34+
console.log(result);
3535
});
3636

3737
class PreparedPlan {
@@ -84,12 +84,42 @@ global.plv8 = {
8484
deasync.loopWhile(() => !clientConnected);
8585

8686
let queryResult = null;
87-
pgClient.query(sql, params).then(result => queryResult = result);
87+
pgClient.query(substituteSQL(sql, params)).then(result => queryResult = result);
8888
deasync.loopWhile(() => queryResult == null);
8989
return queryResult.rows;
9090
},
9191
prepare(sql, typeNames = []) {
9292
return new PreparedPlan(sql, typeNames).prepare();
9393
}
9494
//TODO: Support prepared / batch queries
95-
};
95+
};
96+
97+
//NOTE: The following helper function is necessary, because the NPN pg module does not support to have multiple SQL statements within prepared statements or queries.
98+
/**
99+
* Substitute $1, $2, ... placeholders in a SQL string with values from params array
100+
* @param {string} sql - SQL string with $1, $2, ... placeholders
101+
* @param {Array} params - Array of parameter values
102+
* @returns {string} SQL string with parameters substituted
103+
*/
104+
function substituteSQL(sql, params) {
105+
return sql.replace(/\$(\d+)/g, (match, number) => {
106+
const index = parseInt(number, 10) - 1;
107+
if (index < 0 || index >= params.length) {
108+
throw new Error(`No parameter provided for placeholder ${match}`);
109+
}
110+
const value = params[index];
111+
112+
if (value === null || value === undefined) return 'NULL';
113+
if (typeof value === 'number') return value.toString();
114+
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
115+
if (value instanceof Date) return `'${value.toISOString()}'`;
116+
117+
// Escape single quotes for strings
118+
if (typeof value === 'string') {
119+
return `'${value.replace(/'/g, "''")}'`;
120+
}
121+
122+
// For objects/arrays, JSON stringify
123+
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
124+
});
125+
}

0 commit comments

Comments
 (0)