Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@ flowchart TD

Cross-year-matched rows are never sent to the ODS — they're only made available through the Runway app's API, which EDU and other external consumers query.

### Roster sources & no-ODS year selectability

A roster is the student lookup the executor matches input rows against. Source precedence:

1. **ODS** — for `sendToOds` years, the executor fetches the roster from the ODS API.
2. **EDU** — the cross-year roster from EDU (Snowflake), pulled via `appUrls.roster` as NDJSON when `crossYearMatchAvailable`. Two roles: for ODS years, it's the second-pass match for IDs that didn't match the ODS roster (see Cross-Year Matching Flow — those rows are never sent to the ODS); for no-ODS (`sendToOds=false`) years, it's the roster source, preferred over the S3 file (the executor handles this preference).
3. **S3 roster file** — the fallback for no-ODS years when cross-year matching is unavailable (`__rosters/...jsonl`). The app omits `rosterFilePath` from the payload when `crossYearMatchAvailable` is true (it would be a dangling pointer).

A no-ODS year is **selectable** at job creation, and shows **green** ("roster available") on the ODS-config page, when a roster file exists **OR** the partner has cross-year matching enabled. The executor payload's `crossYearMatchAvailable` is the same partner setting (`crossYearMatchingEnabled`) — there is no creds/connection check at run-prep time. The admin enable endpoint requires working EDU creds to turn the toggle on; once on, the EDU connection is an assumed dependency like postgres or S3: if EDU is unavailable mid-run, the run fails loudly at roster-fetch time rather than silently degrading to weaker matching. (In practice a tenant has either a roster file or an EDU connection, not both, so there is no fallback to preserve.)

### S3 Path Structure

