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
44 changes: 37 additions & 7 deletions src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
GoogleAddOperationsRequest,
GoogleAddOperationsResponse,
GoogleDestinationConfig,
GooglePartialFailureError,
GoogleUserIdentifier,
HashedCustomer,
MetaDestinationConfig,
Expand Down Expand Up @@ -56,6 +57,34 @@ function sleep(ms: number): Promise<void> {
});
}

/**
* 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<number>();
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)) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down
35 changes: 31 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
99 changes: 99 additions & 0 deletions test/sync.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading