Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Qlik Sense is no longer published as a mightyETL row-write target: it is removed from the production target registry/catalog, Spring `ConnectorProperties`, and the modern/legacy Qlik enable alias. `QlikSenseTargetConnector` remains only non-registered reference/design code until a separately reviewed Qlik reload/data-file integration is implemented and verified.
- Durable `POST /api/etl/jobs` submissions now return RFC 9110 `202 Accepted`, a stable pending-job representation, `Location` status-monitor metadata, and explicit replay metadata without changing the synchronous `/api/etl/process` contract. The incomplete intake controller is fail-closed and requires explicit `xtrmetl.etl.jobs.intake-enabled=true` operator opt-in until worker execution and terminal payload clearing are implemented.
- Concurrent requests using the same authenticated-principal-scoped semantic idempotency key now return immediate RFC 9457 `409 etl_idempotency_request_in_progress` responses through PostgreSQL `pg_try_advisory_xact_lock`; retries after completion still replay the committed response.
- `POST /api/etl/process` now supports optional authenticated-principal-scoped `Idempotency-Key` retries with atomic target writes, durable response replay, payload-conflict rejection, and explicit replay response metadata.
Expand All @@ -27,14 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- ETL problem-details client and operator contract: `docs/api/problem-details.md`.
- Operator-configurable ETL admission limits under `mightyetl.etl.*` / `xtrmetl.etl.*`, backed by `ETL_MAX_PAYLOAD_BYTES` and `ETL_MAX_BATCH_RECORDS` environment variables with hard safety ceilings.
- ETL transaction rollback integration coverage and the operator runbook `docs/etl/bounded-atomic-batches.md`.
- Connector scaffolds (contracts + docs only): Qlik Sense, Databricks, Snowflake under `docs/connectors/` and `etl-service` SPI stubs.
- Connector scaffold/reference contracts and docs: Databricks, Snowflake, and the non-registered Qlik design reference under `docs/connectors/` and `etl-service` SPI code.
- Any-to-any CDC design notes and source SPI scaffold: `docs/cdc/any-to-any-cdc.md`, `cdc-service` SPI stubs.
- CDC operations notes: `docs/cdc/ops-and-reliability.md`.
- Product upgrade progress tracker: `docs/mightyETL-product-upgrade-progress.md`.
- CDC status/sources API: `GET /api/cdc/status`, `GET /api/cdc/sources` (no secrets).
- `DebeziumChangeRecordMapper` + `CanonicalChangeRecord` (mapper unit-tested; not on live publish path).
- CDC target SPI registry (`kafka`, `jdbc-replica`) for any-to-any routing scaffold.
- `etl-service` `xtrmetl.connectors.*` disabled config keys for Databricks/Snowflake/Qlik.
- `etl-service` `xtrmetl.connectors.*` disabled config keys for the still-registered Databricks and Snowflake scaffolds.
- Dual-read config aliases: `mightyetl.*` preferred → `xtrmetl.*` (`MightyEtlConfigAliasEnvironmentPostProcessor`).
- Configurable replica tables (`xtrmetl.replica.tables`) for `(id,data)`-shaped tables.
- Optional CDC canonical-map counters (`xtrmetl.cdc.canonical-map-enabled`).
Expand Down
52 changes: 30 additions & 22 deletions docs/connectors/qlik-sense.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,45 @@
# Qlik Sense connector (scaffold)
# Qlik Sense integration (planned)

## Intent

Push or expose change data for Qlik Sense (SaaS or client-managed) so apps can reload against an up-to-date source.
Integrate mightyETL with Qlik Sense / Qlik Cloud only through a product boundary that matches Qlik's actual APIs and operating model. Until that boundary is implemented and verified, Qlik is not published as a mightyETL target connector or production connector configuration surface.

## Status

