Skip to content
Open
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
70 changes: 70 additions & 0 deletions sdk/cosmos/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# AGENTS.md — Azure Cosmos DB SDK for Java

This file provides Cosmos-specific guidance for AI agents working in the `sdk/cosmos` subtree.
For general repo-wide guidance, see the [root AGENTS.md](../../AGENTS.md).

## Test resource hygiene (required for any test that touches an account)

Every live test stage in `tests.yml` now runs against a **long lived shared account** that is never
torn down - either a fixed self-owned account in RG `sdk-ci` (the main and Http2 stages) or a thin
client / GSI account. Several matrix legs and concurrent pipeline runs share them, so a database a
test forgets to delete stays on that account forever.

When adding or changing tests in `azure-cosmos-tests`:

- **Create databases with `TestSuiteBase.createTestDatabase(client, "label")`**, never with
`UUID.randomUUID()`, a fixed literal, or a raw `client.createDatabase(...)` call. The helper embeds
the CI run id in the id so cleanup can attribute the database to this run without deleting another
run's in-flight resources. `TestResourceHygieneTest` fails the build on direct database creation.
- **Still delete what you create** in an `@AfterClass(alwaysRun = true)` using `safeDeleteDatabase`.
`CosmosTestResourceJanitor` cleans up leftovers but **fails the run** when it has to.
- Containers inside a database you delete need no separate cleanup.

### How cleanup works

| Layer | Where | Catches |
| --- | --- | --- |
| `CosmosTestResourceRegistry` + `CosmosTestResourceJanitor` | in the test JVM, at the end of the run | normal completion; deletes leftovers and **fails the run** naming the offending test |
| JVM shutdown hook | in the test JVM | crashes and hard aborts |
| `cleanup-test-resources.yml` post step | pipeline, `condition: always()` | failed jobs, and jobs whose JVM died. Runs on cancellation too, but only within `cancelTimeoutInMinutes` (5 by default), so treat it as best effort there |
| `janitor.yml` | scheduled every 6h | cancelled and timed-out jobs, where nothing in the job ever ran |

Every layer scopes deletion by the run id (`CosmosTestRunId`), derived from `System.JobId` (a GUID that
is unique per job), with the build id carried along so a stray database can be traced back to a build.

When a leak fails a run, TestNG has already written its reports by the time the janitor runs, so the
**published test results show `Tests run: 0` rather than the leak** — which reads like an infrastructure
fault. The leak is reported in the build log and as an ADO issue annotation; look there, not in the Tests
tab. Databases from *other* runs are only removed by the age based
sweep, whose threshold (8h) is comfortably longer than the longest test stage.

`CosmosTestAccountJanitor` is the standalone entry point used by both pipeline layers:

```bash
mvn -f sdk/cosmos/azure-cosmos-tests/pom.xml exec:java \
-Dexec.args="--account-host <uri> --account-key <key> --older-than 8"
```

Omit `--older-than` to delete only the current job's databases.

### Guardrails

Two layers, because a static scan alone is not enough:

- **At runtime**, `CosmosTestResourceRegistry` rejects any database id that CI cleanup could not
attribute to this run, failing the test immediately and naming it. This is the layer that actually
holds: it catches ids built at runtime, ids swapped inside a file that already has a ratchet
allowance, and creation through APIs the scanner does not know about. Disable only with
`-DCOSMOS.TEST_RESOURCE_ID_VALIDATION_ENABLED=false`.
- **Statically**, `TestResourceHygieneTest` ratchets against direct database creation.
`azure-cosmos-tests/src/test/resources/test-resource-hygiene-baseline.properties` records the
violations that existed when the check was introduced, and the build fails when a file exceeds its
entry or a new offending file appears. It runs in the `unit` group, so it gives fast feedback on
every PR without needing an account. Do not add entries for new tests; when you migrate a file,
lower or remove its entry.

### Escape hatches

- `-DCOSMOS.TEST_RESOURCE_JANITOR_ENABLED=false` disables the in-process janitor entirely.
- `-DCOSMOS.TEST_RESOURCE_JANITOR_FAIL_ON_LEAK=false` keeps the cleanup but stops it from failing the
run. Use this only to unblock a pipeline while the leaking test is being fixed.
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,9 @@
<source>1.8</source>
<target>1.8</target>
<scalaVersion>2.12.19</scalaVersion>
<!-- Per-module bridge cache. The default (~/.sbt/1.0/zinc/org.scala-sbt) is shared, and the
plugin installs the bridge with no lock, so parallel builds (-T) can read a half-written jar. -->
<secondaryCacheDir>${project.build.directory}/scala-compiler-bridge</secondaryCacheDir>
</configuration>
<executions>
<execution>
Expand Down
3 changes: 3 additions & 0 deletions sdk/cosmos/azure-cosmos-spark_3/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,9 @@
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<scalaVersion>${scala.version}</scalaVersion>
<!-- Per-module bridge cache. The default (~/.sbt/1.0/zinc/org.scala-sbt) is shared, and the
plugin installs the bridge with no lock, so parallel builds (-T) can read a half-written jar. -->
<secondaryCacheDir>${project.build.directory}/scala-compiler-bridge</secondaryCacheDir>
</configuration>
<executions>
<execution>
Expand Down
12 changes: 12 additions & 0 deletions sdk/cosmos/azure-cosmos-tests/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,18 @@ Licensed under the MIT License.
</configuration>
</plugin>