```
Expand Down
43 changes: 19 additions & 24 deletions app/api/integration/tests/earthbeam-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,31 +107,17 @@ describe('Earthbeam API', () => {
});

describe('cross-year ID matching', () => {
// Default state per test: both gates ON (toggle enabled + creds present)
// so the happy path requires no overrides and each negative test reads
// as "remove one condition, expect the flag to flip false."
let getInfoSpy: jest.SpyInstance;

// crossYearMatchAvailable mirrors the partner toggle alone — there is no
// creds/connection check at run-prep time. EDU problems surface as loud
// run failures at roster-fetch time instead of silent degradation.
beforeEach(async () => {
await global.prisma.partner.update({
where: { id: partnerA.id },
data: { crossYearMatchingEnabled: true },
});
const configService = app.get(AppConfigService);
getInfoSpy = jest.spyOn(configService, 'getEduConnectionInfo').mockResolvedValue({
username: 'snowflake-user',
account: 'example',
database: 'edu_stg',
schema: 'public',
privateKey: Buffer.from('priv'),
});
});

afterEach(() => {
getInfoSpy.mockRestore();
});

it('sets crossYearMatchAvailable=true and emits appUrls.roster when toggle on and creds exist', async () => {
it('sets crossYearMatchAvailable=true and emits appUrls.roster when the partner toggle is on', async () => {
const res = await request(app.getHttpServer())
.get(endpointA)
.set('Authorization', `Bearer ${tokenA}`);
Expand All @@ -157,16 +143,25 @@ describe('Earthbeam API', () => {
expect(res.body.appUrls.roster).toBeUndefined();
});

it('sets crossYearMatchAvailable=false and omits appUrls.roster when creds are missing', async () => {
getInfoSpy.mockResolvedValue(null);
it('omits rosterFilePath on a no-ODS job when cross-year matching is available (EDU is the source)', async () => {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diff here is funky. Test on the left is removed, test on the right is added -- they're different tests, not an update to the same test.

const authService = app.get(EarthbeamApiAuthService);
const noOdsJob = await seedJob({
sendToOds: false,
schoolYearId: '2324',
bundle: bundleA,
tenant: tenantA,
});
const noOdsRun = noOdsJob.runs[0];
const noOdsToken = await authService.createAccessToken({ runId: noOdsRun.id });

const res = await request(app.getHttpServer())
.get(endpointA)
.set('Authorization', `Bearer ${tokenA}`);
.get(`/earthbeam/jobs/${noOdsRun.id}`)
.set('Authorization', `Bearer ${noOdsToken}`);

expect(res.status).toBe(200);
expect(res.body.crossYearMatchAvailable).toBe(false);
expect(res.body.appUrls.roster).toBeUndefined();
expect(res.body.crossYearMatchAvailable).toBe(true);
expect(res.body.appUrls.roster).toBeDefined();
expect(res.body.rosterFilePath).toBeUndefined();
});
});

Expand Down
41 changes: 40 additions & 1 deletion app/api/integration/tests/external-api.v1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,43 @@ describe('ExternalApiV1', () => {
expect(job?.odsId).toBeNull();
expect(job?.sendToOds).toBe(false);
});

it('should create a no-ODS job without a roster file when cross-year matching is enabled', async () => {
await prisma.schoolYearConfig.create({
data: {
partnerId: partnerA.id,
schoolYearId: '2324',
isEnabled: true,
sendToOds: false,
},
});
await prisma.partner.update({
where: { id: partnerA.id },
data: { crossYearMatchingEnabled: true },
});

const doesFileExistMock = app.get(FileService).doesFileExist as jest.Mock;
doesFileExistMock.mockResolvedValue(false);

try {
const res = await request(app.getHttpServer())
.post(endpoint)
.set('Authorization', `Bearer ${token}`)
.send({
...jobInput,
schoolYear: '2024',
});

expect(res.status).toBe(201);

const job = await prisma.job.findUnique({ where: { uid: res.body.uid } });
expect(job?.odsId).toBeNull();
expect(job?.sendToOds).toBe(false);
} finally {
// No partner reset needed — seed data is refreshed before each test
doesFileExistMock.mockResolvedValue(true);
}
});
});

describe('API Client Info', () => {
Expand Down Expand Up @@ -537,7 +574,9 @@ describe('ExternalApiV1', () => {
});

expect(res.status).toBe(400);
expect(res.body.message).toContain('No roster file found');
expect(res.body.message).toContain(
'No roster file found and cross-year matching not enabled'
);
} finally {
doesFileExistMock.mockResolvedValue(true);
}
Expand Down
43 changes: 42 additions & 1 deletion app/api/integration/tests/jobs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ describe('POST /jobs', () => {
expect(job?.sendToOds).toBe(false);
});

// Partner A defaults to crossYearMatchingEnabled=false, so this also
// guards the "cross-year matching disabled" rejection path.
it('should reject no-ODS jobs when the roster file does not exist', async () => {
await prisma.schoolYearConfig.create({
data: {
Expand All @@ -366,7 +368,9 @@ describe('POST /jobs', () => {
});

expect(res.status).toBe(400);
expect(res.body.message).toContain('No roster file found');
expect(res.body.message).toContain(
'No roster file found and cross-year matching not enabled'
);
} finally {
doesFileExistMock.mockResolvedValue(true);
}
Expand Down Expand Up @@ -400,6 +404,43 @@ describe('POST /jobs', () => {
}
});

it('should create a no-ODS job without a roster file when cross-year matching is enabled', async () => {
await prisma.schoolYearConfig.create({
data: {
partnerId: tenantA.partnerId,
schoolYearId: '2324',
isEnabled: true,
sendToOds: false,
},
});
await prisma.partner.update({
where: { id: tenantA.partnerId },
data: { crossYearMatchingEnabled: true },
});

const doesFileExistMock = app.get(FileService).doesFileExist as jest.Mock;
doesFileExistMock.mockResolvedValue(false);

try {
const res = await request(app.getHttpServer())
.post(endpoint)
.set('Cookie', [sessionA.cookie])
.send({
...postJobDto,
schoolYearId: '2324',
});

expect(res.status).toBe(201);

const job = await prisma.job.findUnique({ where: { id: res.body.id } });
expect(job?.odsId).toBeNull();
expect(job?.sendToOds).toBe(false);
} finally {
// No partner reset needed — seed data is refreshed before each test
doesFileExistMock.mockResolvedValue(true);
}
});

it('should reject requests when the school year is not enabled', async () => {
const res = await request(app.getHttpServer())
.post(endpoint)
Expand Down
39 changes: 32 additions & 7 deletions app/api/integration/tests/school-year-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,22 +286,22 @@ describe('GET /school-year-config/tenant', () => {
expect(yearIds).toContain('2526');
expect(yearIds).not.toContain('2324');

// 2425: sendToOds=true → hasRoster is null (no S3 check), hasOds from tenant's ODS config
// 2425: sendToOds=true → hasNonOdsRoster is null (no S3 check), hasOds from tenant's ODS config
const row2425 = res.body.find((r: any) => r.schoolYearId === '2425');
expect(row2425).toMatchObject({
sendToOds: true,
hasOds: true,
hasRoster: null,
hasNonOdsRoster: null,
startYear: 2024,
endYear: 2025,
});

// 2526: sendToOds=false → hasRoster checked via S3, hasOds still reflects ODS config existence
// 2526: sendToOds=false → hasNonOdsRoster checked via S3, hasOds still reflects ODS config existence
const row2526 = res.body.find((r: any) => r.schoolYearId === '2526');
expect(row2526).toMatchObject({
sendToOds: false,
hasOds: true,
hasRoster: true,
hasNonOdsRoster: true,
startYear: 2025,
endYear: 2026,
});
Expand Down Expand Up @@ -340,19 +340,44 @@ describe('GET /school-year-config/tenant', () => {
expect(row2526.hasOds).toBe(true);
});

it('should return hasRoster=false when roster file does not exist', async () => {
it('should return hasNonOdsRoster=false when roster file does not exist and cross-year matching is not enabled', async () => {
const doesFileExistMock = app.get(FileService).doesFileExist as jest.Mock;
doesFileExistMock.mockResolvedValue(false);

try {
const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [cookieA]);

// 2526 is seeded as sendToOds=false, so hasRoster reflects the S3 check
// 2526 is seeded as sendToOds=false and partner A defaults to
// crossYearMatchingEnabled=false, so hasNonOdsRoster reflects the S3 check
const row2526 = res.body.find((r: any) => r.schoolYearId === '2526');
expect(row2526.hasRoster).toBe(false);
expect(row2526.hasNonOdsRoster).toBe(false);
} finally {
doesFileExistMock.mockResolvedValue(true);
}
});

it('should return hasNonOdsRoster=true for a no-ODS year when cross-year matching is enabled, even with no roster file', async () => {
const doesFileExistMock = app.get(FileService).doesFileExist as jest.Mock;
doesFileExistMock.mockResolvedValue(false);
await global.prisma.partner.update({
where: { id: partnerA.id },
data: { crossYearMatchingEnabled: true },
});

try {
const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [cookieA]);

// 2526 is sendToOds=false; toggle on → hasNonOdsRoster true even with no file
const row2526 = res.body.find((r: any) => r.schoolYearId === '2526');
expect(row2526.hasNonOdsRoster).toBe(true);

// ODS years stay null regardless of the toggle
const row2425 = res.body.find((r: any) => r.schoolYearId === '2425');
expect(row2425.hasNonOdsRoster).toBeNull();
} finally {
// No partner reset needed — seed data is refreshed before each test
doesFileExistMock.mockResolvedValue(true);
}
});
});
});
24 changes: 15 additions & 9 deletions app/api/src/earthbeam/api/earthbeam-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
EventEmitterService,
EVENT_EMITTER_SERVICE,
} from 'api/src/event-emitter/event-emitter.service';
import { EduSnowflakePoolService } from './edu-snowflake-pool.service';

@Injectable()
export class EarthbeamApiService {
Expand All @@ -41,8 +40,7 @@ export class EarthbeamApiService {
private readonly encryptionService: EncryptionService,
private readonly fileService: FileService,
private readonly configService: AppConfigService,
@Inject(EVENT_EMITTER_SERVICE) private readonly eventEmitter: EventEmitterService,
private readonly eduPool: EduSnowflakePoolService
@Inject(EVENT_EMITTER_SERVICE) private readonly eventEmitter: EventEmitterService
) {}

async earthbeamInputForRun(runId: Run['id']) {
Expand Down Expand Up @@ -143,9 +141,12 @@ export class EarthbeamApiService {

const executorBaseUrl = this.configService.executorCallbackBaseUrl();

const partnerId = job.tenant.partnerId;
const crossYearEnabled = job.tenant.partner.crossYearMatchingEnabled;
const crossYearMatchAvailable = crossYearEnabled && (await this.eduPool.canConnect(partnerId));
// The partner toggle alone — no creds/connection check. Once the toggle is
// on (the admin enable endpoint requires working creds to turn it on), the
// EDU connection is an assumed dependency like postgres or S3: if EDU is
// unavailable mid-run, the run fails loudly rather than silently degrading
// to weaker matching.
const crossYearMatchAvailable = job.tenant.partner.crossYearMatchingEnabled;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once crossYearMatchingEnabled is set, the EDU connection is just a dependency that we assume is in place, like the postgres connection, ability to talk to S3, etc.

Super helpful framing for reasoning about the behavior throughout the app and executor.


const payload: EarthbeamApiJobResponseDto = {
appDataBasePath: `${job.fileProtocol}://${job.fileBucketOrHost}/${job.fileBasePath}`,
Expand All @@ -171,9 +172,14 @@ export class EarthbeamApiService {
},
crossYearMatchAvailable,
sendToOds: job.sendToOds,
rosterFilePath: job.sendToOds
? undefined
: `s3://${this.configService.rosterBucket()}/${rosterFileKey(job, job.schoolYear)}`,
// When cross-year matching is available, the executor pulls the roster
// from EDU via appUrls.roster, so the S3 file path would be a dangling
// (often nonexistent) pointer — omit it. The executor only reads
// rosterFilePath in its non-cross-year branch.
rosterFilePath:
job.sendToOds || crossYearMatchAvailable
? undefined
: `s3://${this.configService.rosterBucket()}/${rosterFileKey(job, job.schoolYear)}`,
// odsConnection check narrows the type — the early guard ensures it's present when sendToOds
assessmentDatastore:
odsConnection && job.sendToOds
Expand Down
2 changes: 1 addition & 1 deletion app/api/src/external-api/v1/jobs.v1.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class ExternalApiV1JobsController {
school_year_config_missing: `School year is not enabled: ${year}`,
school_year_disabled: `School year is not enabled: ${year}`,
ods_not_found: `No ODS found for school year: ${year}`,
roster_file_missing: `No roster file found for school year: ${year}`,
roster_unavailable: `No roster file found and cross-year matching not enabled for school year: ${year}`,
};
throw new BadRequestException(messages[destination.code]);
}
Expand Down
2 changes: 1 addition & 1 deletion app/api/src/jobs/jobs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export class JobsController {
school_year_config_missing: `School year is not enabled: ${year}`,
school_year_disabled: `School year is not enabled: ${year}`,
ods_not_found: `No ODS found for school year: ${year}`,
roster_file_missing: `No roster file found for school year: ${year}`,
roster_unavailable: `No roster file found and cross-year matching not enabled for school year: ${year}`,
};
throw new BadRequestException(messages[destination.code]);
}
Expand Down
32 changes: 26 additions & 6 deletions app/api/src/jobs/jobs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class JobsService {
| 'school_year_config_missing'
| 'school_year_disabled'
| 'ods_not_found'
| 'roster_file_missing';
| 'roster_unavailable';
}
> {
const config = await this.prisma.schoolYearConfig.findUnique({
Expand Down Expand Up @@ -95,10 +95,31 @@ export class JobsService {
}

if (!config.sendToOds) {
const rosterKey = rosterFileKey({ partnerId: input.tenant.partnerId, tenantCode: input.tenant.code }, config.schoolYear);
const rosterExists = await this.fileService.doesFileExist(rosterKey, this.appConfig.rosterBucket());
if (!rosterExists) {
return { status: 'error', code: 'roster_file_missing' };
// A no-ODS year is valid if a roster file exists OR the partner has
// cross-year matching enabled (EDU can supply the roster).
// Short-circuit the S3 check when the toggle is on; we don't need the file.
const partner = await this.prisma.partner.findUnique({
where: { id: input.tenant.partnerId },
select: { crossYearMatchingEnabled: true },
});
if (!partner) {
// Every tenant has a partner (FK) — a missing one is an invariant
// violation, not a "cross-year disabled" case. Don't proceed.
throw new Error(`Partner not found: ${input.tenant.partnerId}`);
}
Comment on lines +105 to +109
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the philosophy of this kind of check. Is it that the partner could have been deleted after the payload was validated initially in human/airflow initialize()? Is it just type narrowing? If the former, I guess it seems unlikely to me that that would happen, if the latter then the intentionality of the error message throws (😆) me off a bit and maybe it would be easier to use the OrThrow() method.

I know I once commented on a PR in this repo that I was trying to move away from the OrThrow() methods for a particular pattern. On the off chance that that has anything to do with this, I just want to clarify that the thing I want to be less sloppy about in UM is catching those exceptions and always returning a 404, regardless of the reason for the exception.


if (!partner.crossYearMatchingEnabled) {
const rosterKey = rosterFileKey(
{ partnerId: input.tenant.partnerId, tenantCode: input.tenant.code },
config.schoolYear
);
const rosterExists = await this.fileService.doesFileExist(
rosterKey,
this.appConfig.rosterBucket()
);
if (!rosterExists) {
return { status: 'error', code: 'roster_unavailable' };
}
}

return {
Expand Down Expand Up @@ -412,5 +433,4 @@ export class JobsService {

return { result: 'JOB_STARTED', job, run };
}

}
Loading