| Item | State |
|:-----|:------|
| Product claim | **Not supported in runtime** (`ConnectorStatus.SCAFFOLD`) |
| SPI class | `QlikSenseTargetConnector` |
| Config binding (`xtrmetl.connectors.qlik-sense.*`) | Yes |
| Required-key validation | Yes (tenant-url, api-key, app-id) |
| Catalog API fields | Yes (`GET /api/etl/connectors`) |
| Qlik REST / qrs API client | **No** |
| Product claim | **Not supported in runtime** |
| Production target registry | **Not registered** |
| Catalog API (`GET /api/etl/connectors`) | **Not advertised** |
| Production config binding (`xtrmetl.connectors.qlik-sense.*`) | **Not bound** |
| Modern/legacy config alias (`mightyetl.connectors.qlik-sense.enabled`) | **Not mirrored** |
| Reference SPI class | `QlikSenseTargetConnector` remains as non-registered design/reference code |
| Qlik reload API client | **No** |
| Qlik data-file upload/import client | **No** |
| Direct QVD writer | **No** |
| Live write path | **Refused** (`UnsupportedOperationException`) |
| Live row-write path | **No** |

## Planned integration shapes (choose later)
## Product boundary

1. **Indirect (recommended first):** mightyETL lands data in Postgres/Snowflake/Databricks; Qlik connects to that warehouse. Connector only triggers **reload** via Qlik API.
2. **Direct:** REST bulk load into a Qlik data connection (product-specific; higher complexity).
The previous scaffold modeled Qlik as a `TargetConnector.write(...)` row sink even though it never implemented a Qlik client and always refused writes. That shape is no longer advertised through the production registry, `ConnectorProperties`, or the supported modern/legacy connector-enable alias list.

## Config keys
Current Qlik Cloud APIs expose product-specific operations such as application reload orchestration and data-file upload/import. A future mightyETL integration should therefore be designed explicitly around one of those supported boundaries instead of pretending that Qlik is a transactional warehouse row sink.

| Key | Required | Description |
|:----|:--------:|:------------|
| `tenant-url` | yes | Qlik Cloud tenant or Sense enterprise base URL |
| `api-key` | yes | Auth (**secret**) |
| `app-id` | yes | App to reload |
| `mode` | no | `reload-only` \| `push` (push not designed yet) |
## Planned integration shapes

## Limitations (honest)
1. **Warehouse + reload orchestration:** mightyETL lands data in a supported warehouse or database, then a separately designed Qlik integration triggers and observes an application reload.
2. **Data-file workflow:** mightyETL produces a governed file artifact and uses Qlik's supported data-file APIs before a reload, with explicit idempotency, authentication, tenancy, retry, audit, and cleanup contracts.

- No Qlik SDK dependency, no reload automation, no multi-tenant isolation.
- Prefer documenting Qlik → Postgres/Kafka as the supported path until a live client ships.
Neither path is implemented by the current `TargetConnector` SPI. A future implementation requires a separate PRD/TRD/API contract and realistic integration tests before production registration or configuration binding.

## Historical reference keys

The non-registered reference class documents historical design keys such as `tenant-url`, `api-key`, `app-id`, and optional `mode`. Those names are reference material only: Spring `ConnectorProperties` no longer binds a Qlik subtree and the product alias bridge no longer mirrors a Qlik enable flag.

A future Qlik integration must define a new supported configuration contract from the selected API boundary rather than silently reviving these historical names.

## Limitations

- No Qlik API client or SDK integration is shipped.
- No reload automation, file upload/import workflow, QVD writer, or tenant-aware Qlik execution exists.
- Qlik must remain absent from production connector discovery and production config binding until an actual supported integration path has been implemented and verified.
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ public class MightyEtlConfigAliasEnvironmentPostProcessor implements Environment
"etl.max-batch-records",
"etl.jobs.intake-enabled",
"connectors.databricks.enabled",
"connectors.snowflake.enabled",
"connectors.qlik-sense.enabled"
"connectors.snowflake.enabled"
);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
import java.util.Map;

/**
* Target connector enable flags and binding surface for warehouse/BI scaffolds.
* Enabling a scaffold without a real implementation is refused by {@link TargetConnectorDispatcher}.
* Target connector enable flags and binding surface for warehouse scaffolds that remain in the
* production registry. Enabling a scaffold without a real implementation is refused by
* {@link TargetConnectorDispatcher}.
*
* <p>Prefix: {@code xtrmetl.connectors.*} (dual-read from {@code mightyetl.connectors.*}).</p>
*/
Expand All @@ -16,7 +17,6 @@ public class ConnectorProperties {

private final DatabricksProps databricks = new DatabricksProps();
private final SnowflakeProps snowflake = new SnowflakeProps();
private final QlikSenseProps qlikSense = new QlikSenseProps();

public DatabricksProps getDatabricks() {
return databricks;
Expand All @@ -26,15 +26,10 @@ public SnowflakeProps getSnowflake() {
return snowflake;
}

public QlikSenseProps getQlikSense() {
return qlikSense;
}

public boolean isEnabled(String connectorId) {
return switch (connectorId) {
case "databricks" -> databricks.isEnabled();
case "snowflake" -> snowflake.isEnabled();
case "qlik-sense" -> qlikSense.isEnabled();
default -> false;
};
}
Expand All @@ -47,7 +42,6 @@ public Map<String, String> configMap(String connectorId) {
return switch (connectorId) {
case "databricks" -> databricks.toConfigMap();
case "snowflake" -> snowflake.toConfigMap();
case "qlik-sense" -> qlikSense.toConfigMap();
default -> Map.of();
};
}
Expand Down Expand Up @@ -250,54 +244,6 @@ Map<String, String> toConfigMap() {
}
}

public static class QlikSenseProps extends ConnectorFlag {
private String tenantUrl = "";
private String apiKey = "";
private String appId = "";
private String mode = "reload-only";

public String getTenantUrl() {
return tenantUrl;
}

public void setTenantUrl(String tenantUrl) {
this.tenantUrl = tenantUrl;
}

public String getApiKey() {
return apiKey;
}

public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}

public String getAppId() {
return appId;
}

public void setAppId(String appId) {
this.appId = appId;
}

public String getMode() {
return mode;
}

public void setMode(String mode) {
this.mode = mode;
}

Map<String, String> toConfigMap() {
Map<String, String> map = new LinkedHashMap<>();
putIfPresent(map, "tenant-url", tenantUrl);
putIfPresent(map, "api-key", apiKey);
putIfPresent(map, "app-id", appId);
putIfPresent(map, "mode", mode);
return map;
}
}

