Skip to content
Merged
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
31 changes: 24 additions & 7 deletions app/api/definitions/components/release-tracks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -392,11 +392,13 @@ components:
description: 'Exact component snapshot timestamp; also identifies when that snapshot was created'
resolved_version:
type: string
description: 'Tagged version of the exact component snapshot'
nullable: true
description: 'Tagged version of the exact component snapshot, or null for a draft'
strategy_used:
type: string
enum:
- latest_tagged
- latest_draft
- specific_version
- specific_snapshot
filters_applied:
Expand Down Expand Up @@ -652,9 +654,11 @@ components:
type: object
description: |
Reference to a component standard track. Selector fields are determined
by resolution_strategy: latest_tagged rejects version and snapshot;
specific_version requires only version; specific_snapshot requires only
snapshot. Unknown properties are rejected.
by resolution_strategy: latest_tagged and latest_draft reject version and
snapshot; specific_version requires only version; specific_snapshot requires
only snapshot and remains tagged-only. latest_draft selects the active
untagged standard snapshot, never an older retained source or a tagged
fallback. Every strategy imports members only. Unknown properties are rejected.
additionalProperties: false
required:
- track_id
Expand All @@ -673,6 +677,7 @@ components:
type: string
enum:
- latest_tagged
- latest_draft
- specific_version
- specific_snapshot
description: 'How to resolve which snapshot to use from this component'
Expand Down Expand Up @@ -732,6 +737,17 @@ components:
- version
- required:
- snapshot
- title: 'Latest active draft'
properties:
resolution_strategy:
enum:
- latest_draft
not:
anyOf:
- required:
- version
- required:
- snapshot
- title: 'Specific release version'
required:
- version
Expand Down Expand Up @@ -795,11 +811,12 @@ components:
type: object
description: |
Virtual releases only. Immutable provenance keyed by component
release-track ID; each value is the tagged component version frozen
in the released draft's composition_resolution. Standard release
history entries omit this property.
release-track ID; each value is the tagged component version or null
for a draft source frozen in the released draft's composition_resolution.
Standard release history entries omit this property.
additionalProperties:
type: string
nullable: true
pattern: '^\d+\.\d+$'
example:
release-track--a1b2c3d4-e5f6-7890-abcd-ef1234567890: '5.2'
Expand Down
4 changes: 2 additions & 2 deletions app/api/definitions/paths/release-tracks-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -836,8 +836,8 @@ paths:
The new pending draft has empty members and quarantine tiers and a null
composition_resolution. Materialize it before release preview or commit.
Request body is strictly validated via Zod. Unknown composition,
component, filter, and deduplication keys are rejected. latest_tagged
rejects selector fields; specific_version requires version;
component, filter, and deduplication keys are rejected. latest_tagged and
latest_draft reject selector fields; specific_version requires version;
specific_snapshot requires snapshot. Component IDs and required
non-negative integer priorities must each be unique.
An optional strict scheduled_materialization object is persisted on the
Expand Down
13 changes: 12 additions & 1 deletion app/lib/release-tracks/release-track-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,12 @@ const deduplicationStrategySchema = z.enum([
'quarantine',
]);

const resolutionStrategySchema = z.enum(['latest_tagged', 'specific_version', 'specific_snapshot']);
const resolutionStrategySchema = z.enum([
'latest_tagged',
'latest_draft',
'specific_version',
'specific_snapshot',
]);

