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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ flowchart LR

Events are delivered at most once. A proxy that reconnects does not receive events it missed, so a warp that was lost can be repeated with `/party warp`.

**Lonely parties.** A party that still has only its creator after `QUELEA_PARTY_ALONE_TIMEOUT` is disbanded. `QUELEA_PARTY_ALONE_WARNING` before that, the service publishes `party.expiring` with the expiry time, and the proxy tells the leader how many minutes are left. Expired parties are disbanded with the reason `expired-alone`. Every `party.disbanded` event carries a `reason`: `leader` (the leader disbanded), `last-member-left` (only one member remained) or `expired-alone`. Clients must treat an unknown reason like `leader`.
**Lonely parties.** A party that has only one member after `QUELEA_PARTY_ALONE_TIMEOUT` is disbanded. The timeout counts from the creation or from the leave or kick that left that one member, and a join ends it. A leave or kick that leaves one member keeps the party open. `QUELEA_PARTY_ALONE_WARNING` before that, the service publishes `party.expiring` with the expiry time, and the proxy tells the leader how many minutes are left. Expired parties are disbanded with the reason `expired-alone`. Every `party.disbanded` event carries a `reason`: `leader` (the leader disbanded), `last-member-left` (no member remained) or `expired-alone`. Clients must treat an unknown reason like `leader`.

**Party versions and conditional requests.** Every party has a `version`: 1 when it is created, +1 with every change. It is in the party JSON and in every event. `GET /v1/parties/{id}` and `GET /v1/players/{id}/party` send it as a strong `ETag` (for example `"4"`) and answer `304 Not Modified` without a body when `If-None-Match` names the current tag. `kick`, `promote`, `disband` and `warp` accept `If-Match` with one or more strong tags or `*`. If none matches the current version, the command changes nothing and answers `412 precondition-failed`. A weak tag (`W/`) never matches. Without the header the command runs unconditionally.

