Skip to content
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

Store Kafka messages in user session #106

Merged
merged 3 commits into from
Sep 25, 2024
Merged

Store Kafka messages in user session #106

merged 3 commits into from
Sep 25, 2024

Conversation

cc-a
Copy link
Contributor

@cc-a cc-a commented Sep 24, 2024

Description

Moves from storing Kafka messages in a hacky global variable to using the Session data of active users. This works by:

  • Running the Kafka consumer as a Django admin command. This means it has access to the Django context and settings and can directly populate messages into any active user sessions (i.e. the database).
  • Having the index view pop all messages for the session of the user making the request.
  • Using appropriate database settings and atomic transactions to prevent race conditions between the consumer process and the web app.

This approach is a fairly basic but gives some nice properties:

  • storing messages in the session of each individual user gives an easy mechanism for telling which messages have been displayed to which user.
  • the lifetime of messages stored in the sessions is bounded by the standard handling of Django session data (though see below caveat).
  • having the consumer populate into the database directly avoids having to think about how to protect an API end point.
  • readily supports multiple threads/processes/replicas of the web application.

One caveat is the need to clear the session store in deployment - https://docs.djangoproject.com/en/5.1/topics/http/sessions/#clearing-the-session-store. We should also agree a session expiry period with the project team.

Fixes #76.

Type of change

  • Documentation (non-breaking change that adds or improves the documentation)
  • New feature (non-breaking change which adds functionality)
  • Optimization (non-breaking, back-end change that speeds up the code)
  • Bug fix (non-breaking change which fixes an issue)
  • Breaking change (whatever its nature)

Key checklist

  • All tests pass (eg. python -m pytest)
  • The documentation builds and looks OK (eg. python -m sphinx -b html docs docs/build)
  • Pre-commit hooks run successfully (eg. pre-commit run --all-files)

Further checks

  • Code is commented, particularly in hard-to-understand areas
  • Tests added or an issue has been opened to tackle that in the future. (Indicate issue here: # (issue))

@codecov-commenter
Copy link

codecov-commenter commented Sep 24, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (3864185) to head (76c440a).
Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #106   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            5         5           
  Lines           43        44    +1     
=========================================
+ Hits            43        44    +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Contributor

@alexdewar alexdewar left a comment

Choose a reason for hiding this comment

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

Some v minor suggestions, but otherwise looks good 👍.

@@ -2,11 +2,10 @@ services:
app:
build: .
command:
- sh
- bash
Copy link
Contributor

Choose a reason for hiding this comment

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

Did plain old sh not work?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not a strictly necessary change in this PR. I found that bash seems to be better at forwarding interrupt signals to child processes so services shutdown more quickly and cleanly.

docker-compose.yml Show resolved Hide resolved
"""Add commandline options."""
parser.add_argument("--debug", action="store_true")

def handle(self, debug: bool = False, **kwargs: Any) -> None: # type: ignore[misc]
Copy link
Contributor

Choose a reason for hiding this comment

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

Presumably this is because we disallow explicit Any? Tbh I'm not sure that setting is super helpful -- there are cases (like this one) where an explicit Any makes sense. Maybe we should change that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I actually like to disallow explicity Any as I think Any's can be a bit of a crutch so prefer that we think carefully about where we use them and explicitly silence the check where necessary.

self.stdout.flush()
bm = BroadcastMessage()
bm.ParseFromString(message.value)
message_bodies.append(bm.data.value.decode("utf-8"))
Copy link
Contributor

Choose a reason for hiding this comment

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

Encoding defaults to UTF-8:

Suggested change
message_bodies.append(bm.data.value.decode("utf-8"))
message_bodies.append(bm.data.value.decode())

Copy link
Collaborator

@dalonsoa dalonsoa left a comment

Choose a reason for hiding this comment

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

Looks very neat!

Comment on lines +48 to +54
kafka_consumer:
build: .
command:
- bash
- -c
- |
python manage.py kafka_consumer --debug
Copy link
Collaborator

Choose a reason for hiding this comment

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

So, just to make sure I get this, we are using the very same code AND database in both apps, but in one we run the server and in the other we run the kafka_consumer. Is that to better use the compute resources or to better manage the different components in case one of them fail?

Copy link
Contributor Author

@cc-a cc-a Sep 25, 2024

Choose a reason for hiding this comment

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

That's right. Ideally Docker Compose is built around the idea of only having one process running in each container. Whilst we could run this is the background under the app service we would indeed struggle with handling error states. I also think it's cleaner to have the consumer logs in a separate feed.

Ideally we might also run the ssh server in the drunc service as a separate service but there is a hard coded restriction in the dummy_boot command that the process must be started on localhost.

It's also a nice proof of principle that these things can be run in separate places which will be important for deployment where we may have multiple copies of the app running but will only ever want one consumer.

@cc-a
Copy link
Contributor Author

cc-a commented Sep 25, 2024

Thanks for the reviews so far. I'm planning to merge this towards the end of the day.

@cc-a cc-a enabled auto-merge September 25, 2024 17:08
@cc-a cc-a merged commit a34e2c9 into main Sep 25, 2024
4 checks passed
@cc-a cc-a deleted the messages-in-user-session branch September 25, 2024 17:09
Copy link
Contributor

@jamesturner246 jamesturner246 left a comment

Choose a reason for hiding this comment

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

Okay, I follow the logic. Apologies; pile of questions as usual.

Messages are polled from Kafka by a special meta/admin Django function non-stop.

Users who are logged in at a given moment have their sessions atomically updated with messages.

This means that if one user pops the messages, they are not popped for all users.

sessions = Session.objects.all()
for session in sessions:
store = SessionStore(session_key=session.session_key)
store.setdefault("messages", []).extend(message_bodies)
Copy link
Contributor

Choose a reason for hiding this comment

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

So setdefault handles where messages hasn't been added to session, and extend is a funny way of saying append?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right. setdefault is shorthand for the below pattern:

value = mydict.get("key")
if value is None:
    value = []
    mydict["key"] = value

Extend is basically append but it takes an iterator of values to add rather than a single one. The key thing is that it mutates the existing list.

with transaction.atomic():
# atomic here to prevent race condition with messages being
# popped by the web application
sessions = Session.objects.all()
Copy link
Contributor

@jamesturner246 jamesturner246 Sep 27, 2024

Choose a reason for hiding this comment

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

This gets all currently logged in users' sessions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It gets all sessions in the database. This broadly corresponds to logged in users subject to the caveat I mention above.

Copy link
Contributor

Choose a reason for hiding this comment

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

This command file is in some magic directory which is automatically loaded by Django?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Kafka with user sessions
5 participants