<!-- Runs CosmosTestAccountJanitor, the standalone cleanup entry point used by the always-run
pipeline post steps and by the scheduled janitor pipeline. -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.1</version> <!-- {x-version-update;org.codehaus.mojo:exec-maven-plugin;external_dependency} -->
<configuration>
<mainClass>com.azure.cosmos.CosmosTestAccountJanitor</mainClass>
<classpathScope>test</classpathScope>
</configuration>
</plugin>

<!-- CosmosSkip - Needed temporary false values to not fail. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,242 @@

package com.azure.cosmos;

import com.azure.cosmos.models.CosmosDatabaseProperties;
import com.azure.cosmos.models.SqlParameter;
import com.azure.cosmos.models.SqlQuerySpec;
import com.azure.cosmos.util.CosmosPagedFlux;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Predicate;

/**
* Naming and lifetime helper for databases created by the test suite.
* <p>
* Ids follow {@code RxJava.SDKTest.SharedDatabase_<timestamp>_<runId>_<random>}. The run id lets cleanup
* delete exactly the databases created by the current run without touching resources that a concurrently
* executing matrix leg or pipeline run is still using on the same shared account. The legacy three
* segment form (without a run id) is still parsed so databases left behind by builds predating this
* change are still recognized as ours by the age based sweep.
* <p>
* Timestamps are UTC: an id is written by the test agent and may be compared on a different machine.
*/
public final class CosmosDatabaseForTest {
private static final Logger LOGGER = LoggerFactory.getLogger(CosmosDatabaseForTest.class);
public static final String SHARED_DB_ID_PREFIX = "RxJava.SDKTest.SharedDatabase";
private static final String DELIMITER = "_";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss");
private static final int RANDOM_SUFFIX_LENGTH = 8;
private static final int MAX_LABEL_LENGTH = 10;

private CosmosDatabaseForTest() {
}

private static LocalDateTime nowUtc() {
return LocalDateTime.now(ZoneOffset.UTC);
}

public static String generateId() {
return SHARED_DB_ID_PREFIX + DELIMITER + TIME_FORMATTER.format(LocalDateTime.now(ZoneOffset.UTC))
+ DELIMITER + UUID.randomUUID();
return generateId(null);
}

/**
* Generates a run tagged database id. The optional label only makes logs and portal views readable -
* uniqueness comes from the timestamp plus the random suffix, and cleanup scoping from the run id.
* <p>
* Length matters and is deliberately kept short. The database id is embedded in derived resource
* names elsewhere in the SDK - notably the throughput control group id, which is
* {@code <database>/<container>/<group>/<suffix>}, base64 encoded and then extended with a UUID to
* form a control item id. A long database id overflows that and the emulator rejects the request
* with "400 Bad Request - Invalid URL", so keep this comfortably shorter than the ~82 character ids
* that are known to work.
*
* @param label optional human readable label, may be null.
* @return a database id that cleanup is able to attribute to the current run.
*/
public static String generateId(String label) {
String random = RandomStringUtils.randomAlphanumeric(RANDOM_SUFFIX_LENGTH);
String suffix = StringUtils.isEmpty(label) ? random : sanitizeLabel(label) + random;

return SHARED_DB_ID_PREFIX
+ DELIMITER + TIME_FORMATTER.format(nowUtc())
+ DELIMITER + CosmosTestRunId.get()
+ DELIMITER + suffix;
}

private static String sanitizeLabel(String label) {
String sanitized = label.replaceAll("[^A-Za-z0-9]", "");
return sanitized.length() <= MAX_LABEL_LENGTH ? sanitized : sanitized.substring(0, MAX_LABEL_LENGTH);
}

/**
* @param id the database id to check.
* @return true when the id was produced by {@link #generateId(String)} - and is therefore owned by
* the test suite and safe for automated cleanup to delete.
*/
public static boolean isTestDatabaseId(String id) {
return parse(id) != null;
}

private static ParsedId parse(String id) {
if (id == null) {
return null;
}

String[] parts = StringUtils.split(id, DELIMITER);
// 3 parts: legacy <prefix>_<timestamp>_<random>. 4 parts: <prefix>_<timestamp>_<runId>_<random>.
if (parts == null || parts.length < 3 || parts.length > 4) {
return null;
}
if (!StringUtils.equals(parts[0], SHARED_DB_ID_PREFIX)) {
return null;
}

try {
LocalDateTime parsedTime = LocalDateTime.parse(parts[1], TIME_FORMATTER);
String runId = parts.length == 4 ? parts[2] : null;
return new ParsedId(parsedTime, runId);
} catch (Exception e) {
return null;
}
}

/**
* Deletes every database created by the currently executing run, regardless of age. This is the
* safety net for tests that created a database and failed to delete it.
*
* @param client the database manager to clean up with.
* @return what the sweep deleted, and whether it ran to completion.
*/
public static CleanupResult cleanupDatabasesForCurrentRun(DatabaseManager client) {
return cleanupDatabasesForRun(client, CosmosTestRunId.get());
}

/**
* Deletes every database created by the given run, regardless of age. Used by the pipeline post step,
* which knows the run id of the job that just finished.
*
* @param client the database manager to clean up with.
* @param runId the run id to delete databases for.
* @return what the sweep deleted, and whether it ran to completion.
*/
public static CleanupResult cleanupDatabasesForRun(DatabaseManager client, String runId) {
if (StringUtils.isEmpty(runId)) {
// Matching a null run id would match every legacy (pre run id) database, which may belong to a
// run that is still executing. Age based cleanup is the only safe option for those.
throw new IllegalArgumentException("runId must not be empty");
}

LOGGER.info("Cleaning test databases for run {} ...", runId);
return deleteMatching(client, dbForTest -> StringUtils.equals(dbForTest.runId, runId));
}

/**
* Deletes test databases older than the given duration, whichever run created them. Used by the
* scheduled janitor pipeline to recover resources from jobs that were cancelled or timed out, where
* nothing in the job itself ever got the chance to clean up.
* <p>
* The threshold must be comfortably longer than the longest test stage so in-flight runs are never
* touched. This is deliberately not called from inside a test run - a run only ever deletes its own
* resources.
*
* @param client the database manager to clean up with.
* @param threshold the minimum age a database must have to be deleted.
* @return what the sweep deleted, and whether it ran to completion.
*/
public static CleanupResult cleanupTestDatabasesOlderThan(DatabaseManager client, Duration threshold) {
LOGGER.info("Cleaning test databases older than {} ...", threshold);
LocalDateTime cutoff = nowUtc().minus(threshold);
return deleteMatching(client, dbForTest -> dbForTest.createdTime.isBefore(cutoff));
}

private static CleanupResult deleteMatching(DatabaseManager client, Predicate<ParsedId> predicate) {
List<String> deleted = new ArrayList<>();
int failures = 0;

List<CosmosDatabaseProperties> dbs = client.queryDatabases(
new SqlQuerySpec(
"SELECT * FROM c WHERE STARTSWITH(c.id, @PREFIX)",
Collections.singletonList(new SqlParameter("@PREFIX", SHARED_DB_ID_PREFIX))))
.collectList()
.block();

if (dbs == null) {
return new CleanupResult(deleted, failures);
}

for (CosmosDatabaseProperties db : dbs) {
ParsedId parsed = parse(db.getId());
// A null parsed id means the id does not follow the test convention - it may be a hand created
// fixture, so leave it alone.
if (parsed == null || !predicate.test(parsed)) {
continue;
}

LOGGER.info("Deleting database {}", db.getId());
try {
client.getDatabase(db.getId()).delete().block();
deleted.add(db.getId());
} catch (Exception e) {
// Keep going - one undeletable database must not strand the rest - but remember that this
// sweep did not fully succeed, so callers do not mistake "found nothing" for "all clean".
failures++;
LOGGER.warn("Failed to delete database {}", db.getId(), e);
} finally {
CosmosTestResourceRegistry.unregisterDatabase(db.getId());
}
}

return new CleanupResult(deleted, failures);
}

/**
* What a cleanup sweep managed to do. A sweep that deleted nothing because every delete failed is not
* the same as a sweep that found nothing, and callers must be able to tell them apart.
*/
public static final class CleanupResult {
private final List<String> deletedDatabaseIds;
private final int failureCount;

private CleanupResult(List<String> deletedDatabaseIds, int failureCount) {
this.deletedDatabaseIds = deletedDatabaseIds;
this.failureCount = failureCount;
}

public List<String> getDeletedDatabaseIds() {
return this.deletedDatabaseIds;
}

public int getFailureCount() {
return this.failureCount;
}

public boolean isComplete() {
return this.failureCount == 0;
}
}

private static final class ParsedId {
private final LocalDateTime createdTime;
private final String runId;

private ParsedId(LocalDateTime createdTime, String runId) {
this.createdTime = createdTime;
this.runId = runId;
}
}

public interface DatabaseManager {
CosmosPagedFlux<CosmosDatabaseProperties> queryDatabases(SqlQuerySpec query);
CosmosAsyncDatabase getDatabase(String id);
}
}
Loading
Loading