Skip to content

Commit 20048f6

Browse files
committed
write full hash manifest during createAppDeployment for partial dedup
1 parent f84f954 commit 20048f6

File tree

3 files changed

+168
-39
lines changed

3 files changed

+168
-39
lines changed

integration-tests/tests/api/app-deployments.spec.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6201,7 +6201,7 @@ test('v2 version isolation: CDN rejects hash not belonging to the requested vers
62016201
// sha256('query { hello }')
62026202
const hashV1 = 'ec2e01311ab3b02f3d8c8c712f9e579356d332cd007ac4c1ea5df727f482f05f';
62036203
// sha256('query { goodbye }')
6204-
const hashV2Only = 'e52e356849e68a88ee16be34b3e7271b1b69bd4e7b6bce69f3e93ae2bb59e15e';
6204+
const hashV2Only = '736bc03162a1aca7327174a6f7f34e5165d2fbc4698ec87ba8f7c0fb7ecf4c9b';
62056205

62066206
// Create v1.0.0 with hashV1
62076207
await execute({
@@ -6428,3 +6428,103 @@ test('v2 deployment with 100% dedup still resolves correctly via CDN', async ()
64286428
expect(response.status).toBe(200);
64296429
expect(await response.text()).toBe('query { hello }');
64306430
});
6431+
6432+
test('v2 deployment with partial dedup resolves both shared and new hashes', async () => {
6433+
const { createOrg } = await initSeed().createOwner();
6434+
const { createProject, setFeatureFlag } = await createOrg();
6435+
await setFeatureFlag('appDeployments', true);
6436+
const { createTargetAccessToken, createCdnAccess } = await createProject();
6437+
const token = await createTargetAccessToken({});
6438+
6439+
await token.publishSchema({
6440+
sdl: /* GraphQL */ `
6441+
type Query {
6442+
hello: String
6443+
goodbye: String
6444+
}
6445+
`,
6446+
});
6447+
6448+
const cdnAccess = await createCdnAccess();
6449+
// sha256('query { hello }')
6450+
const sharedHash = 'ec2e01311ab3b02f3d8c8c712f9e579356d332cd007ac4c1ea5df727f482f05f';
6451+
// sha256('query { goodbye }')
6452+
const newHash = '736bc03162a1aca7327174a6f7f34e5165d2fbc4698ec87ba8f7c0fb7ecf4c9b';
6453+
6454+
// Create v1.0.0 with sharedHash
6455+
await execute({
6456+
document: CreateAppDeployment,
6457+
variables: { input: { appName: 'my-app', appVersion: '1.0.0' } },
6458+
authToken: token.secret,
6459+
}).then(res => res.expectNoGraphQLErrors());
6460+
6461+
await execute({
6462+
document: AddDocumentsToAppDeploymentWithFormat,
6463+
variables: {
6464+
input: {
6465+
appName: 'my-app',
6466+
appVersion: '1.0.0',
6467+
documents: [{ hash: sharedHash, body: 'query { hello }' }],
6468+
format: AppDeploymentFormatType.V2,
6469+
},
6470+
},
6471+
authToken: token.secret,
6472+
}).then(res => res.expectNoGraphQLErrors());
6473+
6474+
await execute({
6475+
document: ActivateAppDeployment,
6476+
variables: { input: { appName: 'my-app', appVersion: '1.0.0' } },
6477+
authToken: token.secret,
6478+
}).then(res => res.expectNoGraphQLErrors());
6479+
6480+
// Create v2.0.0 with both hashes: sharedHash is deduped, newHash is new
6481+
const { createAppDeployment } = await execute({
6482+
document: CreateAppDeploymentWithHashes,
6483+
variables: {
6484+
input: {
6485+
appName: 'my-app',
6486+
appVersion: '2.0.0',
6487+
hashes: [sharedHash, newHash],
6488+
},
6489+
},
6490+
authToken: token.secret,
6491+
}).then(res => res.expectNoGraphQLErrors());
6492+
6493+
expect(createAppDeployment.ok?.existingHashes).toContain(sharedHash);
6494+
expect(createAppDeployment.ok?.existingHashes).not.toContain(newHash);
6495+
6496+
// Upload only the new document
6497+
await execute({
6498+
document: AddDocumentsToAppDeploymentWithFormat,
6499+
variables: {
6500+
input: {
6501+
appName: 'my-app',
6502+
appVersion: '2.0.0',
6503+
documents: [{ hash: newHash, body: 'query { goodbye }' }],
6504+
format: AppDeploymentFormatType.V2,
6505+
},
6506+
},
6507+
authToken: token.secret,
6508+
}).then(res => res.expectNoGraphQLErrors());
6509+
6510+
await execute({
6511+
document: ActivateAppDeployment,
6512+
variables: { input: { appName: 'my-app', appVersion: '2.0.0' } },
6513+
authToken: token.secret,
6514+
}).then(res => res.expectNoGraphQLErrors());
6515+
6516+
// Both hashes should be accessible via v2.0.0 (manifest has all hashes, not just new ones)
6517+
const sharedResponse = await fetch(`${cdnAccess.cdnUrl}/apps/my-app/2.0.0/${sharedHash}`, {
6518+
method: 'GET',
6519+
headers: { 'X-Hive-CDN-Key': cdnAccess.secretAccessToken },
6520+
});
6521+
expect(sharedResponse.status).toBe(200);
6522+
expect(await sharedResponse.text()).toBe('query { hello }');
6523+
6524+
const newResponse = await fetch(`${cdnAccess.cdnUrl}/apps/my-app/2.0.0/${newHash}`, {
6525+
method: 'GET',
6526+
headers: { 'X-Hive-CDN-Key': cdnAccess.secretAccessToken },
6527+
});
6528+
expect(newResponse.status).toBe(200);
6529+
expect(await newResponse.text()).toBe('query { goodbye }');
6530+
});

packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,24 @@ export class AppDeploymentsManager {
100100

101101
const hashes = args.hashes != null && args.hashes.length > 0 ? args.hashes : null;
102102

103-
// Write format to apps-enabled immediately so activation knows the format,
103+
// Write format and hash manifest immediately so activation has them,
104104
// even if all documents are deduped and no upload happens.
105+
// The manifest must contain ALL hashes (not just newly uploaded ones) for version isolation.
105106
if (hashes) {
106-
await this.appDeployments.writeAppDeploymentFormat({
107-
targetId: selector.targetId,
108-
appName: args.appDeployment.name,
109-
appVersion: args.appDeployment.version,
110-
format: 'v2-inactive',
111-
});
107+
await Promise.all([
108+
this.appDeployments.writeAppDeploymentFormat({
109+
targetId: selector.targetId,
110+
appName: args.appDeployment.name,
111+
appVersion: args.appDeployment.version,
112+
format: 'v2-inactive',
113+
}),
114+
this.appDeployments.writeV2HashManifest({
115+
targetId: selector.targetId,
116+
appName: args.appDeployment.name,
117+
appVersion: args.appDeployment.version,
118+
hashes,
119+
}),
120+
]);
112121
}
113122

114123
const existingHashes = hashes

packages/services/api/src/modules/app-deployments/providers/app-deployments.ts

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -453,44 +453,42 @@ export class AppDeployments {
453453
};
454454
}
455455

456-
// Write v2 hash manifest BEFORE the apps-enabled flag to avoid a race condition
457-
// where the CDN sees the deployment as active but the manifest doesn't exist yet.
456+
// Write v2 hash manifest if not already written during createAppDeployment.
457+
// This handles deployments that didn't use the hashes input (legacy flow).
458458
const deploymentFormat = await this.getDeploymentDocumentFormat(appDeployment.id);
459459
if (deploymentFormat === 'v2') {
460-
const hashesResult = await this.clickhouse.query({
461-
query: cSql`
462-
SELECT document_hash AS hash
463-
FROM app_deployment_documents
464-
PREWHERE app_deployment_id = ${appDeployment.id}
465-
`,
466-
queryId: 'get-deployment-hashes-for-manifest',
467-
timeout: 30_000,
468-
});
469-
const manifestHashes = z
470-
.array(z.object({ hash: z.string() }))
471-
.parse(hashesResult.data)
472-
.map(row => row.hash);
473-
474460
const manifestKey = buildV2HashManifestKey(
475461
appDeployment.targetId,
476462
appDeployment.name,
477463
appDeployment.version,
478464
);
479-
const manifestBody = manifestHashes.join('\n');
480-
481-
for (const s3 of this.s3) {
482-
const manifestResult = await s3.client.fetch(
483-
[s3.endpoint, s3.bucket, manifestKey].join('/'),
484-
{
485-
method: 'PUT',
486-
body: manifestBody,
487-
headers: { 'content-type': 'text/plain' },
488-
aws: { signQuery: true },
489-
},
490-
);
491-
if (manifestResult.statusCode !== 200) {
492-
throw new Error(`Failed to write v2 hash manifest: ${manifestResult.statusMessage}`);
493-
}
465+
// Check if manifest already exists (written during createAppDeployment)
466+
const existingManifest = await this.s3[0].client.fetch(
467+
[this.s3[0].endpoint, this.s3[0].bucket, manifestKey].join('/'),
468+
{ method: 'HEAD', aws: { signQuery: true } },
469+
);
470+
if (existingManifest.statusCode !== 200) {
471+
// Manifest doesn't exist, build it from clickhouse (only has uploaded hashes, not deduped)
472+
const hashesResult = await this.clickhouse.query({
473+
query: cSql`
474+
SELECT document_hash AS hash
475+
FROM app_deployment_documents
476+
PREWHERE app_deployment_id = ${appDeployment.id}
477+
`,
478+
queryId: 'get-deployment-hashes-for-manifest',
479+
timeout: 30_000,
480+
});
481+
const manifestHashes = z
482+
.array(z.object({ hash: z.string() }))
483+
.parse(hashesResult.data)
484+
.map(row => row.hash);
485+
486+
await this.writeV2HashManifest({
487+
targetId: appDeployment.targetId,
488+
appName: appDeployment.name,
489+
appVersion: appDeployment.version,
490+
hashes: manifestHashes,
491+
});
494492
}
495493
}
496494

@@ -1729,6 +1727,28 @@ export class AppDeployments {
17291727
}
17301728
}
17311729
}
1730+
1731+
/** Write the full hash manifest for a v2 deployment version to S3 */
1732+
async writeV2HashManifest(args: {
1733+
targetId: string;
1734+
appName: string;
1735+
appVersion: string;
1736+
hashes: readonly string[];
1737+
}) {
1738+
const manifestKey = buildV2HashManifestKey(args.targetId, args.appName, args.appVersion);
1739+
const manifestBody = args.hashes.join('\n');
1740+
for (const s3 of this.s3) {
1741+
const result = await s3.client.fetch([s3.endpoint, s3.bucket, manifestKey].join('/'), {
1742+
method: 'PUT',
1743+
body: manifestBody,
1744+
headers: { 'content-type': 'text/plain' },
1745+
aws: { signQuery: true },
1746+
});
1747+
if (result.statusCode !== 200) {
1748+
throw new Error(`Failed to write v2 hash manifest: ${result.statusMessage}`);
1749+
}
1750+
}
1751+
}
17321752
}
17331753

17341754
const appDeploymentFields = sql`

0 commit comments

Comments
 (0)