diff --git a/src/sync.ts b/src/sync.ts index c708acd..a59bfa3 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -23,6 +23,7 @@ import type { GoogleAddOperationsRequest, GoogleAddOperationsResponse, GoogleDestinationConfig, + GooglePartialFailureError, GoogleUserIdentifier, HashedCustomer, MetaDestinationConfig, @@ -56,6 +57,34 @@ function sleep(ms: number): Promise { }); } +/** + * Count how many operations in a batch were rejected by a Google partial-failure error. + * + * Google packs per-operation errors into `partialFailureError.details[].errors[]`, each carrying a + * `location.fieldPathElements` path like `[{ fieldName: "operations", index: N }, ...]`. We collect + * the distinct `operations` indices (multiple errors can target the same operation) and return that + * count, capped at `batchSize` for safety. Returns 0 when there is no partial failure. + */ +export function countFailedOperations( + partialFailureError: GooglePartialFailureError | undefined, + batchSize: number, +): number { + if (partialFailureError?.details === undefined) { + return 0; + } + const failedIndices = new Set(); + for (const detail of partialFailureError.details) { + for (const err of detail.errors ?? []) { + for (const element of err.location?.fieldPathElements ?? []) { + if (element.fieldName === 'operations' && typeof element.index === 'number') { + failedIndices.add(element.index); + } + } + } + } + return Math.min(failedIndices.size, batchSize); +} + /** Decide whether an error is worth retrying (transient) vs. fatal (client error). */ function isRetryableError(error: unknown): boolean { if (axios.isAxiosError(error)) { @@ -425,9 +454,8 @@ async function uploadToGoogle( let batchesSent = 0; let accepted = 0; - // Google's offline job processes asynchronously server-side; per-record rejections aren't known - // synchronously here (see issue #3), so this stays 0 until partial-failure parsing lands. - const rejected = 0; + // Incremented per batch from parsed partial-failure details (see countFailedOperations). + let rejected = 0; // Dry-run: validate batching + payload shape without creating a job or hitting the network. if (app.dryRun) { @@ -483,16 +511,18 @@ async function uploadToGoogle( ); if (data.partialFailureError !== undefined) { - // Partial failure: some operations rejected. We can't know the exact count without parsing - // the detailed error, so conservatively count the batch as accepted-with-warnings. + // Partial failure: parse the detailed error to count exactly which operations were rejected. + const failed = countFailedOperations(data.partialFailureError, batch.length); + rejected += failed; + accepted += batch.length - failed; log( `[google] add-ops batch ${i + 1}/${batches.length} partial failure — ` + - `${data.partialFailureError.message ?? 'see Google Ads logs'}`, + `${failed}/${batch.length} rejected: ${data.partialFailureError.message ?? 'see Google Ads logs'}`, ); } else { + accepted += batch.length; log(`[google] add-ops batch ${i + 1}/${batches.length} ok (${batch.length} users)`); } - accepted += batch.length; batchesSent += 1; } diff --git a/src/types.ts b/src/types.ts index b189227..36ae377 100644 --- a/src/types.ts +++ b/src/types.ts @@ -135,12 +135,39 @@ export interface GoogleAddOperationsRequest { readonly enablePartialFailure: boolean; } +/** One element of a Google error's field path, e.g. `{ fieldName: "operations", index: 3 }`. */ +export interface GoogleFieldPathElement { + readonly fieldName?: string; + readonly index?: number; +} + +/** Location of a single Google Ads error within the request. */ +export interface GoogleErrorLocation { + readonly fieldPathElements?: readonly GoogleFieldPathElement[]; +} + +/** A single error inside a `GoogleAdsFailure` detail. */ +export interface GoogleAdsError { + readonly location?: GoogleErrorLocation; + readonly message?: string; +} + +/** One `details[]` entry of a partial-failure error (a packed `GoogleAdsFailure`). */ +export interface GooglePartialFailureDetail { + readonly errors?: readonly GoogleAdsError[]; +} + +/** The `partialFailureError` object returned when some operations are rejected. */ +export interface GooglePartialFailureError { + readonly code?: number; + readonly message?: string; + /** Packed `GoogleAdsFailure` payloads carrying per-operation error locations. */ + readonly details?: readonly GooglePartialFailureDetail[]; +} + /** Response from `offlineUserDataJobs:addOperations`. */ export interface GoogleAddOperationsResponse { - readonly partialFailureError?: { - readonly code?: number; - readonly message?: string; - }; + readonly partialFailureError?: GooglePartialFailureError; } /** Response from creating an offline user data job. */ diff --git a/test/sync.test.ts b/test/sync.test.ts new file mode 100644 index 0000000..63bab52 --- /dev/null +++ b/test/sync.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { chunk, countFailedOperations } from '../src/sync.js'; +import type { GooglePartialFailureError } from '../src/types.js'; + +describe('chunk', () => { + it('splits into fixed-size chunks with a smaller final chunk', () => { + expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + it('returns a single chunk when size >= length', () => { + expect(chunk([1, 2, 3], 10)).toEqual([[1, 2, 3]]); + }); + + it('returns no chunks for an empty input', () => { + expect(chunk([], 5)).toEqual([]); + }); + + it('throws on a non-positive size', () => { + expect(() => chunk([1], 0)).toThrow(); + expect(() => chunk([1], -1)).toThrow(); + }); +}); + +describe('countFailedOperations', () => { + it('returns 0 when there is no partial failure', () => { + expect(countFailedOperations(undefined, 100)).toBe(0); + }); + + it('returns 0 when details are absent', () => { + const err: GooglePartialFailureError = { code: 3, message: 'oops' }; + expect(countFailedOperations(err, 100)).toBe(0); + }); + + it('counts distinct rejected operation indices', () => { + const err: GooglePartialFailureError = { + message: 'partial failure', + details: [ + { + errors: [ + { location: { fieldPathElements: [{ fieldName: 'operations', index: 0 }] } }, + { location: { fieldPathElements: [{ fieldName: 'operations', index: 2 }] } }, + ], + }, + ], + }; + expect(countFailedOperations(err, 100)).toBe(2); + }); + + it('deduplicates multiple errors targeting the same operation', () => { + const err: GooglePartialFailureError = { + details: [ + { + errors: [ + { location: { fieldPathElements: [{ fieldName: 'operations', index: 5 }] } }, + { location: { fieldPathElements: [{ fieldName: 'operations', index: 5 }] } }, + ], + }, + ], + }; + expect(countFailedOperations(err, 100)).toBe(1); + }); + + it('ignores field path elements that are not operation indices', () => { + const err: GooglePartialFailureError = { + details: [ + { + errors: [ + { + location: { + fieldPathElements: [ + { fieldName: 'operations', index: 1 }, + { fieldName: 'create' }, + { fieldName: 'user_identifiers', index: 0 }, + ], + }, + }, + ], + }, + ], + }; + expect(countFailedOperations(err, 100)).toBe(1); + }); + + it('caps the count at the batch size', () => { + const err: GooglePartialFailureError = { + details: [ + { + errors: [ + { location: { fieldPathElements: [{ fieldName: 'operations', index: 0 }] } }, + { location: { fieldPathElements: [{ fieldName: 'operations', index: 1 }] } }, + { location: { fieldPathElements: [{ fieldName: 'operations', index: 2 }] } }, + ], + }, + ], + }; + expect(countFailedOperations(err, 2)).toBe(2); + }); +});