const conflictPolicySchema = z.enum([
'prefer_latest',
Expand Down Expand Up @@ -365,6 +370,12 @@ const componentTrackSchema = z.discriminatedUnion('resolution_strategy', [
resolution_strategy: z.literal('latest_tagged'),
})
.strict(),
z
.object({
...componentTrackBaseShape,
resolution_strategy: z.literal('latest_draft'),
})
.strict(),
z
.object({
...componentTrackBaseShape,
Expand Down
10 changes: 6 additions & 4 deletions app/models/release-tracks/release-track-snapshot-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const componentTrackDefinition = {
},
resolution_strategy: {
type: String,
enum: ['latest_tagged', 'specific_version', 'specific_snapshot'],
enum: ['latest_tagged', 'latest_draft', 'specific_version', 'specific_snapshot'],
required: true,
},
priority: {
Expand Down Expand Up @@ -172,7 +172,10 @@ const componentSnapshotResolutionDefinition = {
resolved_snapshot_id: { type: Date, required: true },
resolved_version: {
type: String,
required: true,
required: function () {
return this.strategy_used !== 'latest_draft';
},
default: null,
validate: validateVersion,
},
strategy_used: { type: String, required: true },
Expand Down Expand Up @@ -360,12 +363,11 @@ const versionHistoryEntryDefinition = {
candidates_count: { type: Number },
quarantine_count: { type: Number },
},
// Virtual tracks only: immutable component track ID → tagged version.
// Virtual tracks only: immutable component track ID → version (null for drafts).
component_versions: {
type: Map,
of: {
type: String,
required: true,
validate: validateVersion,
},
default: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,14 +560,15 @@ class ReleaseTrackDynamicRepository {
}
}

async deleteOlderDrafts(trackId, modified) {
async deleteOlderDrafts(trackId, modified, referencedDrafts = []) {
try {
const Model = this._getModel(trackId);
const retainedDrafts = await Model.distinct('release_source_modified', {
id: trackId,
version: { $type: 'string' },
release_source_modified: { $type: 'date' },
}).exec();
retainedDrafts.push(...referencedDrafts);
const query = {
id: trackId,
version: null,
Expand Down Expand Up @@ -618,6 +619,26 @@ class ReleaseTrackDynamicRepository {
}
}

async findResolvedComponentSnapshotIds(trackId, componentTrackId) {
try {
const Model = this._getModel(trackId);
const snapshots = await Model.aggregate([
{
$match: {
id: trackId,
'composition_resolution.component_snapshots.track_id': componentTrackId,
},
},
{ $unwind: '$composition_resolution.component_snapshots' },
{ $match: { 'composition_resolution.component_snapshots.track_id': componentTrackId } },
{ $group: { _id: '$composition_resolution.component_snapshots.resolved_snapshot_id' } },
]).exec();
return snapshots.map((snapshot) => snapshot._id);
} catch (err) {
throw new DatabaseError(err);
}
}

async deleteAllSnapshots(trackId) {
try {
const Model = this._getModel(trackId);
Expand Down
24 changes: 22 additions & 2 deletions app/services/release-tracks/snapshot-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,16 @@ exports.cloneSnapshot = async function cloneSnapshot(
overrides,
options = {},
) {
if (sourceSnapshot.type === 'standard') {
const { withReleaseLock } = require('./versioning-service');
return withReleaseLock(trackId, () =>
cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options),
);
}
return cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options);
};

async function cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options) {
const clone = deepClone(sourceSnapshot);
const hasSnapshotDescriptionOverride = Object.prototype.hasOwnProperty.call(
overrides || {},
Expand Down Expand Up @@ -479,7 +489,17 @@ exports.cloneSnapshot = async function cloneSnapshot(
}

if (saved.type === 'standard') {
const prunedDrafts = await dynamicRepo.deleteOlderDrafts(trackId, saved.modified);
// Materialization holds this same track's release lock until provenance
// is persisted, so this scan cannot miss a concurrently created dependent.
const virtualTracks = (await registryRepo.findAll({ type: 'virtual' })).data;
const referencedDrafts = await mapWithConcurrency(virtualTracks, 12, (track) =>
dynamicRepo.findResolvedComponentSnapshotIds(track.track_id, trackId),
);
const prunedDrafts = await dynamicRepo.deleteOlderDrafts(
trackId,
saved.modified,
referencedDrafts.flat(),
);
await contentManifestService.discardUnreferenced(
trackId,
prunedDrafts.map((snapshot) => snapshot.content_manifest_id),
Expand All @@ -498,7 +518,7 @@ exports.cloneSnapshot = async function cloneSnapshot(
}
logger.verbose(`SnapshotService: Cloned snapshot for track "${trackId}"`);
return saved;
};
}

// =============================================================================
// Track cloning
Expand Down
8 changes: 4 additions & 4 deletions app/services/release-tracks/versioning-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,15 @@ function virtualReleaseChanges(previousSnapshot, draftSnapshot) {
}

/**
* Capture the tagged component versions frozen into a materialized virtual
* draft. Track IDs are stable provenance keys; component names are descriptive
* metadata and may change or collide.
* Capture the component versions frozen into a materialized virtual draft,
* using null for draft sources. Track IDs are stable provenance keys;
* component names are descriptive metadata and may change or collide.
*/
function virtualComponentVersions(snapshot) {
return Object.fromEntries(
(snapshot.composition_resolution?.component_snapshots || []).map((component) => [
component.track_id,
component.resolved_version,
component.resolved_version ?? null,
]),
);
}
Expand Down
29 changes: 22 additions & 7 deletions app/services/release-tracks/virtual-track-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const CreationCause = require('../../lib/release-tracks/snapshot-creation-causes
// snapshot creation via resolution of component tracks.
//
// Virtual tracks aggregate content from multiple standard tracks by:
// 1. Resolving each component track to a specific tagged snapshot
// 1. Resolving each component track to a tagged snapshot or its active draft
// 2. Collecting members from each resolved snapshot
// 3. Applying per-component filters (object_types and domains)
// 4. Deduplicating across all components
Expand Down Expand Up @@ -127,8 +127,7 @@ exports.validateComposition = async function validateComposition(composition) {
};

/**
* Resolve a component track to a specific tagged snapshot based on its
* resolution strategy.
* Resolve a component track to a snapshot based on its resolution strategy.
*
* @param {Object} component - A component_tracks entry
* @returns {Promise<Object>} The resolved snapshot document
Expand All @@ -142,6 +141,22 @@ async function resolveComponentSnapshot(component) {
snapshot = await dynamicRepo.getLatestTaggedSnapshot(component.track_id);
break;

case 'latest_draft':
snapshot = await dynamicRepo.getLatestSnapshot(component.track_id);
// Older untagged snapshots may be retained for releases or virtual
// provenance. They are not an active rolling draft.
if (
!snapshot ||
snapshot.version != null ||
(await dynamicRepo.getReleaseBySourceModified(component.track_id, snapshot.modified))
) {
throw new BadRequestError({
message: `Component track '${component.track_id}' has no active draft snapshot`,
details: 'Create a standard-track draft before materializing with latest_draft',
});
}
break;

case 'specific_version':
snapshot = await dynamicRepo.getSnapshotByVersion(component.track_id, component.version);
break;
Expand All @@ -160,8 +175,8 @@ async function resolveComponentSnapshot(component) {
throw new NoTaggedSnapshotsError(component.track_id);
}

// For specific_snapshot strategy, the snapshot may be a draft — validate it's tagged
if (snapshot.version == null) {
// Explicit snapshot selection remains tagged-only.
if (component.resolution_strategy !== 'latest_draft' && snapshot.version == null) {
throw new NoTaggedSnapshotsError(component.track_id);
}

Expand Down Expand Up @@ -375,7 +390,7 @@ async function resolveComposition(snapshot, registryMap) {
track_name: registry.name,
track_type: registry.type,
resolved_snapshot_id: resolvedSnapshot.modified,
resolved_version: resolvedSnapshot.version,
resolved_version: resolvedSnapshot.version ?? null,
strategy_used: component.resolution_strategy,
filters_applied: component.filters || undefined,
total_objects_in_source: totalObjectsInSource,
Expand Down Expand Up @@ -497,7 +512,7 @@ exports.updateSchedule = async function updateSchedule(trackId, schedule) {
* Create a new virtual snapshot by resolving the composition rules.
*
* For each component track:
* 1. Resolve to a tagged snapshot via the configured strategy
* 1. Resolve to a tagged snapshot or active draft via the configured strategy
* 2. Extract and filter members
* Then deduplicate across all components and persist a new draft snapshot.
*
Expand Down
20 changes: 0 additions & 20 deletions app/tests/api/release-tracks/release-tracks-release.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,26 +467,6 @@ describe('Release-track release planning and commit API', function () {
name: 'DatabaseError',
details: expect.stringContaining('Component version keys must be valid release track IDs'),
});

const missingValueModified = new Date(created.getTime() + 3000);
await expect(
dynamicRepo.saveSnapshot(track.id, {
...snapshotBase(track),
modified: missingValueModified,
version: '1.2',
version_history: [
{
...historyEntry,
version: '1.2',
snapshot_id: missingValueModified,
component_versions: { [track.id]: null },
},
],
}),
).rejects.toMatchObject({
name: 'DatabaseError',
details: expect.stringContaining('is required'),
});
});

it('resolves latest when the release request is handled', async function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ describe('Virtual release-track composition validation API', function () {
const invalidComponents = [
component('latest_tagged', { version: '1.0' }),
component('latest_tagged', { snapshot: timestamp }),
component('latest_draft', { version: '1.0' }),
component('latest_draft', { snapshot: timestamp }),
component('specific_version'),
component('specific_version', { snapshot: timestamp }),
component('specific_version', { version: '1.0', snapshot: timestamp }),
Expand All @@ -146,6 +148,7 @@ describe('Virtual release-track composition validation API', function () {
const timestamp = '2024-02-01T10:00:00.000Z';
const validComponents = [
component('latest_tagged'),
component('latest_draft'),
component('specific_version', { version: '1.0' }),
component('specific_snapshot', { snapshot: timestamp }),
];
Expand Down
Loading
Loading