-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: integrate aggregation client #22
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
import * as Sentry from '@sentry/serverless' | ||
import { unmarshall } from '@aws-sdk/util-dynamodb' | ||
import { Config } from '@serverless-stack/node/config/index.js' | ||
|
||
import { setFerryOffer } from '../lib/index.js' | ||
import { parseDynamoDbEvent } from '../utils/parse-dynamodb-event.js' | ||
import { mustGetEnv, getAggregationServiceConnection } from '../lib/utils.js' | ||
|
||
Sentry.AWSLambda.init({ | ||
environment: process.env.SST_STAGE, | ||
dsn: process.env.SENTRY_DSN, | ||
tracesSampleRate: 1.0, | ||
}) | ||
|
||
const AWS_REGION = mustGetEnv('AWS_REGION') | ||
|
||
/** | ||
* @param {import('aws-lambda').DynamoDBStreamEvent} event | ||
*/ | ||
async function handler(event) { | ||
const { | ||
CAR_TABLE_NAME, | ||
CARGO_TABLE_NAME, | ||
FERRY_TABLE_NAME, | ||
DID, | ||
AGGREGATION_SERVICE_DID, | ||
AGGREGATION_SERVICE_URL, | ||
} = getEnv() | ||
const { PRIVATE_KEY } = Config | ||
|
||
const records = parseDynamoDbEvent(event) | ||
if (records.length > 1) { | ||
throw new Error('Should only receive one ferry to update') | ||
} | ||
|
||
// @ts-expect-error can't figure out type of new | ||
const newRecord = unmarshall(records[0].new) | ||
|
||
const ctx = { | ||
car: { | ||
region: AWS_REGION, | ||
tableName: CAR_TABLE_NAME | ||
}, | ||
ferry: { | ||
region: AWS_REGION, | ||
tableName: FERRY_TABLE_NAME, | ||
options: { | ||
cargoTableName: CARGO_TABLE_NAME | ||
} | ||
}, | ||
storefront: { | ||
DID, | ||
PRIVATE_KEY | ||
}, | ||
aggregationServiceConnection: getAggregationServiceConnection({ | ||
DID: AGGREGATION_SERVICE_DID, | ||
URL: AGGREGATION_SERVICE_URL | ||
}) | ||
} | ||
await setFerryOffer(newRecord.id, ctx) | ||
} | ||
|
||
export const consumer = Sentry.AWSLambda.wrapHandler(handler) | ||
|
||
/** | ||
* Get Env validating it is set. | ||
*/ | ||
function getEnv() { | ||
return { | ||
DID: mustGetEnv('DID'), | ||
CAR_TABLE_NAME: mustGetEnv('CAR_TABLE_NAME'), | ||
CARGO_TABLE_NAME: mustGetEnv('CARGO_TABLE_NAME'), | ||
FERRY_TABLE_NAME: mustGetEnv('FERRY_TABLE_NAME'), | ||
AGGREGATION_SERVICE_DID: mustGetEnv('AGGREGATION_SERVICE_DID'), | ||
AGGREGATION_SERVICE_URL: mustGetEnv('AGGREGATION_SERVICE_URL'), | ||
} | ||
} |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import * as ed25519 from '@ucanto/principal/ed25519' | ||
import * as DID from '@ipld/dag-ucan/did' | ||
import { Aggregate } from '@web3-storage/aggregate-client' | ||
|
||
/** | ||
* @param {import('../types').StorefrontSignerCtx} serviceSignerCtx | ||
* @param {ed25519.ConnectionView<any>} aggregationServiceConnection | ||
*/ | ||
export async function createAggregateService (serviceSignerCtx, aggregationServiceConnection) { | ||
const issuer = getStorefrontSigner(serviceSignerCtx) | ||
const audience = aggregationServiceConnection.id | ||
|
||
/** @type {import('@web3-storage/aggregate-client/types').InvocationConfig} */ | ||
const InvocationConfig = { | ||
issuer, | ||
audience, | ||
with: issuer.did(), | ||
} | ||
|
||
return { | ||
/** | ||
* | ||
* @param {import('@web3-storage/aggregate-client/types').Offer[]} offers | ||
*/ | ||
offer: async function (offers) { | ||
return await Aggregate.aggregateOffer( | ||
InvocationConfig, | ||
offers, | ||
{ connection: aggregationServiceConnection } | ||
) | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* @param {import('../types').StorefrontSignerCtx} config | ||
*/ | ||
function getStorefrontSigner(config) { | ||
const signer = ed25519.parse(config.PRIVATE_KEY) | ||
if (config.DID) { | ||
const did = DID.parse(config.DID).did() | ||
return signer.withDID(did) | ||
} | ||
return signer | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
import { pipe } from 'it-pipe' | ||
import { batch } from 'streaming-iterables' | ||
import { CID } from 'multiformats/cid' | ||
|
||
import { MAX_BATCH_GET_ITEMS } from '../tables/constants.js' | ||
import { createCarTable } from '../tables/car.js' | ||
import { createFerryTable } from '../tables/ferry.js' | ||
import { createAggregateService } from './aggregate-service.js' | ||
|
||
/** | ||
* @typedef {import('../types.js').FerryOpts} FerryOpts | ||
* @typedef {import('../types.js').CarItem} CarItem | ||
* @typedef {import('../types.js').CarItemFerry} CarItemFerry | ||
* | ||
* @typedef {object} FerryCtx | ||
* @property {string} region | ||
* @property {string} tableName | ||
* @property {FerryOpts} [options] | ||
* | ||
* @typedef {object} CarTableCtx | ||
* @property {string} region | ||
* @property {string} tableName | ||
* @property {import('../types.js').CarOpts} [options] | ||
*/ | ||
|
||
/** | ||
* Add cars to a loading ferry. | ||
* | ||
* @param {CarItemFerry[]} cars | ||
* @param {FerryCtx} ferryCtx | ||
*/ | ||
export async function addCarsToFerry (cars, ferryCtx) { | ||
const ferryTable = createFerryTable(ferryCtx.region, ferryCtx.tableName, ferryCtx.options) | ||
|
||
const ferryId = await ferryTable.getFerryLoading() | ||
await ferryTable.addCargo(ferryId, cars) | ||
Comment on lines
+35
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm struggling to keep up with all that is going on in the PR and maybe I'm also tired so I apologize if some of my comments aren't on point. Here specifically doing async read and then async write makes me uncomfortable without full picture in my head. Specifically I worry of the race condition because ferryID could change on concurrent calls and I'm not sure of the consequences. Perhaps code comment addressing concurrency concern is all that's needed here. With that said I personally would go for design (which may not be applicable here) where writer simply appends to the queue (well known ferry id) and something else than moves things off the batch from queue into actual ferry. Such approach tends fare better given that no writes depend on read state that can change in the meantime. Here we deal with tables however so things may not exactly apply. That said in relational dbs it's not uncommon to do similar approach where in one table you simply record things and in the other you record which record from first table is in which group. That also ends up doing appends as instead of updates and consequently avoiding coordination overhead. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Appreciate the concerns. So, all dynamoDB stream consumers (the case of this) should only have one mutation to avoid partial failure. You are right that we need to be careful with read + write, and that is where the DynamoDB So, let's go into this case:
First important to look into the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Code comment would of course help out, but is also difficult to specify where is the borderline of adding comments or not. Table abstraction code already covers these conditions, so also adding these code comments in callers would make us need to maintain a lot of duplicated documentation. |
||
|
||
return { | ||
id: ferryId | ||
} | ||
} | ||
|
||
/** | ||
* Sets current Ferry as ready if not previously done | ||
* | ||
* @param {string} ferryId | ||
* @param {FerryCtx} ferryCtx | ||
*/ | ||
export async function setFerryAsReady (ferryId, ferryCtx) { | ||
const ferryTable = createFerryTable(ferryCtx.region, ferryCtx.tableName, ferryCtx.options) | ||
|
||
// Update state of ferry to ready | ||
try { | ||
await ferryTable.setAsReady(ferryId) | ||
} catch (/** @type {any} */ error) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we please catch this error in the Ideally all the IO ops should return There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Therefore, the consumer of this (lambda function calling this lib function |
||
// If error is for condition we can safely ignore it given this was changed in a concurrent operation | ||
if (error.name !== 'ConditionalCheckFailedException') { | ||
throw error | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Sets current Ferry offer. | ||
* | ||
* @param {string} ferryId | ||
* @param {object} ctx | ||
* @param {FerryCtx} ctx.car | ||
* @param {FerryCtx} ctx.ferry | ||
* @param {import('../types.js').StorefrontSignerCtx} ctx.storefront | ||
* @param {import('@ucanto/principal/ed25519').ConnectionView<any>} ctx.aggregationServiceConnection | ||
*/ | ||
export async function setFerryOffer (ferryId, ctx) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that how this is implemented, there are guarantees that if (sadly is possible) dynamoDB stream handler is called multiple times for same thing, that two different offers won't be created. |
||
const carTable = createCarTable(ctx.car.region, ctx.car.tableName, ctx.car.options) | ||
const ferryTable = createFerryTable(ctx.ferry.region, ctx.ferry.tableName, ctx.ferry.options) | ||
const aggregateService = await createAggregateService(ctx.storefront, ctx.aggregationServiceConnection) | ||
|
||
// Given ferry is ready to get unloaded to invoke `aggregate/offer`. | ||
// Ferry cargo (reference to CARs in ferry) is read via async iterable | ||
// followed by getting the CAR information of each cargo item in batches. | ||
// Batches are limited by DynamoDB maximum batch sizes. | ||
/** @type {CarItem[]} */ | ||
const offers = await pipe( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needed to get CAR details of all the cargo in ferry to include in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you please add some code comments describing what's going on here. It have read through this block several times before I got what's going on. In particular as reader I don't exactly know what referenced tables hold and how they relate, so I need to build up that context before I can comprehend what is going on. Ideally there will be a code comment providing that context. E.g. Here we pull CARs that were batched into "ferry" with a given id so we can create an aggregate offer for it.... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I will add the comment for this. There are comments in |
||
ferryTable.getCargo(ferryId, { limit: MAX_BATCH_GET_ITEMS }), | ||
batch(MAX_BATCH_GET_ITEMS), | ||
/** | ||
* @param {AsyncGenerator<CarItemFerry[], any, unknown> | Generator<CarItemFerry[], any, unknown>} source | ||
*/ | ||
// @ts-expect-error type not inferred | ||
async function (source) { | ||
/** @type {CarItemFerry[]} */ | ||
const cars = [] | ||
for await (const items of source) { | ||
const pageCars = await carTable.batchGet(items) | ||
for (const car of pageCars) { | ||
cars.push(car) | ||
} | ||
} | ||
|
||
return cars | ||
} | ||
) | ||
|
||
// Send offer | ||
const nOffers = offers.map(offer => ({ | ||
...offer, | ||
link: CID.parse(offer.link) | ||
})) | ||
// @ts-expect-error CID versions | ||
await aggregateService.offer(nOffers) | ||
|
||
// Update state of ferry to ready | ||
try { | ||
await ferryTable.setAsDealPending(ferryId) | ||
} catch (/** @type {any} */ error) { | ||
// If error is for condition we can safely ignore it given this was changed in a concurrent operation | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like I would argue that either:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same answer as above. We can add return types to make it easier if that is helpful. Would like to see opinion from @alanshaw given this code was just moved around to individual file but we already covered it before in previous PRs |
||
if (error.name !== 'ConditionalCheckFailedException') { | ||
throw error | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Sets current Ferry as deal processed | ||
* | ||
* @param {string} ferryId | ||
* @param {string} commP | ||
* @param {FerryCtx} ferryCtx | ||
*/ | ||
export async function setFerryAsProcessed (ferryId, commP, ferryCtx) { | ||
const ferryTable = createFerryTable(ferryCtx.region, ferryCtx.tableName, ferryCtx.options) | ||
|
||
// Update state of ferry to deal processed | ||
try { | ||
await ferryTable.setAsDealProcessed(ferryId, commP) | ||
} catch (/** @type {any} */ error) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same sentiment as with |
||
// If error is for condition we can safely ignore it given this was changed in a concurrent operation | ||
if (error.name !== 'ConditionalCheckFailedException') { | ||
throw error | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am bit confused isn't this supposed to implement aggregate service API ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR web3-storage/dealer#2 implementes aggregate service API. w3filecoin uses the client to create offers, in other words this side is the Storefront