Expand Down Expand Up @@ -87,7 +87,7 @@ Set these on the service. Defaults are in `backend/src/main/resources/applicatio
| `QUELEA_REDIS_LOCK_WAIT` | `1s` | How long a request waits for a busy party lock before it fails with `lock-timeout`. |
| `QUELEA_PARTY_MAX_SIZE` | `8` | Maximum members per party, counting the leader. Minimum 2. |
| `QUELEA_INVITE_TTL` | `60s` | How long an invite stays valid. Re-inviting refreshes it. |
| `QUELEA_PARTY_ALONE_TIMEOUT` | `30m` | A party that has only its creator for this long is disbanded with reason `expired-alone`. |
| `QUELEA_PARTY_ALONE_TIMEOUT` | `30m` | A party that has only one member for this long is disbanded with reason `expired-alone`. |
| `QUELEA_PARTY_ALONE_WARNING` | `5m` | `party.expiring` is published this long before the expiry. `0s` disables the warning. |
| `QUELEA_PARTY_EXPIRY_INTERVAL` | `15s` | How often the expiry job runs; one replica per run, under a lock. The expiry can be this late. |
| `QUELEA_PROXY_TIMEOUT` | `45s` | A proxy without a heartbeat for this long is treated as dead; its players are disconnected from their parties. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,26 @@
/**
* A party in the order its members joined; the leader is one of the members. {@code version} is 1 when the party is
* created and grows by one with every change of the party's state; it is the basis of the HTTP entity tag.
* {@code aloneSince} is the start of the current alone period (one member), or {@code null} when the party has two or
* more members or was stored before the field existed; {@link #aloneStart()} resolves the null case to the creation.
*/
@Serdeable
public record Party(UUID id, UUID leaderId, List<Member> members, Instant createdAt, long version) {
public record Party(UUID id, UUID leaderId, List<Member> members, Instant createdAt, long version, Instant aloneSince) {

public Party {
members = List.copyOf(members);
}

/** A party without an alone-since time, as before the field existed. */
public Party(UUID id, UUID leaderId, List<Member> members, Instant createdAt, long version) {
this(id, leaderId, members, createdAt, version, null);
}

/** When the current alone period began: the stored alone-since time, or the creation for a legacy party. */
public Instant aloneStart() {
return aloneSince != null ? aloneSince : createdAt;
}

public boolean isMember(UUID playerId) {
return member(playerId).isPresent();
}
Expand All @@ -30,13 +42,13 @@ public boolean isLeader(UUID playerId) {
return leaderId.equals(playerId);
}

/** The party after a change of its members: the next version. */
/** The party after a join: the next version, with no alone period (a join ends the one before). */
public Party withMembers(List<Member> newMembers) {
return new Party(id, leaderId, newMembers, createdAt, version + 1);
return new Party(id, leaderId, newMembers, createdAt, version + 1, null);
}

/** The party after a change of its leader: the next version. */
public Party withLeader(UUID newLeaderId) {
return new Party(id, newLeaderId, members, createdAt, version + 1);
return new Party(id, newLeaderId, members, createdAt, version + 1, aloneSince);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public Party create(UUID leaderId) {
throw new PartyException(PartyProblem.ALREADY_IN_PARTY, "The player is already in a party.");
}
Instant now = clock.instant();
Party party = new Party(partyId, leaderId, List.of(new Member(leaderId, leader.name(), now)), now, 1);
Party party = new Party(partyId, leaderId, List.of(new Member(leaderId, leader.name(), now)), now, 1, now);
parties.save(party);
markAlone(party);
statistics.created();
Expand Down Expand Up @@ -254,7 +254,7 @@ public void warnExpiring(UUID partyId) {
if (party == null || party.members().size() != 1) {
return null;
}
publish(PartyEventType.EXPIRING, party, Map.of("expiresAt", party.createdAt().plus(settings.getAloneTimeout())));
publish(PartyEventType.EXPIRING, party, Map.of("expiresAt", party.aloneStart().plus(settings.getAloneTimeout())));
return null;
});
}
Expand Down Expand Up @@ -292,8 +292,9 @@ public void chat(UUID partyId, UUID actorId, String message) {
}

/**
* Removes {@code gone} (after LEFT or KICKED), then either disbands a party of at most one member or hands
* leadership to the longest-standing remaining member. Returns the party state after the change.
* Removes {@code gone} (after LEFT or KICKED). A party left with no member is disbanded; a party left with one
* member stays open and starts a new alone period at this moment; a larger party keeps its members. Leadership
* passes to the longest-standing remaining member when the leader left. Returns the party state after the change.
*/
private Party removeMember(Party party, Member gone, PartyEventType type, Map<String, Object> payload) {
List<Member> remaining = party.members().stream()
Expand All @@ -307,14 +308,19 @@ private Party removeMember(Party party, Member gone, PartyEventType type, Map<St
.map(Member::id)
.orElseThrow();
}
Party after = new Party(party.id(), leaderId, remaining, party.createdAt(), party.version() + 1);
if (remaining.size() <= 1) {
if (remaining.isEmpty()) {
// Two changes in a row: the departure, then the disband. Each event carries its own version.
Party empty = new Party(party.id(), leaderId, remaining, party.createdAt(), party.version() + 1, null);
publish(type, party.id(), leaderId, remaining, party.version() + 1, payload);
disbandParty(party, REASON_LAST_MEMBER_LEFT, remaining, party.version() + 2);
return after;
return empty;
}
Instant aloneSince = remaining.size() == 1 ? clock.instant() : null;
Party after = new Party(party.id(), leaderId, remaining, party.createdAt(), party.version() + 1, aloneSince);
parties.save(after);
if (aloneSince != null) {
markAlone(after);
}
publish(type, after, payload);
if (!leaderId.equals(party.leaderId())) {
publish(PartyEventType.LEADER_CHANGED, after,
Expand Down Expand Up @@ -346,9 +352,12 @@ private void disbandParty(Party party, String reason, List<Member> stillBound, l
Map.of("formerMembers", formerMembers, "reason", reason));
}

/** A party of one member expires after the alone timeout and is warned before, unless the warning is off. */
/**
* A party of one member expires after the alone timeout, counted from the start of its alone period, and is warned
* before, unless the warning is off.
*/
private void markAlone(Party party) {
Instant expireAt = party.createdAt().plus(settings.getAloneTimeout());
Instant expireAt = party.aloneStart().plus(settings.getAloneTimeout());
Duration warning = settings.getAloneWarning();
Instant warnAt = warning.isZero() || warning.isNegative() ? null : expireAt.minus(warning);
parties.markAlone(party.id(), expireAt, warnAt);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public interface PartyStore {

/**
* Marks a party that has one member: its expiry is due at {@code expireAt}; its warning is due at
* {@code warnAt}, or never when {@code warnAt} is null.
* {@code warnAt}, or never when {@code warnAt} is null; a null {@code warnAt} also removes a warning set earlier.
*/
void markAlone(UUID partyId, Instant expireAt, Instant warnAt);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ public void markAlone(UUID partyId, Instant expireAt, Instant warnAt) {
commands.zadd(keys.aloneExpire(), (double) expireAt.toEpochMilli(), partyId.toString());
if (warnAt != null) {
commands.zadd(keys.aloneWarn(), (double) warnAt.toEpochMilli(), partyId.toString());
} else {
// A restarted alone period without warning must not keep the warning of the one before.
commands.zrem(keys.aloneWarn(), partyId.toString());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package net.onelitefeather.quelea.backend.party;

import io.micronaut.serde.ObjectMapper;
import org.junit.jupiter.api.Test;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

/** The stored form of a party, read and written through the same mapper the Redis store uses. */
class PartyJsonTest {

private static final Instant CREATED = Instant.parse("2026-01-01T12:00:00Z");
private static final UUID PARTY = UUID.fromString("00000000-0000-0000-0000-0000000000aa");
private static final UUID LEADER = UUID.fromString("00000000-0000-0000-0000-0000000000bb");

private final ObjectMapper json = ObjectMapper.getDefault();

@Test
void partyStoredBeforeTheAloneSinceFieldReadsItAsNullAndStartsAtCreation() throws Exception {
// The shape 0.4.0 wrote: no aloneSince key at all.
String legacy = """
{"id":"%s","leaderId":"%s","members":[{"id":"%s","name":"Alpha","joinedAt":"%s"}],\
"createdAt":"%s","version":3}""".formatted(PARTY, LEADER, LEADER, CREATED, CREATED);

Party party = json.readValue(legacy, Party.class);

assertNull(party.aloneSince(), "a party without the key must read the alone-since time as null");
assertEquals(CREATED, party.aloneStart(), "without an alone-since time, the alone period starts at creation");
assertEquals(3L, party.version(), "the rest of the stored party must read unchanged");
}

@Test
void aloneSinceSurvivesAWriteAndARead() throws Exception {
Instant departure = CREATED.plusSeconds(600);
Party alone = new Party(PARTY, LEADER, List.of(new Member(LEADER, "Alpha", CREATED)), CREATED, 4, departure);

Party read = json.readValue(json.writeValueAsString(alone), Party.class);

assertEquals(departure, read.aloneSince(), "the alone-since time must come back unchanged");
assertEquals(departure, read.aloneStart(), "the alone period must start at the departure, not at creation");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ void leaderLeavingMakesLongestStandingMemberLeader() {
}

@Test
void lastPartnerLeavingDisbandsPartyAndReleasesRemainingMember() {
void partnerLeavingKeepsThePartyOpenWithTheRemainingMember() {
PartyFixture f = new PartyFixture();
UUID alpha = f.online("Alpha");
UUID bravo = f.online("Bravo");
Expand All @@ -345,22 +345,23 @@ void lastPartnerLeavingDisbandsPartyAndReleasesRemainingMember() {

f.partyService.leave(bravo, "leave");

assertProblem(PartyProblem.NOT_IN_PARTY, () -> f.partyService.partyOf(alpha));
assertProblem(PartyProblem.PARTY_NOT_FOUND, () -> f.partyService.get(party.id()));
assertEquals("party.disbanded", lastEventType(f), "the party must be disbanded when one member remains");
assertEquals(party.id(), f.partyService.partyOf(alpha).id(), "the party must keep its id and the remaining member");
assertEquals(List.of(alpha), f.partyService.get(party.id()).members().stream().map(Member::id).toList(),
"the remaining member must be the only member");
assertFalse(f.events.types().contains("party.disbanded"), "a departure that leaves one member must not disband the party");
}

@Test
void lastPartnerLeavingCarriesReasonLastMemberLeft() {
void soloPartyLeavingDisbandsWithReasonLastMemberLeft() {
PartyFixture f = new PartyFixture();
UUID alpha = f.online("Alpha");
UUID bravo = f.online("Bravo");
Party party = join(f, alpha, "Bravo");
Party party = f.partyService.create(alpha);

f.partyService.leave(bravo, "leave");
f.partyService.leave(alpha, "leave");

assertProblem(PartyProblem.PARTY_NOT_FOUND, () -> f.partyService.get(party.id()));
assertEquals("last-member-left", lastEvent(f, "party.disbanded").payload().get("reason"),
"a party that drops to one member must be disbanded with reason last-member-left, whatever the leave reason");
"a party that loses its last member must be disbanded with reason last-member-left");
}

@Test
Expand Down Expand Up @@ -449,17 +450,18 @@ void kickRemovesMemberAndPublishesKicked() {
}

@Test
void kickOfLastOtherMemberDisbandsParty() {
void kickOfLastOtherMemberKeepsThePartyOpen() {
PartyFixture f = new PartyFixture();
UUID alpha = f.online("Alpha");
UUID bravo = f.online("Bravo");
Party party = join(f, alpha, "Bravo");

f.partyService.kick(party.id(), alpha, bravo);

assertProblem(PartyProblem.PARTY_NOT_FOUND, () -> f.partyService.get(party.id()));
assertProblem(PartyProblem.NOT_IN_PARTY, () -> f.partyService.partyOf(alpha));
assertEquals("party.disbanded", lastEventType(f), "a party of one must be disbanded");
assertEquals(party.id(), f.partyService.partyOf(alpha).id(), "the kick must keep the party with its remaining member");
assertEquals(List.of(alpha), f.partyService.get(party.id()).members().stream().map(Member::id).toList(),
"the leader must be the only member");
assertEquals("party.kicked", lastEventType(f), "the kick must be published, and no disband follows it");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,10 @@ void leaderDisbandCountsReasonLeaderAndRecordsLifetime() {
void lastMemberLeavingCountsReasonLastMemberLeftWithLifetime() {
PartyFixture f = new PartyFixture();
UUID leader = f.online("Alpha");
UUID bravo = f.online("Bravo");
f.partyService.create(leader);
Invite invite = f.partyService.invite(leader, "Bravo");
f.partyService.accept(bravo, invite.partyId());
f.clock.advance(Duration.ofMinutes(10));

f.partyService.leave(bravo, "quit");
f.partyService.leave(leader, "quit");

assertEquals(1.0, disbanded(f.meters, "last-member-left"), "the party must count reason last-member-left");
assertEquals(10.0, lifetime(f.meters, "last-member-left").totalTime(TimeUnit.MINUTES), 0.001,
Expand Down
Loading
Loading