private static void putIfPresent(Map<String, String> map, String key, String value) {
if (value != null && !value.isBlank()) {
map.put(key, value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
import java.util.Map;

/**
* Qlik Sense / Qlik Cloud target surface (reload-trigger first).
* Reference-only design type for a possible future Qlik Sense / Qlik Cloud integration.
*
* <p>Ships config contract + validation + catalog hooks. No Qlik REST client wired;
* {@link #write} always refuses (SCAFFOLD).</p>
* <p>This type is not registered in the production target registry and has no Spring-bound
* production configuration. It is retained only to preserve an explicit design reference while
* mightyETL has no supported Qlik row-write, reload, or data-file execution path. As a scaffold,
* {@link #write} always refuses execution.</p>
*
* <p>A production Qlik integration requires a separately reviewed reload/data-file contract,
* maintained client implementation, credential boundary, lifecycle semantics, operator guidance,
* and realistic integration evidence before this type may become production-discoverable.</p>
*
* @see docs/connectors/qlik-sense.md
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ public class TargetConnectorRegistry {
public TargetConnectorRegistry() {
register(new DatabricksTargetConnector());
register(new SnowflakeTargetConnector());
register(new QlikSenseTargetConnector());
}

public final void register(TargetConnector connector) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.xtrmetl.etl.config;

import com.xtrmetl.etl.connector.ConnectorProperties;
import org.junit.jupiter.api.Test;

import java.util.Arrays;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Guards removal of Qlik row-write configuration from the production connector surface while the
* real Qlik integration remains intentionally unimplemented.
*/
class QlikConnectorConfigurationRetirementTest {

@Test
void productionAliasRegistryDoesNotAdvertiseQlikRowWriteEnablement() {
assertFalse(
MightyEtlConfigAliasEnvironmentPostProcessor.RELATIVE_KEYS
.contains("connectors.qlik-sense.enabled")
);
}

@Test
void productionConnectorPropertiesDoNotBindQlikRowWriteCredentials() {
assertThrows(NoSuchMethodException.class, () -> ConnectorProperties.class.getMethod("getQlikSense"));
assertFalse(
Arrays.stream(ConnectorProperties.class.getDeclaredClasses())
.anyMatch(type -> type.getSimpleName().equals("QlikSenseProps"))
);

ConnectorProperties properties = new ConnectorProperties();
assertFalse(properties.isEnabled("qlik-sense"));
assertTrue(properties.configMap("qlik-sense").isEmpty());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.xtrmetl.etl.connector;

import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Keeps the retained Qlik reference type aligned with its retired production lifecycle status.
*/
class QlikReferenceDocumentationTest {

@Test
void retainedReferenceTypeDoesNotClaimShippedProductionHooks() throws IOException {
String source = Files.readString(
Path.of(
"src/main/java/com/xtrmetl/etl/connector/",
"QlikSenseTargetConnector.java"
),
StandardCharsets.UTF_8
);

assertTrue(source.contains("Reference-only design type"));
assertTrue(source.contains("not registered in the production target registry"));
assertFalse(source.contains("Ships config contract + validation + catalog hooks"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,12 @@ void failsValidationWhenScaffoldEnabledButConfigIncomplete() {
}

@Test
void catalogShowsAllConnectorsDisabledByDefault() {
void catalogShowsAdvertisedConnectorsDisabledByDefault() {
TargetConnectorDispatcher dispatcher =
new TargetConnectorDispatcher(new TargetConnectorRegistry(), new ConnectorProperties());

assertEquals(3, dispatcher.catalog().size());
assertEquals(2, dispatcher.catalog().size());
assertTrue(dispatcher.catalog().stream().noneMatch(row -> "qlik-sense".equals(row.get("id"))));
assertTrue(dispatcher.catalog().stream().noneMatch(row -> Boolean.TRUE.equals(row.get("writable"))));
assertTrue(dispatcher.catalog().stream().noneMatch(row -> Boolean.TRUE.equals(row.get("opened"))));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
class TargetConnectorRegistryTest {

@Test
void registersScaffoldConnectorsForWarehouseAndBiTargets() {
void registersOnlyAdvertisedWarehouseScaffolds() {
TargetConnectorRegistry registry = new TargetConnectorRegistry();

assertEquals(3, registry.all().size());
assertEquals(2, registry.all().size());
assertTrue(registry.find("databricks").isPresent());
assertTrue(registry.find("snowflake").isPresent());
assertTrue(registry.find("qlik-sense").isPresent());
assertFalse(registry.find("qlik-sense").isPresent());

for (TargetConnector connector : registry.all()) {
assertEquals(ConnectorStatus.SCAFFOLD, connector.status());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Unit tests on shipped warehouse/BI connector classes (not live SaaS).
* Unit tests on warehouse target connectors intentionally exposed by the production registry.
*/
class WarehouseBiConnectorSurfaceTest {

static Stream<TargetConnector> scaffolds() {
return Stream.of(
new DatabricksTargetConnector(),
new SnowflakeTargetConnector(),
new QlikSenseTargetConnector()
new SnowflakeTargetConnector()
);
}

Expand Down Expand Up @@ -83,11 +82,11 @@ void snowflakeRequiresAccountPathKeys() {
}

@Test
void qlikRequiresTenantAuthApp() {
QlikSenseTargetConnector c = new QlikSenseTargetConnector();
assertEquals("qlik-sense", c.id());
assertTrue(c.requiredConfigKeys().containsAll(
List.of("tenant-url", "api-key", "app-id")));
void qlikIsNotPublishedAsARowWriteTargetUntilItsRealProductBoundaryExists() {
TargetConnectorRegistry registry = new TargetConnectorRegistry();

assertTrue(registry.find("qlik-sense").isEmpty());
assertTrue(registry.all().stream().noneMatch(connector -> connector.id().equals("qlik-sense")));
}

@Test
Expand Down Expand Up @@ -133,12 +132,13 @@ void dispatcherValidatesBoundConfigBeforeScaffoldRefusal() {
}

@Test
void catalogExposesRequiredKeysAndIntegrationHooks() {
void catalogExposesOnlyIntentionalWarehouseScaffolds() {
TargetConnectorDispatcher dispatcher =
new TargetConnectorDispatcher(new TargetConnectorRegistry(), new ConnectorProperties());

List<Map<String, Object>> catalog = dispatcher.catalog();
assertEquals(3, catalog.size());
assertEquals(2, catalog.size());
assertTrue(catalog.stream().noneMatch(row -> "qlik-sense".equals(row.get("id"))));
for (Map<String, Object> row : catalog) {
assertEquals("SCAFFOLD", row.get("status"));
assertEquals(false, row.get("writable"));
Expand Down
Loading
Loading