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
63 changes: 35 additions & 28 deletions apps/web/tests/tasks-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
openWorkspace,
} from "@luke/database/testing";
import { BOARD_ELEMENT_TYPE } from "@luke/features/board-vocabulary";
import { boardAnswerSchema } from "@luke/features/board-wire";
import { boardAnswerSchema, boardSaveAnswerSchema } from "@luke/features/board-wire";
import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire";
import { PAGE_SHOWER } from "@luke/features/page-wire";
import { DEFAULT_PLAN_TEMPLATE } from "@luke/features/plan-document-template";
Expand Down Expand Up @@ -1566,34 +1566,41 @@ it.layer(getTestDB())("the plan routes", (it) => {
}),
);

it.effect("the board one member saves is read by the rest of the workspace", () =>
Effect.gen(function* () {
const { owner, other, ask } = yield* openAccounts();
const started = readAnswer(
taskAnswerSchema,
HOSTED_HTTP_STATUS.CREATED,
yield* ask(request(TASKS, owner, { method: "POST", body: RELAY })),
).plan;
yield* joinWorkspace(started.workspaceId, other);

const saved = yield* ask(
request(BOARD(started.id), other, {
method: "PUT",

body: { elements: [NOTE], appliedDrawing: 0 },
}),
);
it.effect(
"the board one member saves is read by the rest of the workspace, the save answering its transaction where the board's live row moved and none where it did not",
() =>
Effect.gen(function* () {
const { owner, other, ask } = yield* openAccounts();
const started = readAnswer(
taskAnswerSchema,
HOSTED_HTTP_STATUS.CREATED,
yield* ask(request(TASKS, owner, { method: "POST", body: RELAY })),
).plan;
yield* joinWorkspace(started.workspaceId, other);
const save = () =>
Effect.map(
ask(
request(BOARD(started.id), other, {
method: "PUT",
body: { elements: [NOTE], appliedDrawing: 0 },
}),
),
(answer) => readAnswer(boardSaveAnswerSchema, HOSTED_HTTP_STATUS.OK, answer),
);

assert.equal(saved.status, HOSTED_HTTP_STATUS.OK);
assert.deepEqual(
readAnswer(
boardAnswerSchema,
HOSTED_HTTP_STATUS.OK,
yield* ask(request(BOARD(started.id), owner)),
).board.elements,
[NOTE],
);
}),
const saved = yield* save();
assert.deepEqual(saved.board.elements, [NOTE]);
assert.match(saved.txid ?? "", /^\d+$/, "the board's first save adds its live row");
assert.equal((yield* save()).txid, undefined, "the same scene again moves nothing");
assert.deepEqual(
readAnswer(
boardAnswerSchema,
HOSTED_HTTP_STATUS.OK,
yield* ask(request(BOARD(started.id), owner)),
).board.elements,
[NOTE],
);
}),
);

it.effect(
Expand Down
39 changes: 39 additions & 0 deletions packages/database/src/models/__tests__/board-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { createTask } from "../task-store.js";
* outside the workspace reads none, nor does a member who does not reach
* the repository of a task on its owner's own GitHub token.
*
* A scene's write answers the id of the transaction that wrote it where it
* moved that row, and none where the row stands as it stood.
*
* Synthetic accounts, drawings, and a stand-in for the image throughout.
*/

Expand Down Expand Up @@ -93,4 +96,40 @@ it.layer(getTestDB())("a board's live statement", (it) => {
assert.deepEqual(yield* liveRows(teammate, task.id), []);
}),
);

it.effect(
"a scene's write answers its committed transaction where it moves the live row, and none where it leaves the row as it stood",
() =>
Effect.gen(function* () {
const owner = yield* openAccount;
const workspace = yield* openWorkspace(owner);
const task = yield* createTask(owner, { organizationId: workspace, name: "Invitations" });
const sql = yield* SqlClient.SqlClient;
const statusOf = (txid: string) =>
Effect.map(
sql<{ readonly status: string }>`select pg_xact_status(${txid}::xid8) as status`,
([row]) => row?.status,
);

const first = Option.getOrThrow(yield* writeScene(owner, task.id, SCENE, 0));
assert.ok(first.txid !== undefined, "the board's first write adds its live row");
assert.match(first.txid, /^\d+$/);
assert.equal(yield* statusOf(first.txid), "committed");

const moved = [
{ id: "api", type: BOARD_ELEMENT_TYPE.RECTANGLE, x: 40, y: 0, width: 200, height: 80 },
];
const unmoved = Option.getOrThrow(yield* writeScene(owner, task.id, moved, 0));
assert.deepEqual(unmoved, { board: { elements: moved, appliedDrawing: 0 } });

const same = Option.getOrThrow(yield* writeScene(owner, task.id, moved, 0));
assert.equal(same.txid, undefined, "a save that changes nothing answers none");

yield* writeDrawing(owner, task.id, BOX);
const applied = Option.getOrThrow(yield* writeScene(owner, task.id, moved, 1, IMAGE));
assert.ok(applied.txid !== undefined, "holding the drawing and its image moves the row");
assert.ok(BigInt(applied.txid) > BigInt(first.txid));
assert.equal(yield* statusOf(applied.txid), "committed");
}),
);
});
103 changes: 81 additions & 22 deletions packages/database/src/models/board-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ const BoardRowSchema = Schema.Struct({

type BoardRow = typeof BoardRowSchema.Type;

/** A scene's row as its write returns it: the board, whether it holds an image, and the id of the transaction that wrote it. */
const SavedSceneRowSchema = Schema.Struct({
...BoardRowSchema.fields,
imaged: Schema.Boolean,
txid: Schema.String,
});

/** The id of the transaction a write runs in, read in the write's own `RETURNING`, as text so its 64 bits stay exact. */
const TXID = sql<string>`pg_current_xact_id()::text`;

const BOARD_COLUMNS = {
elements: taskBoard.elements,
appliedDrawing: taskBoard.appliedDrawing,
Expand All @@ -74,17 +84,37 @@ const findBoard = SqlSchema.findOneOption({
),
});

/** The task's id and whether it is archived, under its row's lock until the transaction ends; nothing where the account reaches no such task. */
/** Where the board's live row (`boardLiveStatement`) stands: the drawing the scene holds and whether it holds its image, each null before the board was first written. */
const LiveMarkSchema = Schema.Struct({
appliedDrawing: Schema.NullOr(Schema.Int),
imaged: Schema.NullOr(Schema.Boolean),
});

type LiveMark = typeof LiveMarkSchema.Type;

/**
* The task's id, whether it is archived, and where its board's live row
* stands, under the task row's lock until the transaction ends; nothing
* where the account reaches no such task.
*/
const lockTask = SqlSchema.findOneOption({
Request: TaskKeySchema,
Result: Schema.Struct({ id: Schema.String, archived: Schema.Boolean }),
Result: Schema.Struct({ id: Schema.String, archived: Schema.Boolean, ...LiveMarkSchema.fields }),
execute: ({ userId, taskId }) =>
Database.use((db) =>
db
.select({ id: task.id, archived: sql<boolean>`${task.archivedAt} is not null` })
.select({
id: task.id,
archived: sql<boolean>`${task.archivedAt} is not null`,
appliedDrawing: taskBoard.appliedDrawing,
imaged: sql<
boolean | null
>`case when ${taskBoard.taskId} is null then null else ${taskBoard.image} is not null end`,
})
.from(task)
.leftJoin(taskBoard, eq(taskBoard.taskId, task.id))
.where(reachableTask(userId, taskId))
.for("update"),
.for("update", { of: task }),
),
});

Expand All @@ -97,7 +127,7 @@ const lockTask = SqlSchema.findOneOption({
function upsertScene(elements: readonly BoardElement[], image: string | undefined) {
return SqlSchema.findOne({
Request: Schema.Struct({ taskId: Schema.String, appliedDrawing: Schema.Int, now: Schema.Date }),
Result: BoardRowSchema,
Result: SavedSceneRowSchema,
execute: ({ taskId, appliedDrawing, now }) => {
const scene = { elements, appliedDrawing, updatedAt: now };
// Note that a save moving to a new drawing with no image drops the old one, which shows a drawing gone.
Expand All @@ -110,7 +140,11 @@ function upsertScene(elements: readonly BoardElement[], image: string | undefine
target: taskBoard.taskId,
set: { ...scene, image: image ?? kept },
})
.returning(BOARD_COLUMNS),
.returning({
...BOARD_COLUMNS,
imaged: sql<boolean>`${taskBoard.image} is not null`,
txid: TXID,
}),
);
},
});
Expand Down Expand Up @@ -171,18 +205,22 @@ function boardOf(row: BoardRow): Board {
* Runs one write under the task row's lock, handed whether the task is
* archived as the lock read it; nothing where the account reaches no such task.
*/
function underTaskLock<E>(
function underTaskLock<A, E>(
userId: string,
taskId: string,
write: (now: Date, archived: boolean) => Effect.Effect<BoardRow, E, DatabaseServices>,
write: (
now: Date,
locked: { readonly archived: boolean; readonly mark: LiveMark },
) => Effect.Effect<A, E, DatabaseServices>,
) {
return Effect.flatMap(SqlClient.SqlClient, (client) =>
client.withTransaction(
Effect.gen(function* () {
const locked = yield* lockTask({ userId, taskId });
if (Option.isNone(locked)) return Option.none<Board>();
if (Option.isNone(locked)) return Option.none<A>();
const now = yield* DateTime.nowAsDate;
return Option.some(boardOf(yield* write(now, locked.value.archived)));
const { archived, appliedDrawing, imaged } = locked.value;
return Option.some(yield* write(now, { archived, mark: { appliedDrawing, imaged } }));
}),
),
);
Expand Down Expand Up @@ -211,24 +249,42 @@ export function readBoardImage(

/**
* The Mac's scene, written whole with the number of Luke's drawing it holds
* and any image of it; the board as written. An archived task takes no scene
* of the developer's, read under the same lock, while a drawing of Luke's on
* a call that stood when it was archived still lands.
* and any image of it; the board as written and, where the write moved the
* board's live row (`boardLiveStatement`), the id of the transaction that
* wrote it, which a desktop following the board waits on before it
* confirms the save, as Neon's guide to optimistic mutations has it. A save
* that leaves that row as it stood, such as one moving only the scene's
* elements, answers none, since no follower would ever see its
* transaction. An archived task takes no scene of the developer's, read
* under the same lock, while a drawing of Luke's on a call that stood when
* it was archived still lands.
*/
export function writeScene(
userId: string,
taskId: string,
elements: readonly BoardElement[],
appliedDrawing: number,
image?: string,
): Effect.Effect<Option.Option<Board>, TaskStoreFailure | TaskArchived, DatabaseServices> {
return underTaskLock(userId, taskId, (now, archived) =>
archived
? Effect.fail(new TaskArchived())
: upsertScene(
elements,
image,
)({ taskId, appliedDrawing, now }).pipe(Effect.catchTag("NoSuchElementError", Effect.die)),
): Effect.Effect<
Option.Option<{ readonly board: Board; readonly txid?: string }>,
TaskStoreFailure | TaskArchived,
DatabaseServices
> {
return underTaskLock(userId, taskId, (now, { archived, mark }) =>
Effect.andThen(
archived ? Effect.fail(new TaskArchived()) : Effect.void,
upsertScene(
elements,
image,
)({ taskId, appliedDrawing, now }).pipe(
Effect.catchTag("NoSuchElementError", Effect.die),
Effect.map((row) => {
const board = boardOf(row);
const moved = mark.appliedDrawing !== row.appliedDrawing || mark.imaged !== row.imaged;
return moved ? { board, txid: row.txid } : { board };
Comment on lines +283 to +284

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Concurrent board saves return phantom confirmations

When writeScene waits behind another save, mark can reflect the board before that save commits. An unchanged live row then returns a txid, leaving its follower waiting for a change it cannot see.

Learn more

lockTask joins task_board in the same SELECT FOR UPDATE OF task that waits for the task lock. Under PostgreSQL READ COMMITTED, the waiting statement can retain an earlier snapshot for the unlocked joined board row, even after it obtains the task lock. The upsert runs afterward and observes the committed board, so comparing its result with the earlier mark can incorrectly claim that its transaction changed the live query.

Example: Save A inserts the first board row with appliedDrawing: 0; save B starts its lock query while A holds the task lock. B sees a null joined board mark, then A commits. B writes the same appliedDrawing: 0 with no image and returns a txid, although the live row stays unchanged.

Recommended fix: Acquire the task lock in one statement, then read the board mark in a separate statement under that lock before upsertScene. This gives the comparison a snapshot taken after any preceding save commits. Add a PostgreSQL concurrency test that overlaps two saves; PGlite serializes access to its connection and cannot reproduce this ordering.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}),
),
),
);
}

Expand All @@ -240,7 +296,10 @@ export function writeDrawing(
): TaskStoreEffect<Option.Option<Board>> {
// An upsert that returned no row is the database breaking its own contract, not an outcome.
return underTaskLock(userId, taskId, (now) =>
upsertDrawing(drawing)({ taskId, now }).pipe(Effect.catchTag("NoSuchElementError", Effect.die)),
upsertDrawing(drawing)({ taskId, now }).pipe(
Effect.catchTag("NoSuchElementError", Effect.die),
Effect.map(boardOf),
),
);
}

Expand Down
16 changes: 15 additions & 1 deletion packages/features/src/board-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,23 @@ export const boardSchema = EffectSchema.Struct({

export type Board = typeof boardSchema.Type;

/** A board read (GET), and what a scene's write answers (PUT). */
/** A board read (GET). */
export const boardAnswerSchema = EffectSchema.Struct({ board: boardSchema });

/**
* What a scene's write answers (PUT): the board as written, and, where the
* write moved the board's live row (`boardLiveStatement`), the id of the
* transaction that wrote it as a decimal string, which a desktop following
* the board over Neon Realtime waits to see before it confirms the save. A
* save the live row does not show answers none, so there is nothing to wait for.
*/
export const boardSaveAnswerSchema = EffectSchema.Struct({
board: boardSchema,
txid: EffectSchema.optionalKey(EffectSchema.String.check(EffectSchema.isPattern(/^\d+$/))),
});

export type BoardSaveAnswer = typeof boardSaveAnswerSchema.Type;

/**
* The Mac's write (PUT): the whole scene, and the number of Luke's drawing it
* holds; with the scene drawn as a PNG, base64, when this write is the first
Expand Down
9 changes: 7 additions & 2 deletions packages/features/src/contract/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
import { HTTP_METHOD } from "@luke/wire";
import { openapi } from "@orpc/openapi";
import { Schema as EffectSchema } from "effect";
import { BOARD_BOUNDS, boardAnswerSchema, boardSaveRequestSchema } from "../board-wire.js";
import {
BOARD_BOUNDS,
boardAnswerSchema,
boardSaveAnswerSchema,
boardSaveRequestSchema,
} from "../board-wire.js";
import { MAXIMUM_START_BODY_BYTES } from "../coding-agent-wire.js";
import { planEditAnswerSchema, planEditRequestSchema } from "../plan-edit.js";
import { HOSTED_SERVICE_PATH } from "../service-paths.js";
Expand Down Expand Up @@ -132,7 +137,7 @@ export const tasksContract = {
.meta(bodyCap(BOARD_BODY_BYTES))
.meta(openapi({ method: HTTP_METHOD.PUT, path: `${PLAN}/board`, inputStructure: "detailed" }))
.input(exactBodyInput(EffectSchema.Struct(taskParams), boardSaveRequestSchema))
.output(EffectSchema.flip(boardAnswerSchema)),
.output(EffectSchema.flip(boardSaveAnswerSchema)),
/** The developer's edit of the document. */
edit: hostedProcedure
.meta(bodyCap(EDIT_BODY_BYTES))
Expand Down
17 changes: 7 additions & 10 deletions packages/features/src/task-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type CallCredential,
callAnswered,
} from "./account-call.js";
import type { Board, BoardElement } from "./board-wire.js";
import type { Board, BoardElement, BoardSaveAnswer } from "./board-wire.js";
import { startRefusedAs } from "./coding-agent-client.js";
import type { CodingAgentAgentAnswer } from "./coding-agent-view.js";
import { CODING_AGENT_ATTACHMENT_TYPE } from "./coding-agent-wire.js";
Expand Down Expand Up @@ -833,21 +833,18 @@ export class HostedTaskClient {
);
}

/** The board's scene written whole, with the number of Luke's drawing it holds and, when it is the first to hold it, the scene's image; the board as written, or nothing where the service did not answer. */
/** The board's scene written whole, with the number of Luke's drawing it holds and, when it is the first to hold it, the scene's image; the board as written and, where the write moved the board's live row, the id of its transaction, or nothing where the service did not answer. */
saveBoard(
taskId: string,
elements: readonly BoardElement[],
appliedDrawing: number,
image?: string,
): Effect.Effect<Board | undefined, never, HttpClient.HttpClient> {
): Effect.Effect<BoardSaveAnswer | undefined, never, HttpClient.HttpClient> {
return orNothing(
Effect.map(
askHosted(this.#hosted.plans.saveBoard, {
params: { id: taskId },
body: { elements, appliedDrawing, ...(image === undefined ? undefined : { image }) },
}),
(answer) => answer.board,
),
askHosted(this.#hosted.plans.saveBoard, {
params: { id: taskId },
body: { elements, appliedDrawing, ...(image === undefined ? undefined : { image }) },
}),
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/host/src/compose-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,7 +985,7 @@ function fakeService(tasks: Task[]): FakeService {
service.images[taskId] = image;
const board = { ...service.boards[taskId], elements, appliedDrawing };
service.boards[taskId] = board;
return board;
return { board };
}),
listLive: () =>
Effect.sync(() => {
Expand Down
Loading
Loading